From 6bfc013e350283adf945dc424e191caf2e6f6c35 Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Thu, 10 Sep 2026 05:45:01 +0200 Subject: [PATCH] feat(dashboard): episode delivery countdown Turns "23 shots to go" into "you are not going to make it". One card per episode from EpisodeDueDate (or the project's own date on a standard show): days left, the episode's shots by where they actually are, and the only comparison that matters - shots per day still needed against shots per day the studio managed over the last week. A shot counts as delivered when it appears in a DeliveryItem inside a DELIVERED EXR_DELIVERY package. Shot.status is deliberately not the measure: it says where the work is, not whether the client has the frames, and a MOV_REVIEW package ships nothing. The pace arithmetic is a pure function so the sentence a producer acts on is testable: a rate that only just lands on the due date reports as "no slack" rather than fine, and a week with no deliveries reports as stalled instead of projecting infinity. Co-Authored-By: Claude Opus 5 --- app/(dashboard)/dashboard/page.tsx | 45 ++++- components/dashboard/DeliveryCountdown.tsx | 123 +++++++++++++ lib/dashboard/ops.test.ts | 56 +++++- lib/dashboard/ops.ts | 205 +++++++++++++++++++++ 4 files changed, 423 insertions(+), 6 deletions(-) create mode 100644 components/dashboard/DeliveryCountdown.tsx diff --git a/app/(dashboard)/dashboard/page.tsx b/app/(dashboard)/dashboard/page.tsx index 05eca0d..67d61b6 100644 --- a/app/(dashboard)/dashboard/page.tsx +++ b/app/(dashboard)/dashboard/page.tsx @@ -3,10 +3,16 @@ import { db } from "@/lib/db"; 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 { ShotQueue } from "@/components/dashboard/ShotQueue"; import { TaskWidgets } from "@/components/dashboard/TaskWidgets"; import { ScheduleWidgets } from "@/components/dashboard/ScheduleWidgets"; -import { getBlockedExports, getFarmStatus, getOpsCounts } from "@/lib/dashboard/ops"; +import { + getBlockedExports, + getDeliveryCountdown, + getFarmStatus, + getOpsCounts, +} from "@/lib/dashboard/ops"; import type { DashboardStats } from "@/types"; export const metadata = { title: "Dashboard" }; @@ -23,10 +29,11 @@ async function getDashboardData(userId: string, role: string) { const isArtist = role === "ARTIST"; const isClient = role === "CLIENT"; - const [counts, blocked, farm] = await Promise.all([ + const [counts, blocked, farm, episodes] = await Promise.all([ getOpsCounts(), getBlockedExports(), getFarmStatus(), + getDeliveryCountdown(), ]); // An artist's own shots come first; everyone else sees the floor. @@ -100,7 +107,17 @@ async function getDashboardData(userId: string, role: string) { const stats: DashboardStats = counts; - return { stats, blocked, farm, shots, myTasks, reviewTasks, scheduledTasks, scheduleArtists }; + return { + stats, + blocked, + farm, + episodes, + shots, + myTasks, + reviewTasks, + scheduledTasks, + scheduleArtists, + }; } export default async function DashboardPage() { @@ -109,8 +126,17 @@ export default async function DashboardPage() { const isClient = session.user.role === "CLIENT"; const canSeeSchedule = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role); - const { stats, blocked, farm, shots, myTasks, reviewTasks, scheduledTasks, scheduleArtists } = - await getDashboardData(session.user.id, session.user.role); + const { + stats, + blocked, + farm, + episodes, + shots, + myTasks, + reviewTasks, + scheduledTasks, + scheduleArtists, + } = await getDashboardData(session.user.id, session.user.role); return (
@@ -123,6 +149,15 @@ export default async function DashboardPage() { {!isClient && } + {!isClient && ( +
+

+ Delivery countdown +

+ +
+ )} + {!isClient && (
diff --git a/components/dashboard/DeliveryCountdown.tsx b/components/dashboard/DeliveryCountdown.tsx new file mode 100644 index 0000000..d6676ed --- /dev/null +++ b/components/dashboard/DeliveryCountdown.tsx @@ -0,0 +1,123 @@ +import Link from "next/link"; +import { format } from "date-fns"; +import { cn } from "@/lib/utils"; +import type { CountdownVerdict, EpisodeCountdown } from "@/lib/dashboard/ops"; + +/** + * Are we going to make the date? One card per episode, sorted by how soon. + * + * The bar is the episode's shots by where they actually are, and the row under + * it is the only comparison that matters: shots per day still needed against + * shots per day the studio has managed this week. + */ + +const VERDICT: Record = { + overdue: { rail: "bg-red-500", chip: "text-red-400 bg-red-500/10", label: "Overdue" }, + behind: { rail: "bg-red-500", chip: "text-red-400 bg-red-500/10", label: "Behind" }, + stalled: { rail: "bg-red-500", chip: "text-red-400 bg-red-500/10", label: "Nothing delivered this week" }, + tight: { rail: "bg-amber-500", chip: "text-amber-400 bg-amber-500/10", label: "No slack" }, + ahead: { rail: "bg-green-500", chip: "text-green-400 bg-green-500/10", label: "On track" }, + complete: { rail: "bg-emerald-500", chip: "text-emerald-400 bg-emerald-500/10", label: "All delivered" }, +}; + +/** Two decimals is false precision for a shot count; one is the real resolution. */ +const perDay = (n: number) => n.toFixed(1); + +export function DeliveryCountdown({ episodes }: { episodes: EpisodeCountdown[] }) { + if (episodes.length === 0) { + return ( +
+ No delivery dates set. Add them on a project's Episodes tab to see the countdown here. +
+ ); + } + + return ( +
+ {episodes.map((ep) => { + const verdict = VERDICT[ep.verdict]; + const pct = (n: number) => (ep.total === 0 ? 0 : (n / ep.total) * 100); + + return ( + + {/* Severity rail — the card itself stays neutral so only the + episode in trouble pulls the eye. */} +
+ ); +} diff --git a/lib/dashboard/ops.test.ts b/lib/dashboard/ops.test.ts index 5411fce..0162f2e 100644 --- a/lib/dashboard/ops.test.ts +++ b/lib/dashboard/ops.test.ts @@ -1,5 +1,59 @@ import { describe, it, expect } from "vitest"; -import { compareVersions } from "./ops"; +import { compareVersions, projectPace } from "./ops"; + +/** + * The countdown's whole job is to say "you are not going to make it" early + * enough to do something about it, so the arithmetic behind that sentence is + * worth pinning down. + */ +describe("projectPace", () => { + it("calls it behind when the trailing rate overruns the date", () => { + // 29 shots to go, a fortnight left, 8 delivered last week: 1.14/day + // against the 2.07/day needed, projecting 25.4 days of work — 11.4 days + // past the date, rounded up to a whole day late. + const pace = projectPace(29, 14, 8); + expect(pace.verdict).toBe("behind"); + expect(pace.requiredPerDay).toBeCloseTo(2.07, 2); + expect(pace.actualPerDay).toBeCloseTo(1.14, 2); + expect(pace.daysBehind).toBe(12); + }); + + it("calls a rate that only just lands tight rather than fine", () => { + // Finishing on the last available day leaves no room for a QC failure. + expect(projectPace(10, 10, 7).verdict).toBe("tight"); + }); + + it("is on track with real slack", () => { + const pace = projectPace(5, 30, 7); + expect(pace.verdict).toBe("ahead"); + expect(pace.daysBehind).toBeNull(); + }); + + it("separates a stall from being merely slow", () => { + // Nothing shipped in a week is not a slow rate, it is no rate at all — + // dividing by it would project infinity. + const pace = projectPace(12, 20, 0); + expect(pace.verdict).toBe("stalled"); + expect(pace.daysBehind).toBeNull(); + }); + + it("reports how far past the date an unfinished episode is", () => { + const pace = projectPace(3, -5, 4); + expect(pace.verdict).toBe("overdue"); + expect(pace.daysBehind).toBe(5); + }); + + it("is complete once nothing is left, whatever the rate", () => { + expect(projectPace(0, 3, 0).verdict).toBe("complete"); + expect(projectPace(0, 3, 0).requiredPerDay).toBe(0); + }); + + it("does not divide by zero on the due date itself", () => { + // daysLeft 0 means "today": the work still has to be quoted per day. + expect(projectPace(4, 0, 7).requiredPerDay).toBe(4); + expect(Number.isFinite(projectPace(4, 0, 7).requiredPerDay)).toBe(true); + }); +}); /** * The farm strip flags machines running an older worker than the newest one diff --git a/lib/dashboard/ops.ts b/lib/dashboard/ops.ts index 47c3937..fc49f47 100644 --- a/lib/dashboard/ops.ts +++ b/lib/dashboard/ops.ts @@ -180,6 +180,211 @@ export async function getBlockedExports(limit = 25): Promise { }); } +export type CountdownVerdict = "complete" | "overdue" | "stalled" | "behind" | "tight" | "ahead"; + +export interface EpisodeCountdown { + projectId: string; + projectCode: string; + projectName: string; + /** Null on a standard (non-episodic) show, which has one date of its own. */ + episode: string | null; + dueDate: Date; + /** Calendar days to the due date; negative once it has passed. */ + daysLeft: number; + total: number; + delivered: number; + inQc: number; + inProgress: number; + notStarted: number; + /** Shots per day still needed to land the rest by the due date. */ + requiredPerDay: number; + /** Shots per day actually delivered over the last week. */ + actualPerDay: number; + verdict: CountdownVerdict; + /** + * How late this is: the days the current rate overruns by when `verdict` is + * "behind", or the days the date has already passed when it is "overdue". + */ + daysBehind: number | null; +} + +const DAY_MS = 86_400_000; +/** Shots past QC but not yet in a delivered package. */ +const POST_QC_STATUSES: ExportStatus[] = ["READY_FOR_QC", "READY_FOR_DELIVERY", "PACKAGED"]; + +/** + * Turns "23 shots left, 14 days, 8 delivered last week" into a verdict. + * + * Kept free of the database so the arithmetic that decides whether a producer + * panics can be tested directly. The trailing week is the rate we have + * evidence for; anything else is a hope. A run that only just lands on the due + * date is reported as tight rather than fine — finishing on the last available + * day is luck, not schedule. + */ +export function projectPace( + remaining: number, + daysLeft: number, + deliveredThisWeek: number +): Pick { + const actualPerDay = deliveredThisWeek / 7; + const requiredPerDay = remaining <= 0 ? 0 : remaining / Math.max(daysLeft, 1); + + if (remaining <= 0) { + return { requiredPerDay, actualPerDay, verdict: "complete", daysBehind: null }; + } + if (daysLeft < 0) { + return { requiredPerDay, actualPerDay, verdict: "overdue", daysBehind: -daysLeft }; + } + if (actualPerDay === 0) { + return { requiredPerDay, actualPerDay, verdict: "stalled", daysBehind: null }; + } + + const projectedDays = remaining / actualPerDay; + if (projectedDays > daysLeft) { + return { + requiredPerDay, + actualPerDay, + verdict: "behind", + daysBehind: Math.ceil(projectedDays - daysLeft), + }; + } + return { + requiredPerDay, + actualPerDay, + verdict: projectedDays > daysLeft * 0.9 ? "tight" : "ahead", + daysBehind: null, + }; +} + +/** + * Are we going to make the date, per episode? + * + * A shot counts as delivered when it appears in a package that has actually + * gone out — a DeliveryItem in a DELIVERED EXR_DELIVERY package. Shot.status + * is deliberately not the measure: it says where the work is, not whether the + * client has the frames, and a review package (MOV_REVIEW) ships nothing. + * + * The run rate compares what is left per remaining day against what the studio + * actually managed per day over the last week, which is the only honest way to + * turn "23 shots to go" into "you are not going to make it". + */ +export async function getDeliveryCountdown(limit = 6): Promise { + const projects = await db.project.findMany({ + where: { status: "ACTIVE" }, + select: { + id: true, + code: true, + name: true, + dueDate: true, + projectType: true, + episodeDueDates: { select: { episode: true, dueDate: true } }, + }, + }); + if (projects.length === 0) return []; + + const projectIds = projects.map((p) => p.id); + const weekAgo = new Date(Date.now() - 7 * DAY_MS); + + const [shots, postQc, deliveredItems] = await Promise.all([ + db.shot.findMany({ + where: { projectId: { in: projectIds } }, + select: { id: true, projectId: true, episode: true, status: true }, + }), + db.export.findMany({ + where: { status: { in: POST_QC_STATUSES }, shot: { projectId: { in: projectIds } } }, + select: { shotId: true }, + }), + db.deliveryItem.findMany({ + where: { + package: { + projectId: { in: projectIds }, + kind: "EXR_DELIVERY", + status: "DELIVERED", + }, + }, + select: { shotId: true, package: { select: { deliveredAt: true } } }, + }), + ]); + + const postQcShots = new Set(postQc.map((e) => e.shotId)); + // A shot can be in several packages (a redelivery); the first one out is the + // one that counts towards the rate. + const deliveredAt = new Map(); + for (const item of deliveredItems) { + const when = item.package.deliveredAt; + if (!when) continue; + const seen = deliveredAt.get(item.shotId); + if (!seen || when < seen) deliveredAt.set(item.shotId, when); + } + + const now = Date.now(); + const rows: EpisodeCountdown[] = []; + + for (const project of projects) { + // Episodic shows carry a date per episode; a standard show has one date on + // the project itself, and is treated as a single unnamed target. + const targets: { episode: string | null; dueDate: Date }[] = + project.projectType === "EPISODIC" + ? project.episodeDueDates.map((e) => ({ episode: e.episode, dueDate: e.dueDate })) + : project.dueDate + ? [{ episode: null, dueDate: project.dueDate }] + : []; + + for (const target of targets) { + const targetShots = shots.filter( + (s) => + s.projectId === project.id && + (target.episode === null || (s.episode ?? "") === target.episode) + ); + if (targetShots.length === 0) continue; + + let delivered = 0; + let inQc = 0; + let inProgress = 0; + let notStarted = 0; + let deliveredThisWeek = 0; + + for (const shot of targetShots) { + const when = deliveredAt.get(shot.id); + if (when) { + delivered++; + if (when >= weekAgo) deliveredThisWeek++; + } else if (postQcShots.has(shot.id)) { + inQc++; + } else if (shot.status === "WAITING") { + notStarted++; + } else { + inProgress++; + } + } + + const total = targetShots.length; + const daysLeft = Math.ceil((target.dueDate.getTime() - now) / DAY_MS); + const pace = projectPace(total - delivered, daysLeft, deliveredThisWeek); + + // A finished episode whose date has passed is history, not a countdown. + if (pace.verdict === "complete" && daysLeft < 0) continue; + + rows.push({ + projectId: project.id, + projectCode: project.code, + projectName: project.name, + episode: target.episode, + dueDate: target.dueDate, + daysLeft, + total, + delivered, + inQc, + inProgress, + notStarted, + ...pace, + }); + } + } + + return rows.sort((a, b) => a.dueDate.getTime() - b.dueDate.getTime()).slice(0, limit); +} + export interface OpsCounts { blocked: number; inQc: number;