From 7a7088f2ad91003adf1d2933a8c84a8121a19efa Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Sat, 17 Oct 2026 09:06:25 +0200 Subject: [PATCH] feat(shot-status): flag shots with new comments until marked read A per-user ShotCommentRead marker records how far each person has read a shot's comments. Comments and replies from others after the marker put an orange "!" beside the shot code; its popover lists them and only "Mark read" clears it. A chip filters the board to flagged shots and marks all read. The migration marks existing comments as read so the board starts clean. Co-Authored-By: Claude Opus 5.5 --- actions/comment-reads.ts | 42 +++++ .../shot-status/NewCommentsMarker.tsx | 146 ++++++++++++++++++ .../shot-status/ShotStatusClient.tsx | 107 ++++++++++++- app/(dashboard)/shot-status/page.tsx | 5 +- lib/comment-reads.ts | 146 ++++++++++++++++++ .../migration.sql | 30 ++++ prisma/schema.prisma | 20 +++ 7 files changed, 489 insertions(+), 7 deletions(-) create mode 100644 actions/comment-reads.ts create mode 100644 app/(dashboard)/shot-status/NewCommentsMarker.tsx create mode 100644 lib/comment-reads.ts create mode 100644 prisma/migrations/20261017000000_shot_comment_reads/migration.sql diff --git a/actions/comment-reads.ts b/actions/comment-reads.ts new file mode 100644 index 0000000..8ed6177 --- /dev/null +++ b/actions/comment-reads.ts @@ -0,0 +1,42 @@ +"use server"; + +import { auth } from "@/auth"; +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { + getUnreadCommentsForShot, + getUnreadCommentSummaries, + markShotCommentsRead as markRead, +} from "@/lib/comment-reads"; + +export async function listUnreadShotComments(shotId: string) { + const session = await auth(); + if (!session?.user) throw new Error("Unauthorized"); + return getUnreadCommentsForShot(session.user.id, z.string().min(1).parse(shotId)); +} + +const markSchema = z.object({ + shotId: z.string().min(1), + upTo: z.string().datetime(), +}); + +export async function markShotCommentsRead(shotId: string, upTo: string) { + const session = await auth(); + if (!session?.user) throw new Error("Unauthorized"); + const parsed = markSchema.parse({ shotId, upTo }); + await markRead(session.user.id, parsed.shotId, new Date(parsed.upTo)); + revalidatePath("/shot-status"); + return { success: true }; +} + +/** Mark every flagged shot in a project read, up to what each one had when checked. */ +export async function markProjectCommentsRead(projectId: string) { + const session = await auth(); + if (!session?.user) throw new Error("Unauthorized"); + const summaries = await getUnreadCommentSummaries(session.user.id, z.string().min(1).parse(projectId)); + for (const [shotId, s] of Object.entries(summaries)) { + await markRead(session.user.id, shotId, new Date(s.latestAt)); + } + revalidatePath("/shot-status"); + return { success: true, count: Object.keys(summaries).length }; +} diff --git a/app/(dashboard)/shot-status/NewCommentsMarker.tsx b/app/(dashboard)/shot-status/NewCommentsMarker.tsx new file mode 100644 index 0000000..04d798c --- /dev/null +++ b/app/(dashboard)/shot-status/NewCommentsMarker.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { useState, useTransition } from "react"; +import Link from "next/link"; +import * as Popover from "@radix-ui/react-popover"; +import { formatDistanceToNow } from "date-fns"; +import { AlertCircle, Check, Loader2, MessageSquareReply } from "lucide-react"; +import { listUnreadShotComments, markShotCommentsRead } from "@/actions/comment-reads"; +import type { UnreadCommentItem } from "@/lib/comment-reads"; +import { useToast } from "@/components/ui/use-toast"; + +/** + * The "!" beside a shot code while it has comments this user has not marked + * read. Opening it lists them; they stay flagged until "Mark read" is clicked — + * opening the list or the shot is not enough. + */ +export function NewCommentsMarker({ + shotId, + shotCode, + count, + latestAt, + onRead, +}: { + shotId: string; + shotCode: string; + count: number; + latestAt: string; + onRead: (shotId: string) => void; +}) { + const { toast } = useToast(); + const [open, setOpen] = useState(false); + const [items, setItems] = useState(null); + const [loadError, setLoadError] = useState(false); + const [marking, startMarking] = useTransition(); + + const load = async () => { + setLoadError(false); + try { + setItems(await listUnreadShotComments(shotId)); + } catch { + setLoadError(true); + } + }; + + const markRead = () => + startMarking(async () => { + // Read up to what was listed; with the list not loaded, up to what the + // board showed. Anything newer stays flagged. + const upTo = items?.[0]?.createdAt ?? latestAt; + try { + await markShotCommentsRead(shotId, upTo); + setOpen(false); + onRead(shotId); + } catch { + toast({ title: "Could not mark comments read", variant: "destructive" }); + } + }); + + return ( + { + setOpen(next); + if (next) load(); + }} + > + + + + + +
+ + New on {shotCode} + + +
+
+ {loadError ? ( +

+ + Could not load the comments. +

+ ) : items === null ? ( +

+ + Loading… +

+ ) : items.length === 0 ? ( +

+ Nothing new — the comments may have been deleted. +

+ ) : ( +
    + {items.map((item) => ( +
  • + +
    + {item.kind === "reply" && } + {item.authorName} + {item.source === "WHATSAPP" && ( + client + )} + + v{String(item.versionNumber).padStart(3, "0")} · f{item.frameNumber} + + + {formatDistanceToNow(new Date(item.createdAt), { addSuffix: true })} + +
    +

    + {item.text} +

    + +
  • + ))} +
+ )} +
+
+
+
+ ); +} diff --git a/app/(dashboard)/shot-status/ShotStatusClient.tsx b/app/(dashboard)/shot-status/ShotStatusClient.tsx index 251af70..d102162 100644 --- a/app/(dashboard)/shot-status/ShotStatusClient.tsx +++ b/app/(dashboard)/shot-status/ShotStatusClient.tsx @@ -36,6 +36,8 @@ import { updateShotNotes, bulkUpdateDueDates, bulkAssignArtist, bulkDeleteShots, import { previewMovReview, buildMovReview } from "@/actions/deliveries"; import { useToast } from "@/components/ui/use-toast"; import { LogRequestDialog } from "@/components/delivery/LogRequestDialog"; +import { markProjectCommentsRead } from "@/actions/comment-reads"; +import { NewCommentsMarker } from "./NewCommentsMarker"; import { Film, Clock, @@ -81,6 +83,8 @@ type ShotRow = { manualDeliveryNote: string | null; shotVersion: string | null; delivery: ShotDeliverySummary; + /** Comments from others this user has not marked read; null = none. */ + unreadComments: { count: number; latestAt: string } | null; artist: { id: string; name: string | null; image: string | null; email: string } | null; shotGroup: { id: string; name: string } | null; }; @@ -912,6 +916,7 @@ function ShotTable({ selectedIds, onToggle, onToggleAll, + onCommentsRead, }: { shots: ShotRow[]; canManage: boolean; @@ -919,6 +924,7 @@ function ShotTable({ selectedIds: Set; onToggle: (id: string) => void; onToggleAll: (ids: string[], checked: boolean) => void; + onCommentsRead: (shotId: string) => void; }) { const allChecked = shots.length > 0 && shots.every((s) => selectedIds.has(s.id)); const someChecked = shots.some((s) => selectedIds.has(s.id)); @@ -1014,6 +1020,15 @@ function ShotTable({ > {shot.shotCode} + {shot.unreadComments && ( + + )} {shot.shotVersion && ( {shot.shotVersion} )} @@ -1106,7 +1121,7 @@ export function ShotStatusClient({ projects, selectedProjectId, selectedProject, - shots, + shots: initialShots, episodeDueDates, artists, canManage, @@ -1114,6 +1129,18 @@ export function ShotStatusClient({ const router = useRouter(); const { toast } = useToast(); + // Shots marked read here drop their "!" at once, ahead of the refresh; the + // refreshed props are authoritative, so the local set is cleared when they land. + const [readShotIds, setReadShotIds] = useState>(new Set()); + useEffect(() => setReadShotIds(new Set()), [initialShots]); + const shots = readShotIds.size + ? initialShots.map((s) => (readShotIds.has(s.id) ? { ...s, unreadComments: null } : s)) + : initialShots; + const markShotReadLocally = (shotId: string) => + setReadShotIds((prev) => new Set(prev).add(shotId)); + const [unreadOnly, setUnreadOnly] = useState(false); + const [markingAllRead, startMarkingAllRead] = useTransition(); + // Restore last selected project if no project is currently selected useEffect(() => { if (!selectedProjectId) { @@ -1144,6 +1171,7 @@ export function ShotStatusClient({ if (!selectedProjectId) return; setExpandRestored(false); setStatusFilter(new Set()); + setUnreadOnly(false); const mode = (readStored(`shotStatus:${selectedProjectId}:groupMode`) ?? "flat") as "flat" | "scene" | "group"; setGroupMode(mode); setExpandedEpisodes(new Set(readStoredList(`shotStatus:${selectedProjectId}:episodeExpand`) ?? [])); @@ -1246,13 +1274,43 @@ export function ShotStatusClient({ if (next.size > 0) { setSelectedIds((prev) => { const kept = new Set(); - for (const s of shots) if (prev.has(s.id) && next.has(s.status)) kept.add(s.id); + for (const s of shots) + if (prev.has(s.id) && next.has(s.status) && (!unreadOnly || s.unreadComments)) kept.add(s.id); return kept; }); } }; - const visibleShots = statusFilter.size > 0 ? shots.filter((s) => statusFilter.has(s.status)) : shots; + const visibleShots = shots.filter( + (s) => (statusFilter.size === 0 || statusFilter.has(s.status)) && (!unreadOnly || s.unreadComments) + ); + const unreadShotCount = shots.filter((s) => s.unreadComments).length; + const isFiltered = statusFilter.size > 0 || unreadOnly; + + const toggleUnreadOnly = () => { + const next = !unreadOnly; + setUnreadOnly(next); + // Bulk actions must never reach shots the filter has hidden. + if (next) { + setSelectedIds((prev) => { + const kept = new Set(); + for (const s of shots) if (prev.has(s.id) && s.unreadComments) kept.add(s.id); + return kept; + }); + } + }; + + const handleMarkAllRead = () => + startMarkingAllRead(async () => { + if (!selectedProjectId) return; + try { + await markProjectCommentsRead(selectedProjectId); + setReadShotIds(new Set(shots.filter((s) => s.unreadComments).map((s) => s.id))); + setUnreadOnly(false); + } catch { + toast({ title: "Could not mark comments read", variant: "destructive" }); + } + }); const handleBulkDueDate = async (date: string, includeTasks: boolean) => { try { @@ -1457,7 +1515,7 @@ export function ShotStatusClient({ {selectionCount === 0 ? ( <> - {statusFilter.size > 0 ? `${visibleShots.length} of ${shots.length} shots` : `${shots.length} shots total`} + {isFiltered ? `${visibleShots.length} of ${shots.length} shots` : `${shots.length} shots total`} {Object.entries(STATUS_CONFIG).map(([key, cfg]) => { const count = statusCounts[key] ?? 0; @@ -1485,10 +1543,43 @@ export function ShotStatusClient({ ); })} - {statusFilter.size > 0 && ( + {unreadShotCount > 0 && ( + + + + + )} + {isFiltered && (