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 <noreply@anthropic.com>
This commit is contained in:
twotalesanimation
2026-10-17 09:06:25 +02:00
parent 0aedc4b1fa
commit 7a7088f2ad
7 changed files with 489 additions and 7 deletions
+42
View File
@@ -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 };
}
@@ -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<UnreadCommentItem[] | null>(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 (
<Popover.Root
open={open}
onOpenChange={(next) => {
setOpen(next);
if (next) load();
}}
>
<Popover.Trigger asChild>
<button
type="button"
title={`${count} new comment${count !== 1 ? "s" : ""} on ${shotCode}`}
aria-label={`${count} new comment${count !== 1 ? "s" : ""} on ${shotCode}`}
className="flex items-center justify-center h-4 min-w-4 rounded-full bg-orange-500 px-1 text-[10px] font-bold leading-none text-white hover:bg-orange-400 shrink-0"
>
!{count > 1 && <span className="ml-0.5 font-medium">{count}</span>}
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
align="start"
sideOffset={6}
className="z-50 w-96 rounded-lg border border-zinc-700 bg-zinc-900 shadow-xl"
>
<div className="flex items-center justify-between border-b border-zinc-800 px-3 py-2">
<span className="text-xs font-medium text-zinc-300">
New on <span className="font-mono text-white">{shotCode}</span>
</span>
<button
type="button"
onClick={markRead}
disabled={marking}
className="flex items-center gap-1 rounded border border-zinc-700 px-2 py-1 text-xs text-zinc-200 hover:bg-zinc-800 disabled:opacity-50"
>
{marking ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
Mark read
</button>
</div>
<div className="max-h-80 overflow-y-auto">
{loadError ? (
<p className="flex items-center gap-1.5 px-3 py-4 text-xs text-red-400">
<AlertCircle className="h-3.5 w-3.5" />
Could not load the comments.
</p>
) : items === null ? (
<p className="flex items-center gap-1.5 px-3 py-4 text-xs text-zinc-500">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Loading…
</p>
) : items.length === 0 ? (
<p className="px-3 py-4 text-xs text-zinc-500">
Nothing new — the comments may have been deleted.
</p>
) : (
<ul className="divide-y divide-zinc-800">
{items.map((item) => (
<li key={`${item.kind}-${item.id}`}>
<Link
href={`/review/${item.versionId}`}
className="block px-3 py-2 hover:bg-zinc-800/60"
>
<div className="flex items-center gap-1.5 text-[11px] text-zinc-500">
{item.kind === "reply" && <MessageSquareReply className="h-3 w-3" />}
<span className="text-zinc-300">{item.authorName}</span>
{item.source === "WHATSAPP" && (
<span className="rounded bg-green-500/15 px-1 text-green-400">client</span>
)}
<span className="font-mono">
v{String(item.versionNumber).padStart(3, "0")} · f{item.frameNumber}
</span>
<span className="ml-auto">
{formatDistanceToNow(new Date(item.createdAt), { addSuffix: true })}
</span>
</div>
<p className="mt-0.5 line-clamp-3 whitespace-pre-line text-xs text-zinc-200">
{item.text}
</p>
</Link>
</li>
))}
</ul>
)}
</div>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
}
@@ -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<string>;
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}
</Link>
{shot.unreadComments && (
<NewCommentsMarker
shotId={shot.id}
shotCode={shot.shotCode}
count={shot.unreadComments.count}
latestAt={shot.unreadComments.latestAt}
onRead={onCommentsRead}
/>
)}
{shot.shotVersion && (
<span className="font-mono text-xs text-zinc-400 shrink-0">{shot.shotVersion}</span>
)}
@@ -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<Set<string>>(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<string>();
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<string>();
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 ? (
<>
<span className="text-xs text-zinc-500 self-center">
{statusFilter.size > 0 ? `${visibleShots.length} of ${shots.length} shots` : `${shots.length} shots total`}
{isFiltered ? `${visibleShots.length} of ${shots.length} shots` : `${shots.length} shots total`}
</span>
{Object.entries(STATUS_CONFIG).map(([key, cfg]) => {
const count = statusCounts[key] ?? 0;
@@ -1485,10 +1543,43 @@ export function ShotStatusClient({
</button>
);
})}
{statusFilter.size > 0 && (
{unreadShotCount > 0 && (
<span className="flex items-center gap-1">
<button
type="button"
onClick={() => setStatusFilter(new Set())}
onClick={toggleUnreadOnly}
aria-pressed={unreadOnly}
title={unreadOnly ? "Show all shots" : "Show only shots with new comments"}
className="rounded-full"
>
<Badge
className={cn(
"gap-1 text-xs cursor-pointer bg-orange-500/15 text-orange-300 border-orange-500/30",
unreadOnly && "ring-1 ring-current"
)}
>
<span className="font-bold">!</span>
{unreadShotCount} with new comments
</Badge>
</button>
<button
type="button"
onClick={handleMarkAllRead}
disabled={markingAllRead}
className="flex items-center gap-1 text-xs text-zinc-500 hover:text-zinc-300 transition-colors disabled:opacity-50"
>
{markingAllRead ? <Loader2 className="h-3 w-3 animate-spin" /> : <CheckCircle2 className="h-3 w-3" />}
Mark all read
</button>
</span>
)}
{isFiltered && (
<button
type="button"
onClick={() => {
setStatusFilter(new Set());
setUnreadOnly(false);
}}
className="flex items-center gap-1 text-xs text-zinc-500 hover:text-zinc-300 transition-colors"
>
<X className="h-3 w-3" />
@@ -1608,6 +1699,7 @@ export function ShotStatusClient({
projectId={selectedProjectId!}
selectedIds={selectedIds}
onToggle={toggleShot}
onCommentsRead={markShotReadLocally}
onToggleAll={toggleMany}
/>
</div>
@@ -1657,6 +1749,7 @@ export function ShotStatusClient({
projectId={selectedProjectId!}
selectedIds={selectedIds}
onToggle={toggleShot}
onCommentsRead={markShotReadLocally}
onToggleAll={toggleMany}
/>
</div>
@@ -1706,6 +1799,7 @@ export function ShotStatusClient({
projectId={selectedProjectId!}
selectedIds={selectedIds}
onToggle={toggleShot}
onCommentsRead={markShotReadLocally}
onToggleAll={toggleMany}
/>
</div>
@@ -1722,6 +1816,7 @@ export function ShotStatusClient({
projectId={selectedProjectId!}
selectedIds={selectedIds}
onToggle={toggleShot}
onCommentsRead={markShotReadLocally}
onToggleAll={toggleMany}
/>
</div>
+4 -1
View File
@@ -3,6 +3,7 @@ import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { ShotStatusClient } from "./ShotStatusClient";
import { summariseShotDelivery } from "@/lib/render-pipeline/shot-delivery-summary";
import { getUnreadCommentSummaries, type UnreadCommentSummary } from "@/lib/comment-reads";
async function getProjects() {
return db.project.findMany({
@@ -116,12 +117,14 @@ export default async function ShotStatusPage({
const { projectId } = await searchParams;
const canManage = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role);
const [projects, shots, episodeDueDates, artists] = await Promise.all([
const [projects, shotRows, episodeDueDates, artists, unread] = await Promise.all([
getProjects(),
projectId ? getShotsForProject(projectId) : Promise.resolve([]),
projectId ? getEpisodeDueDatesForProject(projectId) : Promise.resolve([]),
canManage ? getAssignableArtists() : Promise.resolve([]),
projectId ? getUnreadCommentSummaries(session.user.id, projectId) : Promise.resolve({} as Record<string, UnreadCommentSummary>),
]);
const shots = shotRows.map((shot) => ({ ...shot, unreadComments: unread[shot.id] ?? null }));
const selectedProject = projects.find((p) => p.id === projectId) ?? null;
+146
View File
@@ -0,0 +1,146 @@
import { db } from "@/lib/db";
import { Prisma } from "@prisma/client";
/**
* New-comment tracking for the shot-status board. A comment or reply is new to
* a user when someone else wrote it on one of the shot's versions (directly or
* through a task on the shot) after the user's ShotCommentRead marker. With no
* marker the baseline is when the account was created, so a new user does not
* inherit the whole back catalogue.
*
* Comments with no author (the author's account was deleted) count as someone
* else's. Pasted WhatsApp notes are authored by whoever pasted them, so they
* are new to everyone but that person.
*/
export type UnreadCommentSummary = {
count: number;
/** createdAt of the newest unread item — what "mark read" reads up to. */
latestAt: string;
};
export async function getUnreadCommentSummaries(
userId: string,
projectId: string
): Promise<Record<string, UnreadCommentSummary>> {
const rows = await db.$queryRaw<{ shotId: string; count: number; latestAt: Date }[]>(Prisma.sql`
WITH me AS (
SELECT "createdAt" FROM "users" WHERE "id" = ${userId}
),
items AS (
SELECT COALESCE(v."shotId", t."shotId") AS "shotId", c."createdAt", c."authorId"
FROM "comments" c
JOIN "versions" v ON v."id" = c."versionId"
LEFT JOIN "tasks" t ON t."id" = v."taskId"
UNION ALL
SELECT COALESCE(v."shotId", t."shotId") AS "shotId", r."createdAt", r."authorId"
FROM "comment_replies" r
JOIN "comments" c ON c."id" = r."commentId"
JOIN "versions" v ON v."id" = c."versionId"
LEFT JOIN "tasks" t ON t."id" = v."taskId"
)
SELECT i."shotId", COUNT(*)::int AS "count", MAX(i."createdAt") AS "latestAt"
FROM items i
JOIN "shots" s ON s."id" = i."shotId"
LEFT JOIN "shot_comment_reads" scr ON scr."shotId" = s."id" AND scr."userId" = ${userId}
WHERE s."projectId" = ${projectId}
AND i."authorId" IS DISTINCT FROM ${userId}
AND i."createdAt" > COALESCE(scr."readAt", (SELECT "createdAt" FROM me))
GROUP BY i."shotId"
`);
return Object.fromEntries(
rows.map((r) => [r.shotId, { count: r.count, latestAt: r.latestAt.toISOString() }])
);
}
export type UnreadCommentItem = {
id: string;
kind: "comment" | "reply";
text: string;
authorName: string;
source: string | null;
versionId: string;
versionNumber: number;
frameNumber: number;
createdAt: string;
};
/** The unread comments and replies on one shot, newest first. */
export async function getUnreadCommentsForShot(
userId: string,
shotId: string
): Promise<UnreadCommentItem[]> {
const [user, marker] = await Promise.all([
db.user.findUnique({ where: { id: userId }, select: { createdAt: true } }),
db.shotCommentRead.findUnique({ where: { userId_shotId: { userId, shotId } } }),
]);
const since = marker?.readAt ?? user?.createdAt ?? new Date(0);
const onShot: Prisma.VersionWhereInput = { OR: [{ shotId }, { task: { shotId } }] };
// `not: userId` alone would also drop authorless rows (SQL NULL), which count.
const byOthers = { OR: [{ authorId: null }, { authorId: { not: userId } }] };
const author = { select: { name: true, email: true } };
const version = { select: { id: true, versionNumber: true } };
const [comments, replies] = await Promise.all([
db.comment.findMany({
where: { version: onShot, createdAt: { gt: since }, ...byOthers },
select: { id: true, text: true, source: true, frameNumber: true, createdAt: true, author, version },
}),
db.commentReply.findMany({
where: { comment: { version: onShot }, createdAt: { gt: since }, ...byOthers },
select: {
id: true,
text: true,
createdAt: true,
author,
comment: { select: { frameNumber: true, version } },
},
}),
]);
const items: UnreadCommentItem[] = [
...comments.map((c) => ({
id: c.id,
kind: "comment" as const,
text: c.text,
authorName: c.author?.name ?? c.author?.email ?? "Unknown",
source: c.source,
versionId: c.version.id,
versionNumber: c.version.versionNumber,
frameNumber: c.frameNumber,
createdAt: c.createdAt.toISOString(),
})),
...replies.map((r) => ({
id: r.id,
kind: "reply" as const,
text: r.text,
authorName: r.author?.name ?? r.author?.email ?? "Unknown",
source: null,
versionId: r.comment.version.id,
versionNumber: r.comment.version.versionNumber,
frameNumber: r.comment.frameNumber,
createdAt: r.createdAt.toISOString(),
})),
];
return items.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}
/**
* Move a user's marker forward to `upTo` — the newest item they were shown,
* not "now", so a comment that lands while the list is open stays unread.
* Never moves a marker backwards.
*/
export async function markShotCommentsRead(userId: string, shotId: string, upTo: Date) {
const readAt = upTo > new Date() ? new Date() : upTo;
const existing = await db.shotCommentRead.findUnique({
where: { userId_shotId: { userId, shotId } },
});
if (existing && existing.readAt >= readAt) return;
await db.shotCommentRead.upsert({
where: { userId_shotId: { userId, shotId } },
create: { userId, shotId, readAt },
update: { readAt },
});
}
@@ -0,0 +1,30 @@
-- Per-user read marker for a shot's comments: the shot-status board flags a
-- shot with "!" while it has comments from others newer than the marker.
CREATE TABLE "shot_comment_reads" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"shotId" TEXT NOT NULL,
"readAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "shot_comment_reads_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "shot_comment_reads_userId_shotId_key" ON "shot_comment_reads"("userId", "shotId");
CREATE INDEX "shot_comment_reads_shotId_idx" ON "shot_comment_reads"("shotId");
ALTER TABLE "shot_comment_reads" ADD CONSTRAINT "shot_comment_reads_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "shot_comment_reads" ADD CONSTRAINT "shot_comment_reads_shotId_fkey" FOREIGN KEY ("shotId") REFERENCES "shots"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Everything already commented counts as read on launch, so the board starts
-- clean instead of flagging every shot that has ever had a note.
INSERT INTO "shot_comment_reads" ("id", "userId", "shotId", "readAt")
SELECT md5(u."id" || ':' || s."id"), u."id", s."id", CURRENT_TIMESTAMP
FROM "users" u
CROSS JOIN "shots" s
WHERE u."role" <> 'CLIENT'
AND EXISTS (
SELECT 1 FROM "comments" c
JOIN "versions" v ON v."id" = c."versionId"
LEFT JOIN "tasks" t ON t."id" = v."taskId"
WHERE COALESCE(v."shotId", t."shotId") = s."id"
);
+20
View File
@@ -263,6 +263,7 @@ model User {
createdTakes Take[] @relation("TakeCreator")
uploadedTakeAttachments TakeAttachment[] @relation("TakeAttachmentUploader")
assignedBoardItems BoardItem[] @relation("BoardItemAssignee")
shotCommentReads ShotCommentRead[]
@@map("users")
}
@@ -498,6 +499,7 @@ model Shot {
exports Export[]
deliveryRequestLines DeliveryRequestLine[]
boardItems BoardItem[]
commentReads ShotCommentRead[]
@@unique([projectId, shotCode])
@@map("shots")
@@ -675,6 +677,24 @@ model Approval {
@@map("approvals")
}
/// How far each user has read a shot's comments. A comment or reply on any of
/// the shot's versions, written by someone else after `readAt`, is new to that
/// user and flags the shot on the shot-status board until they mark it read.
/// No row = nothing read since the account was created.
model ShotCommentRead {
id String @id @default(cuid())
userId String
shotId String
readAt DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
shot Shot @relation(fields: [shotId], references: [id], onDelete: Cascade)
@@unique([userId, shotId])
@@index([shotId])
@@map("shot_comment_reads")
}
model Notification {
id String @id @default(cuid())
userId String