From 6e2e9f22a6e952af42e30a821fa538f9d98852e7 Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Fri, 11 Sep 2026 21:35:16 -0400 Subject: [PATCH] Add event dashboard stats and tolerate repeated bulk approvals --- .../events/[id]/bulk-photo-actions.tsx | 20 +++++++++++------- .../app/dashboard/events/[id]/event-stats.tsx | 21 +++++++++++++++++++ .../src/app/dashboard/events/[id]/page.tsx | 6 +++++- apps/web/src/server/bulk-photos.ts | 3 ++- .../submission-groups.integration.test.ts | 16 ++++++++++++++ 5 files changed, 57 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/app/dashboard/events/[id]/event-stats.tsx diff --git a/apps/web/src/app/dashboard/events/[id]/bulk-photo-actions.tsx b/apps/web/src/app/dashboard/events/[id]/bulk-photo-actions.tsx index 46f148a..5924d2b 100644 --- a/apps/web/src/app/dashboard/events/[id]/bulk-photo-actions.tsx +++ b/apps/web/src/app/dashboard/events/[id]/bulk-photo-actions.tsx @@ -24,7 +24,11 @@ export function BulkPhotoActions({ eventId, rows, selected, setSelected, canMode setReport(allResults); setSelected(allResults.filter(row => row.status === "failed" || row.status === "skipped").map(row => row.photoId)); setPending(null); - toast(`${result.affected} photos changed; ${allResults.length - result.affected} skipped or failed`); + const unchanged = allResults.filter(row => row.status === "unchanged").length; + const issues = allResults.filter(row => row.status === "failed" || row.status === "skipped").length; + const message = `${result.affected} updated${unchanged ? ` · ${unchanged} already set` : ""}${issues ? ` · ${issues} need attention` : ""}`; + if (issues) toast.warning(message); else toast.success(message); + void utils.manager.stats.invalidate({ eventId }); await utils.manager.photos.invalidate({ eventId }); }, onError: error => toast.error(error.message), }); @@ -32,15 +36,17 @@ export function BulkPhotoActions({ eventId, rows, selected, setSelected, canMode const actions: Action[] = [...(canModerate ? ["public", "hidden", ...(canPrivate ? ["private"] : []), "rejected"] as Action[] : []), ...(canDelete ? ["delete" as const] : [])]; const icons = { public: CheckIcon, hidden: EyeOffIcon, private: LockIcon, rejected: XIcon, delete: Trash2Icon }; return
-
+
{selected.length} selected · up to 100 at a time +
- {canModerate ? : null} + {canModerate ? : null} +
- {selected.length ?
{actions.map(action => { + {selected.length ?
{actions.map(action => { const Icon = icons[action]; - return ; + return ; })}
: null} {report.length ?
Last bulk action · {report.length} results
    {report.map(row =>
  • {row.photoId.slice(0, 8)} · {row.status}{row.reason ? ` — ${row.reason}` : ""}
  • )}
: null} { if (!open && !apply.isPending) setPending(null); }}> @@ -48,7 +54,7 @@ export function BulkPhotoActions({ eventId, rows, selected, setSelected, canMode {pending?.action === "delete" ? "Deletion is permanent and removes originals and generated images." : "Only the selected photos will change. New uploads will not be included."} {preview.isLoading ?

Checking selection…

: preview.isError ?

{preview.error.message}

: <> -

{preview.data?.affected ?? 0} eligible · {(preview.data?.results.length ?? 0) - (preview.data?.affected ?? 0)} skipped

+

{preview.data?.affected ?? 0} to update · {preview.data?.results.filter(row => row.status === "unchanged").length ?? 0} already set · {preview.data?.results.filter(row => row.status === "skipped" || row.status === "failed").length ?? 0} unavailable

{pending?.photoIds.map(id => { const photo = rows.find(row => row.id === id); // eslint-disable-next-line @next/next/no-img-element @@ -57,7 +63,7 @@ export function BulkPhotoActions({ eventId, rows, selected, setSelected, canMode {preview.data?.results.filter(row => row.reason).map(row =>

{row.photoId.slice(0, 8)}: {row.reason}

)} } - diff --git a/apps/web/src/app/dashboard/events/[id]/event-stats.tsx b/apps/web/src/app/dashboard/events/[id]/event-stats.tsx new file mode 100644 index 0000000..bbc3337 --- /dev/null +++ b/apps/web/src/app/dashboard/events/[id]/event-stats.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { api } from "@/trpc/react"; +import { Card, CardHeader, CardDescription, CardContent } from "@/components/ui/card"; + +const metrics = [ + ["photos", "Photos"], ["approved", "Gallery-ready"], + ["awaiting_review", "Awaiting review"], ["failed", "Processing failed"], + ["guests", "Guests"], ["notes", "Notes"], +] as const; + +export function EventStats({ eventId }: { eventId: string }) { + const stats = api.manager.stats.useQuery({ eventId }, { refetchInterval: 15000, refetchIntervalInBackground: false }); + if (stats.isError) return

Event stats couldn’t load. Refresh the page to try again.

; + return
+ {metrics.map(([key, label]) => + {label} +

{stats.data ? Number(stats.data[key] ?? 0).toLocaleString() : "—"}

+
)} +
; +} diff --git a/apps/web/src/app/dashboard/events/[id]/page.tsx b/apps/web/src/app/dashboard/events/[id]/page.tsx index a1456fd..20c58c6 100644 --- a/apps/web/src/app/dashboard/events/[id]/page.tsx +++ b/apps/web/src/app/dashboard/events/[id]/page.tsx @@ -15,6 +15,7 @@ import { EventSchedule } from "./event-schedule"; import { eventStatusLabel } from "@/lib/event-status"; import { effectiveEvent } from "@/lib/event-lifecycle"; import { EventExports } from "./event-exports"; +import { EventStats } from "./event-stats"; export default async function EventDashboardPage({ params, @@ -124,6 +125,7 @@ export default async function EventDashboardPage({ return (
@@ -139,11 +141,13 @@ export default async function EventDashboardPage({

{event.guestUrl.replace(/^https?:\/\//, "")}

{event.location ?

{event.location}

: null}
-
+
+ {event.permissions.includes("overview.read") ? : null} + } tabs={tabs} /> diff --git a/apps/web/src/server/bulk-photos.ts b/apps/web/src/server/bulk-photos.ts index 200f151..987c4d4 100644 --- a/apps/web/src/server/bulk-photos.ts +++ b/apps/web/src/server/bulk-photos.ts @@ -20,7 +20,7 @@ export async function bulkPhotos(userId: string, input: z.infer true); } - const results: { photoId: string; status: "eligible" | "updated" | "deleted" | "skipped" | "failed"; reason?: string }[] = []; + const results: { photoId: string; status: "eligible" | "updated" | "deleted" | "unchanged" | "skipped" | "failed"; reason?: string }[] = []; // Each photo is its own transaction: failures never hide earlier successful work. for (const photoId of input.photoIds) { try { @@ -29,6 +29,7 @@ export async function bulkPhotos(userId: string, input: z.infer ({ photoId, status: "skipped" as const, reason }); if (!photo || (photo.visibility === "private" && !canPrivate)) return skip("Unavailable or not permitted"); + if (input.action === photo.visibility) return { photoId, status: "unchanged" as const }; if (input.action === "delete" && !["ready", "failed"].includes(photo.processingStatus)) return skip("Wait for upload and processing to finish"); if (input.action !== "delete" && !canTransitionVisibility(photo.visibility, input.action)) return skip("Already set or transition not allowed"); if (!apply) return { photoId, status: "eligible" as const }; diff --git a/apps/web/src/server/submission-groups.integration.test.ts b/apps/web/src/server/submission-groups.integration.test.ts index 2be44d8..ca04d53 100644 --- a/apps/web/src/server/submission-groups.integration.test.ts +++ b/apps/web/src/server/submission-groups.integration.test.ts @@ -58,6 +58,21 @@ test.skipIf(process.env.POLISH_INTEGRATION !== "1")("group ownership and event-s await expect(moderator.bulkPhotos({ ...bulk, confirm: false as true })).rejects.toThrow(); expect((await moderator.bulkPhotos({ ...bulk, confirm: true })).affected).toBe(2); expect((await moderator.bulkPhotos({ ...bulk, confirm: true })).affected).toBe(2); + const repeated = await moderator.bulkPhotos({ ...bulk, requestId: crypto.randomUUID(), confirm: true }); + expect(repeated.affected).toBe(0); + expect(repeated.results.filter(row => row.status === "unchanged")).toHaveLength(2); + expect(repeated.results.find(row => row.photoId === privatePhoto!.id)?.status).toBe("skipped"); + await db.update(photos).set({ visibility: "pending" }).where(and(eq(photos.eventId, event!.id), eq(photos.id, result[0]!.id))); + const mixedPreview = await moderator.previewBulkPhotos(bulk); + expect(mixedPreview.affected).toBe(1); + expect(mixedPreview.results.filter(row => row.status === "unchanged")).toHaveLength(1); + const mixed = await moderator.bulkPhotos({ ...bulk, requestId: crypto.randomUUID(), confirm: true }); + expect(mixed.affected).toBe(1); + expect(mixed.results.filter(row => row.status === "unchanged")).toHaveLength(1); + const stats = await manager.stats({ eventId: event!.id }); + expect(Number(stats!.photos)).toBe(3); + expect(Number(stats!.approved)).toBe(1); + expect(Number(stats!.guests)).toBe(1); await expect(moderator.bulkPhotos({ ...bulk, action: "hidden", confirm: true })).rejects.toThrow("different inputs"); // A replay cannot undo a later, intentional change. await moderator.bulkPhotos({ ...bulk, requestId: crypto.randomUUID(), action: "hidden", confirm: true }); @@ -66,6 +81,7 @@ test.skipIf(process.env.POLISH_INTEGRATION !== "1")("group ownership and event-s await expect(moderator.bulkPhotos({ ...bulk, action: "private", confirm: true })).rejects.toThrow(); const [otherEvent] = await db.insert(events).values({ groupId, title: "Other event", slug: `${id}-other` }).returning(); await db.insert(eventMemberships).values({ eventId: otherEvent!.id, userId: people[0]!.id, role: "owner" }); + expect(Number((await manager.stats({ eventId: otherEvent!.id }))!.photos)).toBe(0); const crossEvent = await manager.previewBulkPhotos({ ...bulk, eventId: otherEvent!.id }); expect(crossEvent.affected).toBe(0); const deletion = await manager.previewBulkPhotos({ ...bulk, action: "delete" });