feat(board): paste client feedback from WhatsApp
Deploy / deploy (push) Successful in 3m34s

"Paste feedback" on each board card takes a client's message as it arrived
and splits it into rounds ("FEEDBACK 113 DUE Oct 7 12:00 PM") and per-shot
notes, tolerating WhatsApp copy prefixes, bold markers, wrapped lines,
several codes per note and codes without the show prefix.

A review step shows each note's shot, the version the client was sent,
the player frame and a "move task to Changes" tick (on by default).
Saving creates a FeedbackRound, a player comment per note tagged
source=WHATSAPP (shown as "Client feedback") and a board line under the
round's due time; ticking the line off resolves the comment.

Quoted frames are read as comp frames: shot frameStart or the house 1001,
plus the slate (recorded, else 1 for pipeline previews). Version.duration
is never stored on live, so the length-based slate inference can't be used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
twotalesanimation
2026-10-07 17:31:34 +02:00
parent 34af26dfbf
commit 842a8fe036
12 changed files with 1073 additions and 8 deletions
+14 -1
View File
@@ -115,10 +115,23 @@ export async function updateBoardItem(id: string, data: z.infer<typeof updateSch
export async function setBoardItemDone(id: string, done: boolean) {
const user = await requireStaff();
await db.boardItem.update({
const item = await db.boardItem.update({
where: { id },
data: done ? { doneAt: new Date(), doneById: user.id } : { doneAt: null, doneById: null },
select: { commentId: true },
});
// A line from pasted feedback is that client note: ticking it off resolves
// the comment in the player too. (The comment may have been deleted there.)
if (item.commentId) {
const comment = await db.comment.updateMany({
where: { id: item.commentId },
data: { isResolved: done },
});
if (comment.count) {
const c = await db.comment.findUnique({ where: { id: item.commentId }, select: { versionId: true } });
if (c) revalidatePath(`/review/${c.versionId}`);
}
}
revalidatePath("/board");
return { success: true };
}
+253
View File
@@ -0,0 +1,253 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { findShot, suggestShot, type BoardShot } from "@/lib/board-parse";
import { parseFeedback, slateFrames, toPlayerFrame } from "@/lib/feedback-parse";
import { updateTaskStatus } from "@/actions/tasks";
/** Comment.source for notes pasted from a client message */
const SOURCE = "WHATSAPP";
async function requireStaff() {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (session.user.role === "CLIENT") throw new Error("Insufficient permissions");
return session.user;
}
/**
* The shot a pasted code means: exact code or client alias first, then a code
* the client shortened by dropping the show prefix ("113_018_040" for
* "UNG_113_018_040") when exactly one shot ends with it.
*/
function resolveShot<T extends BoardShot>(code: string, shots: T[]): T | null {
const exact = findShot(code, shots) as T | null;
if (exact) return exact;
const tail = `_${code.toUpperCase()}`;
const hits = shots.filter((s) => s.shotCode.toUpperCase().endsWith(tail));
return hits.length === 1 ? hits[0] : null;
}
export interface FeedbackVersionOption {
id: string;
label: string;
sent: boolean;
sharedAt: string | null;
taskId: string | null;
taskStatus: string | null;
/** The note's quoted frame converted for this version's media; null = no frame quoted */
playerFrame: number | null;
fromComp: boolean;
}
export interface FeedbackPreviewNote {
key: string;
code: string;
text: string;
quotedFrame: number | null;
shot: { id: string; shotCode: string } | null;
suggestion: string | null;
versions: FeedbackVersionOption[];
defaultVersionId: string | null;
}
export interface FeedbackPreview {
rounds: { title: string; dueText: string | null; notes: FeedbackPreviewNote[] }[];
ignored: string[];
}
export async function previewFeedback(projectId: string, input: string): Promise<FeedbackPreview> {
await requireStaff();
z.string().cuid().parse(projectId);
const parsed = parseFeedback(z.string().max(50000).parse(input));
const shots = await db.shot.findMany({
where: { projectId },
select: { id: true, shotCode: true, clientCode: true, frameStart: true },
});
const matched = new Map<string, (typeof shots)[number] | null>();
for (const r of parsed.rounds) for (const n of r.notes) {
const code = n.codes[0];
if (!matched.has(code)) matched.set(code, resolveShot(code, shots));
}
const shotIds = [...new Set([...matched.values()].filter(Boolean).map((s) => s!.id))];
// Recent versions per shot, whether hung off the shot or its task
const versions = shotIds.length
? await db.version.findMany({
where: { OR: [{ shotId: { in: shotIds } }, { task: { shotId: { in: shotIds } } }] },
orderBy: { createdAt: "desc" },
select: {
id: true,
versionNumber: true,
shotId: true,
isClientVisible: true,
sharedAt: true,
frameCount: true,
pictureStartFrame: true,
task: { select: { id: true, status: true, shotId: true } },
},
})
: [];
const byShot = new Map<string, typeof versions>();
for (const v of versions) {
const sid = v.shotId ?? v.task?.shotId;
if (!sid) continue;
const list = byShot.get(sid) ?? [];
if (list.length < 6) list.push(v);
byShot.set(sid, list);
}
let k = 0;
return {
ignored: parsed.ignored,
rounds: parsed.rounds.map((r) => ({
title: r.title,
dueText: r.dueText,
notes: r.notes.map((n) => {
const code = n.codes[0];
const shot = matched.get(code) ?? null;
const options: FeedbackVersionOption[] = (shot ? byShot.get(shot.id) ?? [] : []).map((v) => {
const conv = n.frame != null
? toPlayerFrame(n.frame, {
shotFrameStart: shot!.frameStart,
pictureStart: slateFrames(v),
frameCount: v.frameCount,
})
: null;
return {
id: v.id,
label: `v${String(v.versionNumber).padStart(3, "0")}`,
sent: v.isClientVisible,
sharedAt: v.sharedAt?.toISOString() ?? null,
taskId: v.task?.id ?? null,
taskStatus: v.task?.status ?? null,
playerFrame: conv?.frame ?? null,
fromComp: conv?.fromComp ?? false,
};
});
// The client is commenting on what they were sent
const def = options.find((o) => o.sent) ?? options[0] ?? null;
return {
key: `n${k++}`,
code: shot?.shotCode ?? code,
text: n.text,
quotedFrame: n.frame,
shot: shot ? { id: shot.id, shotCode: shot.shotCode } : null,
suggestion: shot ? null : suggestShot(code, shots)?.shotCode ?? null,
versions: options,
defaultVersionId: def?.id ?? null,
};
}),
})),
};
}
const commitSchema = z.object({
projectId: z.string().cuid(),
rounds: z.array(z.object({
title: z.string().max(200),
dueAt: z.string().datetime().nullable(),
notes: z.array(z.object({
code: z.string().min(1).max(100),
shotId: z.string().cuid().nullable(),
text: z.string().min(1).max(5000),
versionId: z.string().cuid().nullable(),
frame: z.number().int().min(0).nullable(),
setChanges: z.boolean(),
})).min(1),
})).min(1),
});
/**
* Saves pasted client feedback: one FeedbackRound per round, each note as a
* player comment (tagged as client feedback) and a board line, and — where
* ticked — the version's task moved to Changes.
*/
export async function commitFeedback(data: z.infer<typeof commitSchema>) {
const user = await requireStaff();
const parsed = commitSchema.parse(data);
const notes = parsed.rounds.flatMap((r) => r.notes);
const versionIds = [...new Set(notes.map((n) => n.versionId).filter(Boolean) as string[])];
const versions = await db.version.findMany({
where: { id: { in: versionIds } },
select: { id: true, fps: true, shotId: true, task: { select: { id: true, shotId: true, status: true, projectId: true } }, shot: { select: { projectId: true } } },
});
const vById = new Map(versions.map((v) => [v.id, v]));
for (const n of notes) {
if (!n.versionId) continue;
const v = vById.get(n.versionId);
const projectOk = v && (v.shot?.projectId ?? v.task?.projectId) === parsed.projectId;
const shotOk = v && (v.shotId ?? v.task?.shotId) === n.shotId;
if (!projectOk || !shotOk) throw new Error(`Version does not belong to ${n.code}`);
}
const last = await db.boardItem.findFirst({
where: { projectId: parsed.projectId },
orderBy: { sortOrder: "desc" },
select: { sortOrder: true },
});
let sort = (last?.sortOrder ?? -1) + 1;
let comments = 0;
await db.$transaction(async (tx) => {
for (const r of parsed.rounds) {
const round = await tx.feedbackRound.create({
data: {
projectId: parsed.projectId,
title: r.title.trim() || "Client feedback",
dueAt: r.dueAt ? new Date(r.dueAt) : null,
createdById: user.id,
},
});
for (const n of r.notes) {
const v = n.versionId ? vById.get(n.versionId)! : null;
const frame = n.frame ?? 0;
const comment = v
? await tx.comment.create({
data: {
versionId: v.id,
authorId: user.id,
frameNumber: frame,
timestamp: frame / v.fps,
text: n.text.trim(),
source: SOURCE,
},
select: { id: true },
})
: null;
if (comment) comments++;
await tx.boardItem.create({
data: {
projectId: parsed.projectId,
shotId: n.shotId,
code: n.code.toUpperCase(),
text: n.text.trim(),
roundId: round.id,
commentId: comment?.id ?? null,
sortOrder: sort++,
createdById: user.id,
},
});
}
}
});
// Outside the transaction: updateTaskStatus notifies the artist and
// recalculates shot status, exactly as moving the task by hand does.
const tasks = new Set<string>();
for (const n of notes) {
const task = n.versionId ? vById.get(n.versionId)?.task : null;
if (n.setChanges && task && task.status !== "CHANGES") tasks.add(task.id);
}
for (const taskId of tasks) await updateTaskStatus(taskId, "CHANGES");
for (const id of versionIds) revalidatePath(`/review/${id}`);
revalidatePath("/board");
return { success: true, notes: notes.length, comments, tasksChanged: tasks.size };
}
+87 -5
View File
@@ -12,6 +12,7 @@ import {
Clock,
Flag,
Link2,
MessageSquareText,
Pencil,
Plus,
Trash2,
@@ -31,6 +32,7 @@ import {
setProjectBoardStatus,
updateBoardItem,
} from "@/actions/board";
import { FeedbackPasteDialog } from "./FeedbackPasteDialog";
type User = { id: string; name: string | null; email: string; image: string | null };
@@ -40,6 +42,9 @@ type BoardItem = {
code: string | null;
group: string;
waitingOn: string | null;
round: { id: string; title: string; dueAt: string | null } | null;
/** The player comment a pasted-feedback line was saved as */
comment: { versionId: string; frame: number } | null;
doneAt: string | null;
assignee: { id: string; name: string | null; image: string | null } | null;
shot: {
@@ -138,15 +143,26 @@ export function BoardClient({
function ProjectSection({ project, users, who }: { project: BoardProject; users: User[]; who: string | null }) {
const [showDone, setShowDone] = useState(false);
const [pasting, setPasting] = useState(false);
const visible = project.items.filter((i) =>
who === null ? true : who === "none" ? !i.assignee : i.assignee?.id === who,
);
const open = visible.filter((i) => !i.doneAt);
const done = visible.filter((i) => i.doneAt);
const flagged = open.filter((i) => i.code && !i.shot);
const waiting = open.filter((i) => i.waitingOn && !(i.code && !i.shot));
const rest = open.filter((i) => !flagged.includes(i) && !waiting.includes(i));
// Pasted feedback stays together under its round, flags and all
const rounds: { round: NonNullable<BoardItem["round"]>; items: BoardItem[] }[] = [];
for (const item of open) {
if (!item.round) continue;
let r = rounds.find((x) => x.round.id === item.round!.id);
if (!r) rounds.push((r = { round: item.round, items: [] }));
r.items.push(item);
}
rounds.sort((a, b) => (a.round.dueAt ?? "9").localeCompare(b.round.dueAt ?? "9"));
const loose = open.filter((i) => !i.round);
const flagged = loose.filter((i) => i.code && !i.shot);
const waiting = loose.filter((i) => i.waitingOn && !(i.code && !i.shot));
const rest = loose.filter((i) => !flagged.includes(i) && !waiting.includes(i));
// Groups keep the order they were first typed in
const groups: { name: string; items: BoardItem[] }[] = [];
@@ -171,8 +187,17 @@ function ProjectSection({ project, users, who }: { project: BoardProject; users:
On hold
</span>
)}
<Button
size="sm"
variant="outline"
className="ml-auto h-7"
onClick={() => setPasting(true)}
title="Paste a client's feedback message"
>
<MessageSquareText /> Paste feedback
</Button>
{project.shotTotal > 0 && (
<div className="ml-auto flex items-center gap-2 text-xs text-zinc-400">
<div className="flex items-center gap-2 text-xs text-zinc-400">
<div className="h-1.5 w-24 overflow-hidden rounded-full bg-zinc-800">
<div className="h-full bg-green-500/70" style={{ width: `${pct}%` }} />
</div>
@@ -185,6 +210,11 @@ function ProjectSection({ project, users, who }: { project: BoardProject; users:
<StatusLine project={project} />
<Milestones project={project} />
{rounds.map(({ round, items }) => (
<RoundBlock key={round.id} round={round} count={items.length}>
{items.map((i) => <ItemRow key={i.id} item={i} users={users} projectId={project.id} />)}
</RoundBlock>
))}
{flagged.length > 0 && (
<ItemBlock title="Flags" icon={<Flag className="h-3.5 w-3.5 text-red-400" />}>
{flagged.map((i) => <ItemRow key={i.id} item={i} users={users} projectId={project.id} />)}
@@ -221,10 +251,54 @@ function ProjectSection({ project, users, who }: { project: BoardProject; users:
</div>
)}
</div>
<FeedbackPasteDialog
projectId={project.id}
projectName={project.name}
open={pasting}
onOpenChange={setPasting}
/>
</section>
);
}
function RoundBlock({
round,
count,
children,
}: {
round: NonNullable<BoardItem["round"]>;
count: number;
children: React.ReactNode;
}) {
const due = round.dueAt ? new Date(round.dueAt) : null;
const late = due && due.getTime() < Date.now();
const days = due ? differenceInCalendarDays(due, new Date()) : null;
const when = due
? days === 0 ? `today ${format(due, "HH:mm")}` : days === 1 ? `tomorrow ${format(due, "HH:mm")}` : format(due, "d MMM HH:mm")
: null;
return (
<div>
<div className="mb-1 flex flex-wrap items-center gap-1.5 text-xs uppercase tracking-wider text-zinc-500">
<MessageSquareText className="h-3.5 w-3.5 text-indigo-400" />
{round.title}
{when && (
<span
className={cn(
"rounded px-1.5 py-px normal-case tracking-normal",
late ? "bg-red-500/10 text-red-300" : days !== null && days <= 1 ? "bg-amber-500/10 text-amber-300" : "text-zinc-400",
)}
>
{late ? "overdue · " : "due "}{when}
</span>
)}
<span className="normal-case tracking-normal text-zinc-600">{count} open</span>
</div>
<div className="divide-y divide-zinc-800/60">{children}</div>
</div>
);
}
function ItemBlock({ title, icon, children }: { title: string; icon?: React.ReactNode; children: React.ReactNode }) {
return (
<div>
@@ -466,7 +540,15 @@ function ItemRow({ item, users, projectId }: { item: BoardItem; users: User[]; p
</Link>
)}
<span className={cn("text-zinc-200", done && "text-zinc-600 line-through")}>{item.text}</span>
{item.comment && (
<Link
href={`/review/${item.comment.versionId}`}
className="text-[11px] text-indigo-400/80 hover:text-indigo-300"
title="Open the note in the player"
>
note @ F{item.comment.frame}
</Link>
)}
</div>
{(item.waitingOn || (flagged && !done)) && (
@@ -0,0 +1,342 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { format } from "date-fns";
import { AlertTriangle, ArrowLeft, Link2, MessageSquareText } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { useToast } from "@/components/ui/use-toast";
import { cn } from "@/lib/utils";
import { parseDue } from "@/lib/feedback-parse";
import { commitFeedback, previewFeedback, type FeedbackPreview, type FeedbackPreviewNote } from "@/actions/feedback";
type NoteState = {
include: boolean;
text: string;
versionId: string | null;
/** Typed frame; null = use the selected version's converted frame */
frame: string | null;
setChanges: boolean;
};
type RoundState = { title: string; due: string; notes: Record<string, NoteState> };
const LOCAL_DT = "yyyy-MM-dd'T'HH:mm";
function initialState(preview: FeedbackPreview): RoundState[] {
return preview.rounds.map((r) => {
const due = parseDue(r.dueText);
return {
title: r.title || `Feedback ${format(new Date(), "d MMM")}`,
due: due ? format(due, LOCAL_DT) : "",
notes: Object.fromEntries(
r.notes.map((n) => [
n.key,
{ include: true, text: n.text, versionId: n.defaultVersionId, frame: null, setChanges: true },
]),
),
};
});
}
export function FeedbackPasteDialog({
projectId,
projectName,
open,
onOpenChange,
}: {
projectId: string;
projectName: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const router = useRouter();
const { toast } = useToast();
const [input, setInput] = useState("");
const [preview, setPreview] = useState<FeedbackPreview | null>(null);
const [rounds, setRounds] = useState<RoundState[]>([]);
const [isPending, startTransition] = useTransition();
const reset = () => {
setInput("");
setPreview(null);
setRounds([]);
};
const read = (text: string) =>
startTransition(async () => {
try {
const p = await previewFeedback(projectId, text);
if (p.rounds.length === 0) {
toast({ title: "No shot notes found", description: "Each note needs to start with a shot code.", variant: "destructive" });
return;
}
setPreview(p);
setRounds(initialState(p));
} catch (e) {
toast({ title: "Couldn't read feedback", description: (e as Error).message, variant: "destructive" });
}
});
// Accepting a suggested code rewrites the paste and reads it again, so
// everything downstream (versions, frames) comes from the real shot.
const applySuggestion = (code: string, suggestion: string) => {
const next = input.split(code).join(suggestion);
setInput(next);
read(next);
};
const updateNote = (ri: number, key: string, patch: Partial<NoteState>) =>
setRounds((rs) => rs.map((r, i) => (i === ri ? { ...r, notes: { ...r.notes, [key]: { ...r.notes[key], ...patch } } } : r)));
const frameFor = (n: FeedbackPreviewNote, s: NoteState): number | null => {
if (s.frame !== null) return s.frame.trim() === "" ? null : Number(s.frame);
return n.versions.find((v) => v.id === s.versionId)?.playerFrame ?? null;
};
const included = preview
? preview.rounds.flatMap((r, ri) => r.notes.filter((n) => rounds[ri]?.notes[n.key]?.include && rounds[ri].notes[n.key].text.trim()))
: [];
const save = () => {
if (!preview) return;
const payload = preview.rounds
.map((r, ri) => {
const rs = rounds[ri];
return {
title: rs.title,
dueAt: rs.due ? new Date(rs.due).toISOString() : null,
notes: r.notes
.filter((n) => rs.notes[n.key].include && rs.notes[n.key].text.trim())
.map((n) => {
const s = rs.notes[n.key];
const frame = frameFor(n, s);
return {
code: n.code,
shotId: n.shot?.id ?? null,
text: s.text.trim(),
versionId: n.shot ? s.versionId : null,
frame: frame != null && Number.isFinite(frame) && frame >= 0 ? Math.floor(frame) : null,
setChanges: !!s.versionId && s.setChanges,
};
}),
};
})
.filter((r) => r.notes.length > 0);
if (payload.length === 0) return;
startTransition(async () => {
try {
const res = await commitFeedback({ projectId, rounds: payload });
toast({
title: `${res.notes} note${res.notes === 1 ? "" : "s"} logged`,
description: `${res.comments} in the player · ${res.tasksChanged} task${res.tasksChanged === 1 ? "" : "s"} moved to Changes`,
});
reset();
onOpenChange(false);
router.refresh();
} catch (e) {
toast({ title: "Couldn't save feedback", description: (e as Error).message, variant: "destructive" });
}
});
};
const field = "rounded-md border border-zinc-700 bg-zinc-950 px-2 text-xs text-white placeholder:text-zinc-600 focus:border-amber-500/50 focus:outline-none";
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) reset(); onOpenChange(o); }}>
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<MessageSquareText className="h-4 w-4 text-amber-400" />
Paste client feedback · {projectName}
</DialogTitle>
<DialogDescription>
{preview
? "Check where each note will go. Notes are saved in the player as client feedback and added to the board."
: "Paste the message as it arrived. Each note should start with a shot code; a line like “FEEDBACK 113 DUE Oct 7 12:00 PM” starts a round."}
</DialogDescription>
</DialogHeader>
{!preview ? (
<div className="space-y-3">
<textarea
autoFocus
value={input}
onChange={(e) => setInput(e.target.value)}
rows={12}
placeholder={"FEEDBACK 113 DUE Oct 7, 2026 12:00 PM\nUNG_113_008_040 The screen is partially peeking under the mask\nUNG_113_018_050 At frame 1018, the mask is still slipping"}
className={cn(field, "w-full resize-y py-2 font-mono text-[13px] leading-relaxed")}
/>
<div className="flex justify-end">
<Button onClick={() => read(input)} disabled={isPending || !input.trim()}>
{isPending ? "Reading…" : "Read feedback"}
</Button>
</div>
</div>
) : (
<div className="space-y-5">
{preview.ignored.length > 0 && (
<p className="text-xs text-zinc-500">
Skipped (no shot code): {preview.ignored.map((l) => `“${l}”`).join(", ")}
</p>
)}
{preview.rounds.map((r, ri) => {
const rs = rounds[ri];
if (!rs) return null;
return (
<div key={ri} className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<input
value={rs.title}
onChange={(e) => setRounds((all) => all.map((x, i) => (i === ri ? { ...x, title: e.target.value } : x)))}
className={cn(field, "h-8 w-56 text-sm font-medium")}
/>
<span className="text-xs text-zinc-500">due</span>
<input
type="datetime-local"
value={rs.due}
onChange={(e) => setRounds((all) => all.map((x, i) => (i === ri ? { ...x, due: e.target.value } : x)))}
className={cn(field, "h-8 [color-scheme:dark]")}
/>
{r.dueText && !rs.due && (
<span className="text-xs text-amber-400">Couldn’t read “{r.dueText}”, set it by hand</span>
)}
</div>
<div className="overflow-x-auto rounded-lg border border-zinc-800">
<table className="w-full text-xs">
<thead className="bg-zinc-950 text-left uppercase tracking-wider text-zinc-500">
<tr>
<th className="w-8 px-2 py-2" />
<th className="px-2 py-2">Shot</th>
<th className="px-2 py-2">Note</th>
<th className="px-2 py-2">Version</th>
<th className="px-2 py-2">Frame</th>
<th className="px-2 py-2" title="Move the task to Changes">Changes</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800">
{r.notes.map((n) => {
const s = rs.notes[n.key];
const v = n.versions.find((x) => x.id === s.versionId) ?? null;
const frame = frameFor(n, s);
return (
<tr key={n.key} className={cn("align-top", !s.include && "opacity-40")}>
<td className="px-2 py-2">
<input
type="checkbox"
checked={s.include}
onChange={(e) => updateNote(ri, n.key, { include: e.target.checked })}
className="mt-1 accent-amber-500"
/>
</td>
<td className="whitespace-nowrap px-2 py-2 font-mono">
{n.shot ? (
<span className="text-amber-400">{n.shot.shotCode}</span>
) : (
<div className="space-y-1">
<span className="flex items-center gap-1 text-red-400">
<AlertTriangle className="h-3 w-3" /> {n.code}
</span>
{n.suggestion ? (
<button
type="button"
onClick={() => applySuggestion(n.code, n.suggestion!)}
className="flex items-center gap-1 font-sans text-zinc-400 hover:text-amber-400"
>
<Link2 className="h-3 w-3" /> Use {n.suggestion}
</button>
) : (
<span className="block font-sans text-zinc-500">Not in project; flag only</span>
)}
</div>
)}
</td>
<td className="min-w-[16rem] px-2 py-2">
<textarea
value={s.text}
onChange={(e) => updateNote(ri, n.key, { text: e.target.value })}
rows={Math.min(5, Math.max(1, Math.ceil(s.text.length / 60)))}
className={cn(field, "w-full resize-y py-1 leading-snug")}
/>
</td>
<td className="px-2 py-2">
{n.versions.length > 0 ? (
<select
value={s.versionId ?? ""}
onChange={(e) => updateNote(ri, n.key, { versionId: e.target.value || null, frame: null })}
className={cn(field, "h-7")}
>
{n.versions.map((o) => (
<option key={o.id} value={o.id}>
{o.label}
{o.sent ? ` · sent${o.sharedAt ? " " + format(new Date(o.sharedAt), "d MMM") : ""}` : " · not sent"}
</option>
))}
<option value="">Board only</option>
</select>
) : (
<span className="text-zinc-500">{n.shot ? "No versions; board only" : "—"}</span>
)}
{v && !v.sent && <span className="mt-1 block text-amber-400/80">Not sent to client</span>}
</td>
<td className="px-2 py-2">
{s.versionId ? (
<>
<input
inputMode="numeric"
value={s.frame ?? (frame != null ? String(frame) : "")}
onChange={(e) => updateNote(ri, n.key, { frame: e.target.value.replace(/\D/g, "") })}
placeholder="0"
className={cn(field, "h-7 w-16 font-mono")}
/>
{n.quotedFrame != null && s.frame === null && v && (
<span className="mt-1 block whitespace-nowrap text-zinc-500">
{v.fromComp ? `comp ${n.quotedFrame}` : `as quoted`}
</span>
)}
</>
) : (
<span className="text-zinc-600">—</span>
)}
</td>
<td className="px-2 py-2 text-center">
{v?.taskId ? (
<input
type="checkbox"
checked={s.setChanges}
disabled={v.taskStatus === "CHANGES"}
title={v.taskStatus === "CHANGES" ? "Already in Changes" : "Move the task to Changes"}
onChange={(e) => updateNote(ri, n.key, { setChanges: e.target.checked })}
className="mt-1 accent-amber-500"
/>
) : (
<span className="text-zinc-600">—</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
})}
<div className="flex flex-wrap items-center justify-between gap-2">
<Button variant="ghost" onClick={() => setPreview(null)} disabled={isPending}>
<ArrowLeft /> Edit paste
</Button>
<Button onClick={save} disabled={isPending || included.length === 0}>
{isPending ? "Saving…" : `Save ${included.length} note${included.length === 1 ? "" : "s"}`}
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}
+13
View File
@@ -38,6 +38,7 @@ export default async function BoardPage({
orderBy: { sortOrder: "asc" },
include: {
assignee: { select: { id: true, name: true, image: true } },
round: { select: { id: true, title: true, dueAt: true } },
shot: {
select: {
id: true,
@@ -76,6 +77,14 @@ export default async function BoardPage({
}
}
// Pasted-feedback lines link to the comment they were saved as
const commentIds = projects.flatMap((p) => p.boardItems.map((i) => i.commentId).filter(Boolean) as string[]);
const commentVersion = new Map(
(commentIds.length
? await db.comment.findMany({ where: { id: { in: commentIds } }, select: { id: true, versionId: true, frameNumber: true } })
: []
).map((c) => [c.id, c]),
);
const shotCounts = await db.shot.groupBy({
by: ["projectId", "status"],
where: { projectId: { in: projectIds } },
@@ -127,6 +136,10 @@ export default async function BoardPage({
code: i.code,
group: i.group,
waitingOn: i.waitingOn,
round: i.round ? { id: i.round.id, title: i.round.title, dueAt: i.round.dueAt?.toISOString() ?? null } : null,
comment: i.commentId && commentVersion.has(i.commentId)
? { versionId: commentVersion.get(i.commentId)!.versionId, frame: commentVersion.get(i.commentId)!.frameNumber }
: null,
doneAt: i.doneAt?.toISOString() ?? null,
assignee: i.assignee,
shot: i.shot
@@ -46,6 +46,8 @@ interface Comment {
timestamp: number;
text: string;
isResolved: boolean;
/** "WHATSAPP" = client feedback sent by message and logged by the studio */
source?: string | null;
createdAt: string;
/** This reviewer wrote it, so they can change or remove it. Set by the API. */
canEdit?: boolean;
@@ -660,7 +662,9 @@ export default function ClientReviewPage({
</AvatarFallback>
</Avatar>
<span className="text-xs text-zinc-300 font-medium">
{comment.author.name ?? comment.author.email.split("@")[0]}
{comment.source === "WHATSAPP"
? "Client feedback"
: comment.author.name ?? comment.author.email.split("@")[0]}
</span>
<span className="ml-auto font-mono text-xs text-amber-400/80">
{frameToTimecode(comment.frameNumber, version.fps)}
+11 -1
View File
@@ -342,8 +342,18 @@ function CommentThread({
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-medium">
{comment.author?.name ?? comment.author?.email ?? "Deleted User"}
{comment.source === "WHATSAPP"
? "Client feedback"
: comment.author?.name ?? comment.author?.email ?? "Deleted User"}
</span>
{comment.source === "WHATSAPP" && (
<span
className="rounded border border-indigo-500/20 bg-indigo-500/10 px-1 py-px text-[10px] text-indigo-400"
title={`Pasted from WhatsApp by ${comment.author?.name ?? comment.author?.email ?? "someone"}`}
>
via {comment.author?.name?.split(" ")[0] ?? "WhatsApp"}
</span>
)}
<button
className="font-mono text-xs text-primary/80 hover:text-primary hover:underline"
onClick={() => onJumpToFrame(comment.frameNumber)}
+116
View File
@@ -0,0 +1,116 @@
import { describe, it, expect } from "vitest";
import { parseDue, parseFeedback, slateFrames, toPlayerFrame } from "./feedback-parse";
describe("parseFeedback", () => {
it("splits rounds and notes from the doc's own format", () => {
const { rounds, ignored } = parseFeedback(`FEEDBACK 117 DUE Oct 7, 2026 12:00 PM
UNG_117_023_010 The screen comp starts warping, slightly expanding and contracting in the second half of the shot.
UNG_117_024_010 48fps @ 200% Done Dineo seems slightly too small
FEEDBACK 113 DUE Oct 7, 2026 12:00 PM
UNG_113_008_040 The screen is partially peeking under the mask on OZ
UNG_113_018_050 At frame 1018, the mask is still slipping`);
expect(ignored).toEqual([]);
expect(rounds.map((r) => [r.title, r.dueText, r.notes.length])).toEqual([
["FEEDBACK 117", "Oct 7, 2026 12:00 PM", 2],
["FEEDBACK 113", "Oct 7, 2026 12:00 PM", 2],
]);
expect(rounds[1].notes[1]).toEqual({
codes: ["UNG_113_018_050"],
text: "At frame 1018, the mask is still slipping",
frame: 1018,
});
expect(rounds[0].notes[1].frame).toBeNull();
});
it("joins wrapped lines onto the note above, even ones mentioning changes or due", () => {
const { rounds } = parseFeedback(`UNG_108_004_030 Comp needs work.
Visible matte edge on left of "Top Story" banner.
lighting changes, due to the grade`);
expect(rounds).toHaveLength(1);
expect(rounds[0].title).toBe("");
expect(rounds[0].notes[0].text).toBe(
'Comp needs work. Visible matte edge on left of "Top Story" banner. lighting changes, due to the grade',
);
});
it("strips WhatsApp prefixes and formatting, and keeps preamble aside", () => {
const { rounds, ignored } = parseFeedback(`[07/10/2026, 09:12] Stephen Abbott: Hi team, notes below
[07/10/2026, 09:13] Stephen Abbott: *UNG_113_031_010* As the camera pans the screen comp seems to shrink`);
expect(rounds[0].title).toBe("");
expect(rounds[0].notes[0].codes).toEqual(["UNG_113_031_010"]);
expect(rounds[0].notes[0].text).toBe("As the camera pans the screen comp seems to shrink");
expect(ignored).toEqual(["Hi team, notes below"]);
const plain = parseFeedback("Morning!\nUNG_113_031_010 shrink");
expect(plain.ignored).toEqual(["Morning!"]);
});
it("gives one note per shot when several codes lead the line", () => {
const { rounds } = parseFeedback("UNG_113_032_034 / UNG_113_032_036, UNG_113_032_038 same matte fix");
expect(rounds[0].notes.map((n) => [n.codes[0], n.text])).toEqual([
["UNG_113_032_034", "same matte fix"],
["UNG_113_032_036", "same matte fix"],
["UNG_113_032_038", "same matte fix"],
]);
});
it("accepts codes without the show prefix and upper-cases them", () => {
const { rounds } = parseFeedback("- 113_018_040: hard blue edge, fr 1004");
expect(rounds[0].notes[0]).toMatchObject({ codes: ["113_018_040"], text: "hard blue edge, fr 1004", frame: 1004 });
});
});
describe("parseDue", () => {
const now = new Date(2026, 9, 7, 8, 0);
it("reads a full date and time", () => {
const d = parseDue("Oct 7, 2026 12:00 PM", now)!;
expect([d.getFullYear(), d.getMonth(), d.getDate(), d.getHours()]).toEqual([2026, 9, 7, 12]);
});
it("fills in the year", () => {
const d = parseDue("7 Oct 16:00", now)!;
expect([d.getFullYear(), d.getMonth(), d.getDate(), d.getHours()]).toEqual([2026, 9, 7, 16]);
});
it("returns null for what it cannot read", () => {
expect(parseDue("end of day", now)).toBeNull();
expect(parseDue(null, now)).toBeNull();
});
});
describe("slateFrames", () => {
it("uses the recorded slate, else 1 for pipeline previews, else 0", () => {
expect(slateFrames({ pictureStartFrame: 2, frameCount: 120 })).toBe(2);
expect(slateFrames({ pictureStartFrame: 0, frameCount: 120 })).toBe(0);
expect(slateFrames({ pictureStartFrame: null, frameCount: 120 })).toBe(1);
expect(slateFrames({ pictureStartFrame: null, frameCount: null })).toBe(0);
});
});
describe("toPlayerFrame", () => {
it("maps a comp frame through the shot start and slate", () => {
expect(toPlayerFrame(1018, { shotFrameStart: 1001, pictureStart: 1, frameCount: 120 })).toEqual({
frame: 18,
fromComp: true,
});
});
it("assumes the house 1001 start when the shot has none", () => {
expect(toPlayerFrame(1018, { shotFrameStart: null, pictureStart: 1, frameCount: 120 }).frame).toBe(18);
expect(toPlayerFrame(1018, { shotFrameStart: 0, pictureStart: 0, frameCount: null }).frame).toBe(17);
});
it("takes a small number as the player's own frame", () => {
expect(toPlayerFrame(40, { shotFrameStart: 1001, pictureStart: 1, frameCount: 120 })).toEqual({
frame: 40,
fromComp: false,
});
});
it("keeps the frame inside the picture", () => {
expect(toPlayerFrame(1500, { shotFrameStart: 1001, pictureStart: 1, frameCount: 100 }).frame).toBe(100);
expect(toPlayerFrame(500, { shotFrameStart: null, pictureStart: 0, frameCount: 100 }).frame).toBe(99);
});
});
+180
View File
@@ -0,0 +1,180 @@
/**
* Client feedback pasted from WhatsApp (or an email, or the old doc) split
* into rounds and per-shot notes.
*
* [07/10/2026, 09:12] Stephen: FEEDBACK 113 DUE Oct 7, 2026 12:00 PM
* UNG_113_018_040 there is a hard blue edge on her finger,
* along her thumb, which is not in the OCF
* UNG_113_018_050 At frame 1018, the mask is still slipping
*
* - A line that starts with a shot code starts a note. Several codes before
* the text ("UNG_113_032_034 / UNG_113_032_036 same fix") give one note per
* shot. Lines without a code continue the note above them — WhatsApp
* messages wrap.
* - A line with no code that names feedback/notes/a round, mentions a due
* date, or ends in ":" starts a new round. "DUE …" is split off as the
* round's due text; turning it into a date is left to the browser
* (parseDue) so "12:00 PM" means the studio's noon, not the server's.
* - WhatsApp's copy prefix ("[07/10, 09:12] Name: ") and bold, italic
* and strike markers are stripped.
*
* Codes are kept as typed; matching them to shots is the server's job.
*/
export interface FeedbackNote {
codes: string[];
text: string;
/** Frame number as the client quoted it ("at frame 1018"); null = none given */
frame: number | null;
}
export interface FeedbackRoundDraft {
title: string;
dueText: string | null;
notes: FeedbackNote[];
}
export interface ParsedFeedback {
rounds: FeedbackRoundDraft[];
/** Lines before any note that were not a round header ("Hi team") */
ignored: string[];
}
// "[07/10/2026, 09:12]" / "[9:12 AM, 10/7/2026]" then "Sender Name: "
const WHATSAPP_PREFIX = /^\[[^\]]*\d[^\]]*\]\s*(?:[^:]{1,60}:\s)?/;
const BULLET = /^(?:[-*•–]|\d+[.)])\s+/;
// One code: underscore-joined segments. Clients sometimes drop the show
// prefix ("113_018_050"), so two segments are enough here.
const CODE = "[A-Za-z0-9]+(?:_[A-Za-z0-9]+)+";
const LEADING_CODES = new RegExp(`^(${CODE})((?:\\s*(?:,|/|&|\\+|and)?\\s*${CODE})*)`, "i");
const HEADER_START = /^(feedback|notes|round|comments)\b/i;
const DUE = /\bdue\b[:\s]*(.*)$/i;
const DUE_WITH_DATE = /\bdue\b[:\s]+\S*\d/i;
const FRAME = /\b(?:frames?|fr\.?|frm)\s*#?\s*(\d{1,6})\b/i;
function clean(line: string): string {
return line
.replace(WHATSAPP_PREFIX, "")
.replace(/[*~]/g, "") // *bold*, ~strike~
// _italic_ only around whole words: shot codes are full of underscores
.replace(/(^|\s)_([^_\s][^_]*?)_(?=[\s.,!?]|$)/g, "$1$2")
.replace(BULLET, "")
.trim();
}
// Strict on purpose: a wrapped note line ("lighting changes, due to the
// grade") must continue the note, not start a round.
function isHeader(line: string): boolean {
if (HEADER_START.test(line)) return true;
return line.length <= 60 && (line.endsWith(":") || DUE_WITH_DATE.test(line));
}
export function parseFeedback(input: string): ParsedFeedback {
const rounds: FeedbackRoundDraft[] = [];
const ignored: string[] = [];
let round: FeedbackRoundDraft | null = null;
let note: FeedbackNote[] | null = null; // the note (one per code) being continued
const startRound = (title: string, dueText: string | null) => {
round = { title, dueText, notes: [] };
rounds.push(round);
note = null;
};
for (const raw of input.split(/\r?\n/)) {
const line = clean(raw);
if (!line) continue;
const lead = LEADING_CODES.exec(line);
if (lead) {
const codes = (lead[0].match(new RegExp(CODE, "g")) ?? []).map((c) => c.toUpperCase());
const text = line.slice(lead[0].length).replace(/^[\s:\-–,.]+/, "").trim();
if (!round) startRound("", null);
note = codes.map((code) => ({ codes: [code], text, frame: null }));
round!.notes.push(...note);
continue;
}
if (isHeader(line)) {
const due = DUE.exec(line);
const title = (due ? line.slice(0, due.index) : line).replace(/[\s:\-–]+$/, "").trim();
startRound(title, due?.[1].trim() || null);
continue;
}
if (note) {
for (const n of note as FeedbackNote[]) n.text = n.text ? `${n.text} ${line}` : line;
} else {
ignored.push(line);
}
}
for (const r of rounds as FeedbackRoundDraft[]) {
for (const n of r.notes) {
const f = FRAME.exec(n.text);
n.frame = f ? Number(f[1]) : null;
}
}
return { rounds: rounds.filter((r) => r.notes.length > 0), ignored };
}
/**
* The round's due time from the text the client wrote ("Oct 7, 2026 12:00 PM",
* "7 Oct 12:00", "Friday" is not attempted). Run in the browser so the time is
* read in the studio's zone. A date without a year takes `now`'s year.
*/
export function parseDue(dueText: string | null, now = new Date()): Date | null {
if (!dueText) return null;
const text = dueText.replace(/\b(at|@)\b/gi, " ").replace(/\s+/g, " ").trim();
// V8's Date parser accepts almost anything ("end of day 2026"); insist on
// a month name or a numeric date before handing it over.
if (!/\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\b|\d{1,2}[/.-]\d{1,2}/i.test(text)) return null;
const direct = new Date(text);
if (!isNaN(direct.getTime()) && /\d{4}/.test(text)) return direct;
const withYear = new Date(`${text} ${now.getFullYear()}`);
if (!isNaN(withYear.getTime())) return withYear;
// "7 Oct 12:00" → move the year before the time so Date can read it
const m = /^(.*?)(\d{1,2}:\d{2}\s*(?:am|pm)?)$/i.exec(text);
if (m) {
const d = new Date(`${m[1].trim()} ${now.getFullYear()} ${m[2]}`);
if (!isNaN(d.getTime())) return d;
}
return null;
}
/** First comp frame by house convention, for shots with no frameStart recorded */
export const HOUSE_FRAME_START = 1001;
/**
* Slate frames before the picture in a version's review media. Recorded on
* newer versions; otherwise pipeline previews (the ones that record an EXR
* frameCount) open with a 1-frame slate and other uploads with none.
* Version.duration is not stored, so the length-based inference in
* lib/handles.ts is not available here.
*/
export function slateFrames(v: { pictureStartFrame: number | null; frameCount: number | null }): number {
if (v.pictureStartFrame != null && v.pictureStartFrame >= 0) return v.pictureStartFrame;
return v.frameCount != null ? 1 : 0;
}
/**
* The player frame (0-based, in the review media) for a frame the client
* quoted. Clients read the burned-in comp frame (1001-based), so a quote at
* or past the shot's first frame is mapped through it and the slate; a
* smaller number is taken as the player's own counter.
*/
export function toPlayerFrame(
quoted: number,
opts: { shotFrameStart: number | null; pictureStart: number; frameCount: number | null },
): { frame: number; fromComp: boolean } {
const { pictureStart, frameCount } = opts;
const compStart = opts.shotFrameStart && opts.shotFrameStart > 0 ? opts.shotFrameStart : HOUSE_FRAME_START;
const last = frameCount && frameCount > 0 ? pictureStart + frameCount - 1 : null;
const clamp = (f: number, min: number) => Math.max(min, last != null ? Math.min(last, f) : f);
if (quoted >= compStart) {
return { frame: clamp(quoted - compStart + pictureStart, pictureStart), fromComp: true };
}
return { frame: clamp(quoted, 0), fromComp: false };
}
@@ -0,0 +1,22 @@
-- Client feedback pasted from WhatsApp: rounds group the notes on the board,
-- and each note is saved as a player comment tagged with its source.
ALTER TABLE "comments" ADD COLUMN "source" TEXT;
CREATE TABLE "feedback_rounds" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"dueAt" TIMESTAMP(3),
"createdById" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "feedback_rounds_pkey" PRIMARY KEY ("id")
);
ALTER TABLE "board_items" ADD COLUMN "roundId" TEXT;
ALTER TABLE "board_items" ADD COLUMN "commentId" TEXT;
CREATE INDEX "feedback_rounds_projectId_idx" ON "feedback_rounds"("projectId");
ALTER TABLE "feedback_rounds" ADD CONSTRAINT "feedback_rounds_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "board_items" ADD CONSTRAINT "board_items_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "feedback_rounds"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+28
View File
@@ -390,6 +390,7 @@ model Project {
boardStatusAt DateTime?
boardItems BoardItem[]
milestones ProjectMilestone[]
feedbackRounds FeedbackRound[]
@@map("projects")
}
@@ -612,6 +613,10 @@ model Comment {
timestamp Float
text String @db.Text
isResolved Boolean @default(false)
/// Where the note came from when it was not typed in the player. "WHATSAPP"
/// = client feedback pasted from a message; `authorId` is then whoever
/// logged it, not who said it, and the player labels it client feedback.
source String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -1288,6 +1293,12 @@ model BoardItem {
assignee User? @relation("BoardItemAssignee", fields: [assigneeId], references: [id], onDelete: SetNull)
/// What the line is blocked on ("client GFX ADH_GFX_013_010"); null = not blocked
waitingOn String?
/// Set when the line came from pasted client feedback: the round it belongs
/// to, and the player comment it was saved as. Ticking the line off
/// resolves that comment.
roundId String?
round FeedbackRound? @relation(fields: [roundId], references: [id], onDelete: SetNull)
commentId String?
sortOrder Int @default(0)
doneAt DateTime?
doneById String?
@@ -1300,6 +1311,23 @@ model BoardItem {
@@map("board_items")
}
/// One batch of client feedback as it arrived ("FEEDBACK 113 DUE Oct 7 12:00
/// PM"). Its notes are saved as player comments and as board lines; the round
/// is what groups them on the board under one due time.
model FeedbackRound {
id String @id @default(cuid())
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
title String
dueAt DateTime?
createdById String
createdAt DateTime @default(now())
items BoardItem[]
@@index([projectId])
@@map("feedback_rounds")
}
/// A dated project milestone — batch deliveries, picture locks, shoot start,
/// colour & mix review. Episode due dates stay in EpisodeDueDate because the
/// shot-status board keys off them; these are the dates around the work.
+2
View File
@@ -64,6 +64,8 @@ export interface CommentWithReplies {
timestamp: number;
text: string;
isResolved: boolean;
/** "WHATSAPP" = client feedback pasted from a message; author is who logged it */
source?: string | null;
createdAt: Date;
updatedAt: Date;
author: {