diff --git a/src/modules/bangs/components/bang-widget.tsx b/src/modules/bangs/components/bang-widget.tsx
index dadd032..62bca79 100644
--- a/src/modules/bangs/components/bang-widget.tsx
+++ b/src/modules/bangs/components/bang-widget.tsx
@@ -3,6 +3,7 @@
import { useState, useTransition, useRef, useCallback } from "react";
import { createPortal } from "react-dom";
import confetti from "canvas-confetti";
+import { Pencil, Trash2 } from "lucide-react";
import {
Dialog,
DialogContent,
@@ -11,8 +12,9 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
-import type { BangStatsDto } from "../server/queries";
-import { addBang } from "../server/actions";
+import { Button } from "@/components/ui/button";
+import type { BangStatsDto, RecentBangDto } from "../server/queries";
+import { addBang, deleteBang, updateBang } from "../server/actions";
// ─── Celebration engine ───────────────────────────────────────────────────────
@@ -301,14 +303,7 @@ export function BangWidget({ stats, maxRecentBangs }: Props) {
Last {Math.min(stats.recent.length, maxRecentBangs)}
{stats.recent.map((bang) => (
-
- {formatBangDate(bang.occurredOn)}
- {bang.recordedByName && (
-
- {bang.recordedByName}
-
- )}
-
+
))}
)}
@@ -320,6 +315,142 @@ export function BangWidget({ stats, maxRecentBangs }: Props) {
);
}
+function RecentBangRow({ bang }: { bang: RecentBangDto }) {
+ const [editOpen, setEditOpen] = useState(false);
+ const [deleteOpen, setDeleteOpen] = useState(false);
+ const [dateValue, setDateValue] = useState(bang.occurredOn);
+ const [isPending, startTransition] = useTransition();
+ const [error, setError] = useState(null);
+
+ function handleEditOpenChange(open: boolean) {
+ setEditOpen(open);
+ if (open) {
+ setDateValue(bang.occurredOn);
+ setError(null);
+ }
+ }
+
+ function handleEditSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ setError(null);
+ startTransition(async () => {
+ try {
+ await updateBang({ id: bang.id, occurredOn: dateValue });
+ setEditOpen(false);
+ } catch {
+ setError("Something went wrong. Try again.");
+ }
+ });
+ }
+
+ function handleDelete() {
+ setError(null);
+ startTransition(async () => {
+ try {
+ await deleteBang({ id: bang.id });
+ setDeleteOpen(false);
+ } catch {
+ setError("Something went wrong. Try again.");
+ }
+ });
+ }
+
+ return (
+
+
{formatBangDate(bang.occurredOn)}
+
+ {bang.recordedByName && (
+
+ {bang.recordedByName}
+
+ )}
+
+
+
+
+
+
+
+
+ );
+}
+
function todayValue(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
diff --git a/src/modules/bangs/manifest.tsx b/src/modules/bangs/manifest.tsx
index f774192..ba5657c 100644
--- a/src/modules/bangs/manifest.tsx
+++ b/src/modules/bangs/manifest.tsx
@@ -23,7 +23,11 @@ const bangsManifest: ModuleManifest = {
resolveUrl: () => "/",
renderActivity: (entry) => {
const date = entry.payload?.occurredOn as string | undefined;
- return date ? `Recorded a bang on ${date}` : "Recorded a bang";
+ const dateLabel = date ? ` on ${date}` : "";
+ if (entry.action === "create") return `Recorded a bang${dateLabel}`;
+ if (entry.action === "delete") return `Deleted a bang${dateLabel}`;
+ if (entry.action === "update") return `Updated a bang${dateLabel}`;
+ return `Recorded a bang${dateLabel}`;
},
},
],
diff --git a/src/modules/bangs/server/actions.ts b/src/modules/bangs/server/actions.ts
index 1aa8e7d..622ebc1 100644
--- a/src/modules/bangs/server/actions.ts
+++ b/src/modules/bangs/server/actions.ts
@@ -1,5 +1,6 @@
"use server";
+import { and, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { db } from "@/lib/db";
@@ -7,11 +8,19 @@ import { getCurrentSession } from "@/lib/session";
import { logActivity } from "@/modules/_core/activity";
import { bangEvents } from "../schema";
+const dateString = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Must be YYYY-MM-DD");
+
const addBangInput = z.object({
- occurredOn: z
- .string()
- .regex(/^\d{4}-\d{2}-\d{2}$/, "Must be YYYY-MM-DD")
- .optional(),
+ occurredOn: dateString.optional(),
+});
+
+const updateBangInput = z.object({
+ id: z.string().uuid(),
+ occurredOn: dateString,
+});
+
+const deleteBangInput = z.object({
+ id: z.string().uuid(),
});
function todayString(): string {
@@ -48,3 +57,63 @@ export async function addBang(input: z.input = {}) {
return bang;
}
+
+export async function updateBang(input: z.input) {
+ const parsed = updateBangInput.parse(input);
+ const { household } = await getCurrentSession();
+ await assertCanAccessBang(parsed.id, household.id);
+
+ const [bang] = await db
+ .update(bangEvents)
+ .set({ occurredOn: parsed.occurredOn })
+ .where(eq(bangEvents.id, parsed.id))
+ .returning();
+
+ if (!bang) throw new Error("Bang was not updated");
+
+ await logActivity({
+ entityType: "bangs.event",
+ entityId: bang.id,
+ action: "update",
+ payload: { occurredOn: parsed.occurredOn },
+ });
+
+ revalidatePath("/");
+ revalidatePath("/d/[slug]", "page");
+
+ return bang;
+}
+
+export async function deleteBang(input: z.input) {
+ const parsed = deleteBangInput.parse(input);
+ const { household } = await getCurrentSession();
+ await assertCanAccessBang(parsed.id, household.id);
+
+ const [existing] = await db
+ .select({ occurredOn: bangEvents.occurredOn })
+ .from(bangEvents)
+ .where(eq(bangEvents.id, parsed.id))
+ .limit(1);
+
+ await logActivity({
+ entityType: "bangs.event",
+ entityId: parsed.id,
+ action: "delete",
+ payload: existing ? { occurredOn: existing.occurredOn } : undefined,
+ });
+
+ await db.delete(bangEvents).where(eq(bangEvents.id, parsed.id));
+
+ revalidatePath("/");
+ revalidatePath("/d/[slug]", "page");
+}
+
+async function assertCanAccessBang(bangId: string, householdId: string) {
+ const [bang] = await db
+ .select({ id: bangEvents.id })
+ .from(bangEvents)
+ .where(and(eq(bangEvents.id, bangId), eq(bangEvents.householdId, householdId)))
+ .limit(1);
+
+ if (!bang) throw new Error("Forbidden");
+}
diff --git a/tests/e2e/bangs.spec.ts b/tests/e2e/bangs.spec.ts
new file mode 100644
index 0000000..b2511db
--- /dev/null
+++ b/tests/e2e/bangs.spec.ts
@@ -0,0 +1,62 @@
+import { expect, test, type Page } from "@playwright/test";
+
+async function ensureSignedIn(page: Page) {
+ await page.goto("/");
+ const devLogin = page.getByRole("button", { name: "Dev login" });
+ if (await devLogin.isVisible().catch(() => false)) {
+ await devLogin.click();
+ await page.waitForURL((url) => !url.pathname.startsWith("/login"));
+ }
+ await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
+}
+
+function formatBangDate(iso: string): string {
+ const [year, month, day] = iso.split("-").map(Number);
+ const d = new Date(year!, month! - 1, day!);
+ return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
+}
+
+function bangWidget(page: Page) {
+ return page.locator('[data-slot="card"]').filter({
+ has: page.getByRole("heading", { name: "Bang Counter" }),
+ });
+}
+
+test("bangs happy path", async ({ page }) => {
+ const originalDate = "2026-03-10";
+ const editedDate = "2026-04-20";
+
+ await ensureSignedIn(page);
+
+ const widget = bangWidget(page);
+ await expect(widget).toBeVisible();
+
+ await widget.getByRole("button", { name: "Add Bang" }).click();
+ await expect(page.getByRole("dialog", { name: "Record a bang" })).toBeVisible();
+ await page.getByLabel("Date").fill(originalDate);
+ await page.getByRole("button", { name: "Record it" }).click();
+ await expect(page.getByRole("dialog", { name: "Record a bang" })).toBeHidden();
+
+ const originalLabel = formatBangDate(originalDate);
+ await expect(widget.getByText(originalLabel, { exact: true })).toBeVisible();
+
+ await widget.getByRole("button", { name: `Edit bang on ${originalDate}` }).click();
+ await expect(page.getByRole("dialog", { name: "Edit bang" })).toBeVisible();
+ await page.getByLabel("Date").fill(editedDate);
+ await page.getByRole("button", { name: "Save" }).click();
+ await expect(page.getByRole("dialog", { name: "Edit bang" })).toBeHidden();
+
+ const editedLabel = formatBangDate(editedDate);
+ await expect(widget.getByText(editedLabel, { exact: true })).toBeVisible();
+ await expect(widget.getByText(originalLabel, { exact: true })).toBeHidden();
+
+ await widget.getByRole("button", { name: `Delete bang on ${editedDate}` }).click();
+ await expect(page.getByRole("dialog", { name: "Delete bang?" })).toBeVisible();
+ await page
+ .getByRole("dialog", { name: "Delete bang?" })
+ .getByRole("button", { name: "Delete" })
+ .click();
+ await expect(page.getByRole("dialog", { name: "Delete bang?" })).toBeHidden();
+
+ await expect(widget.getByText(editedLabel, { exact: true })).toBeHidden();
+});