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} + + )} + + +
+ + + + + Edit bang + +
+
+ + setDateValue(e.target.value)} + className="input input-sm" + required + /> +
+ {error && editOpen &&

{error}

} +
+ + + +
+
+ + { + setDeleteOpen(open); + if (!open) setError(null); + }} + > + + + Delete bang? + +

+ Remove the bang from {formatBangDate(bang.occurredOn)}? This cannot be undone. +

+ {error && deleteOpen &&

{error}

} + + + +
+
+
+ ); +} + 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(); +});