From 6afbe82a296078e648fafe86df6211c3c21bf731 Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Thu, 10 Sep 2026 05:58:08 +0200 Subject: [PATCH] feat(dashboard): QC queue, deliveries and whose-court panels Completes the operations dashboard. QC queue: what is waiting on a person to watch the frames, oldest first. Every stage before QC is worked by the farm, so this is the only one that cannot drain itself - depth here is the best early warning that a delivery date is going to move, and it had no home outside a filter on the queue page. Deliveries: the last few packages, flagging FAILED and builds stuck in BUILDING. A failed build is otherwise silent - the exports stay READY_FOR_DELIVERY, nothing reaches the delivery volume, and nobody finds out until the client asks where the shots are. Whose court: versions sent to the client and unanswered, against shots with unresolved notes. Two numbers rather than one because they call for opposite responses - a fortnight-old review is a phone call, unactioned notes are a scheduling problem. The artist view now scopes to the artist throughout: the blocked list, and the counters above it, which otherwise quoted a studio-wide figure next to a personal list. Co-Authored-By: Claude Opus 5 --- app/(dashboard)/dashboard/page.tsx | 64 +++++- components/dashboard/QcQueue.tsx | 96 +++++++++ components/dashboard/RecentDeliveries.tsx | 74 +++++++ components/dashboard/WhoseCourt.tsx | 70 +++++++ lib/dashboard/ops.ts | 241 +++++++++++++++++++++- 5 files changed, 532 insertions(+), 13 deletions(-) create mode 100644 components/dashboard/QcQueue.tsx create mode 100644 components/dashboard/RecentDeliveries.tsx create mode 100644 components/dashboard/WhoseCourt.tsx diff --git a/app/(dashboard)/dashboard/page.tsx b/app/(dashboard)/dashboard/page.tsx index 67d61b6..2b17342 100644 --- a/app/(dashboard)/dashboard/page.tsx +++ b/app/(dashboard)/dashboard/page.tsx @@ -4,6 +4,9 @@ import { StatsCards } from "@/components/dashboard/StatsCards"; import { BlockedQueue } from "@/components/dashboard/BlockedQueue"; import { FarmStrip } from "@/components/dashboard/FarmStrip"; import { DeliveryCountdown } from "@/components/dashboard/DeliveryCountdown"; +import { QcQueue } from "@/components/dashboard/QcQueue"; +import { RecentDeliveries } from "@/components/dashboard/RecentDeliveries"; +import { WhoseCourt } from "@/components/dashboard/WhoseCourt"; import { ShotQueue } from "@/components/dashboard/ShotQueue"; import { TaskWidgets } from "@/components/dashboard/TaskWidgets"; import { ScheduleWidgets } from "@/components/dashboard/ScheduleWidgets"; @@ -12,6 +15,10 @@ import { getDeliveryCountdown, getFarmStatus, getOpsCounts, + getQcQueue, + getQcStaleHours, + getRecentDeliveries, + getWhoseCourt, } from "@/lib/dashboard/ops"; import type { DashboardStats } from "@/types"; @@ -29,12 +36,18 @@ async function getDashboardData(userId: string, role: string) { const isArtist = role === "ARTIST"; const isClient = role === "CLIENT"; - const [counts, blocked, farm, episodes] = await Promise.all([ - getOpsCounts(), - getBlockedExports(), - getFarmStatus(), - getDeliveryCountdown(), - ]); + // Producers see the floor; an artist sees the failures that are theirs. + const [counts, blocked, farm, episodes, qcQueue, qcStaleHours, deliveries, court] = + await Promise.all([ + getOpsCounts(isArtist ? userId : undefined), + getBlockedExports(isArtist ? { artistId: userId } : {}), + getFarmStatus(), + getDeliveryCountdown(), + isArtist || isClient ? [] : getQcQueue(), + getQcStaleHours(), + isArtist || isClient ? [] : getRecentDeliveries(), + isArtist || isClient ? null : getWhoseCourt(), + ]); // An artist's own shots come first; everyone else sees the floor. const shots = await db.shot.findMany({ @@ -112,6 +125,10 @@ async function getDashboardData(userId: string, role: string) { blocked, farm, episodes, + qcQueue, + qcStaleHours, + deliveries, + court, shots, myTasks, reviewTasks, @@ -125,12 +142,17 @@ export default async function DashboardPage() { if (!session?.user) return null; const isClient = session.user.role === "CLIENT"; + const isArtistRole = session.user.role === "ARTIST"; const canSeeSchedule = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role); const { stats, blocked, farm, episodes, + qcQueue, + qcStaleHours, + deliveries, + court, shots, myTasks, reviewTasks, @@ -162,7 +184,7 @@ export default async function DashboardPage() {

- Blocked — needs a human + {isArtistRole ? "Your blocked shots" : "Blocked — needs a human"}

@@ -176,6 +198,34 @@ export default async function DashboardPage() {
)} + {!isClient && !isArtistRole && ( +
+
+

+ QC queue +

+ +
+ +
+
+

+ Deliveries +

+ +
+ {court && ( +
+

+ Whose court +

+ +
+ )} +
+
+ )} + {!isClient && ( +
+

+ {entries.length === 0 + ? "QC queue empty" + : `${entries.length} waiting on QC`} + {oldest && ( + + oldest {formatDistanceToNowStrict(oldest.since)} + + )} +

+ + Open QC → + +
+ + {entries.length === 0 ? ( +

+ Nothing is waiting on a QC pass. +

+ ) : ( +
+ + + + + + + + + + + + {entries.map((e) => { + const stale = Date.now() - e.since.getTime() > staleHours * 3600_000; + return ( + + + + + + + + ); + })} + +
ShotVersionWaitingFramesSubmitted by
+ + {e.shotCode} + +
+ {e.projectCode} + {e.episode ? ` · ep ${e.episode}` : ""} +
+
+ {e.versionString} + + {formatDistanceToNowStrict(e.since)} + + {e.frameStart}–{e.frameEnd} + + {e.submittedBy ?? "—"} +
+
+ )} + + ); +} diff --git a/components/dashboard/RecentDeliveries.tsx b/components/dashboard/RecentDeliveries.tsx new file mode 100644 index 0000000..ee8284b --- /dev/null +++ b/components/dashboard/RecentDeliveries.tsx @@ -0,0 +1,74 @@ +import Link from "next/link"; +import { formatDistanceToNowStrict } from "date-fns"; +import { cn } from "@/lib/utils"; +import { DELIVERY_STATUS_STYLES } from "@/app/(dashboard)/pipeline/status-colors"; +import type { DeliverySummary } from "@/lib/dashboard/ops"; + +/** + * The last few packages, and whether any of them went wrong. + * + * A failed build is otherwise silent — the exports stay READY_FOR_DELIVERY and + * nothing reaches the delivery volume, but no one finds out until the client + * asks where the shots are. + */ +export function RecentDeliveries({ deliveries }: { deliveries: DeliverySummary[] }) { + const problems = deliveries.filter((d) => d.status === "FAILED" || d.stuck).length; + + return ( +
+
+

+ Recent packages + {problems > 0 && ( + + {problems} need{problems === 1 ? "s" : ""} attention + + )} +

+ + All deliveries → + +
+ + {deliveries.length === 0 ? ( +

No packages built yet.

+ ) : ( + deliveries.map((d) => ( + +
+
+ #{d.packageNumber}{" "} + + {d.projectCode} + {d.episode ? ` · ${d.episode}` : ""} + {d.kind === "MOV_REVIEW" ? " · review" : ""} + +
+
+ {d.status === "FAILED" && d.errorMessage + ? d.errorMessage + : `${d.itemCount} shot${d.itemCount === 1 ? "" : "s"} · ${d.atLabel} ${formatDistanceToNowStrict(d.at)} ago`} +
+
+ + {d.stuck ? "STUCK" : d.status} + + + )) + )} +
+ ); +} diff --git a/components/dashboard/WhoseCourt.tsx b/components/dashboard/WhoseCourt.tsx new file mode 100644 index 0000000..3f7625e --- /dev/null +++ b/components/dashboard/WhoseCourt.tsx @@ -0,0 +1,70 @@ +import Link from "next/link"; +import type { WhoseCourt as WhoseCourtData } from "@/lib/dashboard/ops"; + +/** + * Which waits are the client's and which are ours. + * + * Two numbers rather than one, because they call for opposite responses: a + * version unanswered for a fortnight is a phone call, while notes nobody has + * picked up is a scheduling problem. + */ +export function WhoseCourt({ court }: { court: WhoseCourtData }) { + return ( +
+ + +
+ ); +} + +function Side({ + title, + subtitle, + side, + empty, + className, +}: { + title: string; + subtitle: string; + side: WhoseCourtData["client"]; + empty: string; + className?: string; +}) { + return ( +
+

{title}

+

{subtitle}

+ + {side.count} + + {side.items.length === 0 ? ( +

{empty}

+ ) : ( +
    + {side.items.map((item) => ( +
  • + + {item.shotCode} + + {item.label} +
  • + ))} +
+ )} +
+ ); +} diff --git a/lib/dashboard/ops.ts b/lib/dashboard/ops.ts index fc49f47..bdccce2 100644 --- a/lib/dashboard/ops.ts +++ b/lib/dashboard/ops.ts @@ -56,7 +56,10 @@ export interface BlockedExport { * forgotten QC pass shows up. A failed export is currently only visible if * someone thinks to open the render queue and filter for it. */ -export async function getBlockedExports(limit = 25): Promise { +export async function getBlockedExports( + opts: { artistId?: string; limit?: number } = {} +): Promise { + const { artistId, limit = 25 } = opts; const [qcStaleHours, stalledHours] = await Promise.all([ getConfigNumber("dashboard.qcStaleHours"), getConfigNumber("dashboard.stalledJobHours"), @@ -70,7 +73,11 @@ export async function getBlockedExports(limit = 25): Promise { // Finished shows would otherwise fill the list with failures nobody // intends to fix. Export carries a projectId but no project relation, so // the scope is reached through the shot. - shot: { project: { status: { in: ["ACTIVE", "ON_HOLD"] } } }, + shot: { + project: { status: { in: ["ACTIVE", "ON_HOLD"] } }, + // An artist's dashboard shows the failures that are theirs to fix. + ...(artistId ? { artistId } : {}), + }, OR: [ { status: { in: FAILED_STATUSES } }, { status: "READY_FOR_QC", statusChangedAt: { lt: qcCutoff } }, @@ -401,11 +408,16 @@ export interface OpsCounts { * told anyone anything. Each of these can go down, which is what makes it * worth looking at twice in a day. */ -export async function getOpsCounts(): Promise { +export async function getOpsCounts(artistId?: string): Promise { // Export has a projectId column but no project relation, so live-production - // scope is reached through the shot. + // scope is reached through the shot. An artist gets their own figures, so the + // cards agree with the blocked list underneath them rather than quoting a + // studio-wide number beside a personal one. const liveProject: Prisma.ExportWhereInput = { - shot: { project: { status: { in: ["ACTIVE", "ON_HOLD"] } } }, + shot: { + project: { status: { in: ["ACTIVE", "ON_HOLD"] } }, + ...(artistId ? { artistId } : {}), + }, }; const [qcStaleHours, stalledHours] = await Promise.all([ getConfigNumber("dashboard.qcStaleHours"), @@ -436,7 +448,10 @@ export async function getOpsCounts(): Promise { approvalStatus: "PENDING_REVIEW", isClientVisible: true, isLatest: true, - shot: { project: { status: { in: ["ACTIVE", "ON_HOLD"] } } }, + shot: { + project: { status: { in: ["ACTIVE", "ON_HOLD"] } }, + ...(artistId ? { artistId } : {}), + }, }, }), db.project.count({ where: { status: "ACTIVE" } }), @@ -445,6 +460,220 @@ export async function getOpsCounts(): Promise { return { blocked, inQc, readyToDeliver, awaitingClientReview, activeProjects }; } +/** How long an export may sit in READY_FOR_QC before the UI calls it stale. */ +export function getQcStaleHours(): Promise { + return getConfigNumber("dashboard.qcStaleHours"); +} + +export interface QcQueueEntry { + exportId: string; + shotCode: string; + versionString: string; + projectCode: string; + episode: string | null; + since: Date; + frameStart: number; + frameEnd: number; + submittedBy: string | null; +} + +/** + * What is waiting on a human to look at the frames. + * + * QC is the last gate before anything can be packaged, and it is the only + * stage in the pipeline that cannot drain itself — everything before it is + * worked by the farm. Depth here is the single best predictor of a delivery + * slipping, and until now it had no home outside the render queue's filter. + */ +export async function getQcQueue(limit = 8): Promise { + const rows = await db.export.findMany({ + where: { + status: "READY_FOR_QC", + shot: { project: { status: { in: ["ACTIVE", "ON_HOLD"] } } }, + }, + orderBy: { statusChangedAt: "asc" }, + take: limit, + select: { + id: true, + versionString: true, + statusChangedAt: true, + frameStart: true, + frameEnd: true, + submittedByName: true, + shot: { + select: { shotCode: true, episode: true, project: { select: { code: true } } }, + }, + }, + }); + + return rows.map((row) => ({ + exportId: row.id, + shotCode: row.shot.shotCode, + versionString: row.versionString, + projectCode: row.shot.project.code, + episode: row.shot.episode, + since: row.statusChangedAt, + frameStart: row.frameStart, + frameEnd: row.frameEnd, + // Free text from the panel's config: Export keeps submittedById for the + // resolved account but carries no relation to it. + submittedBy: row.submittedByName, + })); +} + +export interface DeliverySummary { + id: string; + packageNumber: number; + name: string; + projectCode: string; + episode: string; + kind: string; + status: string; + itemCount: number; + errorMessage: string | null; + /** Most recent of delivered/built/created — what the timestamp column means. */ + at: Date; + atLabel: "delivered" | "built" | "created"; + /** BUILDING for longer than a build plausibly takes. */ + stuck: boolean; +} + +/** + * Recent packages, and whether any of them went wrong. + * + * A failed delivery build is otherwise silent: the export stays + * READY_FOR_DELIVERY, nothing is on the delivery volume, and the only trace is + * a status on a page nobody opens between deliveries. + */ +export async function getRecentDeliveries(limit = 5): Promise { + const rows = await db.deliveryPackage.findMany({ + where: { project: { status: { in: ["ACTIVE", "ON_HOLD"] } } }, + orderBy: { createdAt: "desc" }, + take: limit, + select: { + id: true, + packageNumber: true, + name: true, + episode: true, + kind: true, + status: true, + errorMessage: true, + createdAt: true, + builtAt: true, + deliveredAt: true, + project: { select: { code: true } }, + _count: { select: { items: true } }, + }, + }); + + const buildStuckAfter = 2 * 3600_000; + return rows.map((row) => { + const at = row.deliveredAt ?? row.builtAt ?? row.createdAt; + return { + id: row.id, + packageNumber: row.packageNumber, + name: row.name, + projectCode: row.project.code, + episode: row.episode, + kind: row.kind, + status: row.status, + itemCount: row._count.items, + errorMessage: row.errorMessage, + at, + atLabel: row.deliveredAt ? "delivered" : row.builtAt ? "built" : "created", + // A build that never finished leaves the package sitting in BUILDING + // forever; nothing times it out. + stuck: row.status === "BUILDING" && Date.now() - row.createdAt.getTime() > buildStuckAfter, + }; + }); +} + +export interface CourtSide { + count: number; + items: { id: string; shotCode: string; label: string; href: string }[]; +} + +export interface WhoseCourt { + client: CourtSide; + us: CourtSide; +} + +/** + * Which waits are ours and which are the client's. + * + * The distinction is the useful part: a version sitting unanswered for eleven + * days is a phone call, while notes nobody has actioned are a scheduling + * problem. Lumping both into "needs revisions" hid which was which. + */ +export async function getWhoseCourt(perSide = 4): Promise { + const liveShot: Prisma.ShotWhereInput = { + project: { status: { in: ["ACTIVE", "ON_HOLD"] } }, + }; + + const [withClient, openNotes] = await Promise.all([ + db.version.findMany({ + where: { + approvalStatus: "PENDING_REVIEW", + isClientVisible: true, + isLatest: true, + shot: liveShot, + }, + // Longest wait first — that is the one worth chasing. + orderBy: [{ sharedAt: { sort: "asc", nulls: "last" } }, { createdAt: "asc" }], + take: perSide, + select: { + id: true, + versionNumber: true, + sharedAt: true, + createdAt: true, + shot: { select: { id: true, shotCode: true } }, + }, + }), + // Unresolved notes on the version currently in play. Grouped in memory + // because the interesting unit is the shot, not the comment. + db.comment.findMany({ + where: { + isResolved: false, + version: { isLatest: true, shot: liveShot }, + }, + select: { id: true, version: { select: { shot: { select: { id: true, shotCode: true } } } } }, + }), + ]); + + const byShot = new Map(); + for (const note of openNotes) { + const shot = note.version?.shot; + if (!shot) continue; + const entry = byShot.get(shot.id) ?? { shotCode: shot.shotCode, count: 0 }; + entry.count++; + byShot.set(shot.id, entry); + } + const ours = [...byShot.entries()].sort((a, b) => b[1].count - a[1].count); + + const days = (from: Date) => Math.max(0, Math.floor((Date.now() - from.getTime()) / DAY_MS)); + + return { + client: { + count: withClient.length, + items: withClient.map((v) => ({ + id: v.id, + shotCode: v.shot?.shotCode ?? "—", + label: `${days(v.sharedAt ?? v.createdAt)}d`, + href: `/review/${v.id}`, + })), + }, + us: { + count: ours.length, + items: ours.slice(0, perSide).map(([shotId, entry]) => ({ + id: shotId, + shotCode: entry.shotCode, + label: `${entry.count} note${entry.count === 1 ? "" : "s"}`, + href: `/shot-status`, + })), + }, + }; +} + export interface FarmStatus { machines: Awaited>; online: number;