feat: bang edit and delete

This commit is contained in:
ginnoir
2026-07-04 18:20:38 -05:00
parent 1a03763e1b
commit 753f653b89
4 changed files with 281 additions and 15 deletions
+141 -10
View File
@@ -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)}
</p>
{stats.recent.map((bang) => (
<div key={bang.id} className="flex items-center justify-between gap-2 py-0.5">
<span className="text-sm">{formatBangDate(bang.occurredOn)}</span>
{bang.recordedByName && (
<span className="max-w-[40%] truncate text-xs text-[var(--ink-mute)]">
{bang.recordedByName}
</span>
)}
</div>
<RecentBangRow key={bang.id} bang={bang} />
))}
</div>
)}
@@ -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<string | null>(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 (
<div className="group/row flex items-center justify-between gap-2 py-0.5">
<span className="text-sm">{formatBangDate(bang.occurredOn)}</span>
<div className="flex min-w-0 items-center gap-1">
{bang.recordedByName && (
<span className="max-w-[5rem] truncate text-xs text-[var(--ink-mute)] sm:max-w-[6rem]">
{bang.recordedByName}
</span>
)}
<Button
size="icon-sm"
variant="ghost"
aria-label={`Edit bang on ${bang.occurredOn}`}
onClick={() => setEditOpen(true)}
>
<Pencil className="size-3.5" />
</Button>
<Button
size="icon-sm"
variant="ghost"
aria-label={`Delete bang on ${bang.occurredOn}`}
onClick={() => setDeleteOpen(true)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
<Dialog open={editOpen} onOpenChange={handleEditOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit bang</DialogTitle>
</DialogHeader>
<form
id={`edit-bang-${bang.id}`}
onSubmit={handleEditSubmit}
className="flex flex-col gap-3 pt-1"
>
<div className="flex flex-col gap-1">
<label htmlFor={`edit-bang-date-${bang.id}`} className="text-sm font-medium">
Date
</label>
<input
id={`edit-bang-date-${bang.id}`}
type="date"
value={dateValue}
onChange={(e) => setDateValue(e.target.value)}
className="input input-sm"
required
/>
</div>
{error && editOpen && <p className="text-sm text-red-500">{error}</p>}
</form>
<DialogFooter showCloseButton>
<button
type="submit"
form={`edit-bang-${bang.id}`}
disabled={isPending}
className="btn btn-primary btn-sm"
>
{isPending ? "Saving…" : "Save"}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={deleteOpen}
onOpenChange={(open) => {
setDeleteOpen(open);
if (!open) setError(null);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete bang?</DialogTitle>
</DialogHeader>
<p className="text-sm text-[var(--ink-mute)]">
Remove the bang from {formatBangDate(bang.occurredOn)}? This cannot be undone.
</p>
{error && deleteOpen && <p className="text-sm text-red-500">{error}</p>}
<DialogFooter showCloseButton>
<button
type="button"
disabled={isPending}
onClick={handleDelete}
className="btn btn-sm bg-red-600 text-white hover:bg-red-700"
>
{isPending ? "Deleting…" : "Delete"}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function todayValue(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
+5 -1
View File
@@ -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}`;
},
},
],
+73 -4
View File
@@ -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<typeof addBangInput> = {}) {
return bang;
}
export async function updateBang(input: z.input<typeof updateBangInput>) {
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<typeof deleteBangInput>) {
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");
}
+62
View File
@@ -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();
});