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 <noreply@anthropic.com>
This commit is contained in:
twotalesanimation
2026-09-10 05:45:01 +02:00
parent f6a7f0ef5a
commit 6bfc013e35
4 changed files with 423 additions and 6 deletions
+40 -5
View File
@@ -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 (
<div className="p-8 space-y-6 max-w-[1600px] mx-auto">
@@ -123,6 +149,15 @@ export default async function DashboardPage() {
{!isClient && <StatsCards stats={stats} />}
{!isClient && (
<div className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Delivery countdown
</h2>
<DeliveryCountdown episodes={episodes} />
</div>
)}
{!isClient && (
<div id="blocked" className="grid grid-cols-1 xl:grid-cols-3 gap-6 items-start scroll-mt-6">
<div className="xl:col-span-2 space-y-3">
+123
View File
@@ -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<CountdownVerdict, { rail: string; chip: string; label: string }> = {
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 (
<div className="rounded-lg border border-zinc-800 bg-card px-4 py-8 text-center text-sm text-zinc-500">
No delivery dates set. Add them on a project&apos;s Episodes tab to see the countdown here.
</div>
);
}
return (
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 xl:grid-cols-3">
{episodes.map((ep) => {
const verdict = VERDICT[ep.verdict];
const pct = (n: number) => (ep.total === 0 ? 0 : (n / ep.total) * 100);
return (
<Link
key={`${ep.projectId}:${ep.episode ?? ""}`}
href={`/shot-status?projectId=${ep.projectId}`}
className="group relative flex flex-col gap-3.5 overflow-hidden rounded-lg border border-zinc-800 bg-card p-4 pl-[18px] transition-colors hover:border-zinc-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500"
>
{/* Severity rail — the card itself stays neutral so only the
episode in trouble pulls the eye. */}
<span className={cn("absolute inset-y-0 left-0 w-[3px]", verdict.rail)} aria-hidden="true" />
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-[15px] font-semibold text-white">
{ep.projectCode}
{ep.episode && <span className="font-normal text-zinc-500"> · ep {ep.episode}</span>}
</div>
<div className="mt-0.5 text-xs text-zinc-400">
Due {format(ep.dueDate, "EEE d MMM yyyy")}
</div>
</div>
<div className="shrink-0 text-right">
<b className="block text-3xl font-bold leading-none tabular-nums text-white">
{Math.abs(ep.daysLeft)}
</b>
<small className="text-[11px] uppercase tracking-wide text-zinc-500">
{ep.daysLeft < 0 ? "days over" : "days left"}
</small>
</div>
</div>
<div>
<div
className="flex h-2 overflow-hidden rounded-full bg-zinc-800"
role="img"
aria-label={`${ep.delivered} of ${ep.total} shots delivered, ${ep.inQc} through QC, ${ep.inProgress} in progress`}
>
<i className="block h-full bg-emerald-500" style={{ width: `${pct(ep.delivered)}%` }} />
<i className="block h-full bg-amber-500" style={{ width: `${pct(ep.inQc)}%` }} />
<i className="block h-full bg-blue-500" style={{ width: `${pct(ep.inProgress)}%` }} />
</div>
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-zinc-400">
<span className="inline-flex items-center gap-1.5">
<i className="h-2 w-2 rounded-sm bg-emerald-500" /> {ep.delivered} delivered
</span>
<span className="inline-flex items-center gap-1.5">
<i className="h-2 w-2 rounded-sm bg-amber-500" /> {ep.inQc} in QC
</span>
<span className="inline-flex items-center gap-1.5">
<i className="h-2 w-2 rounded-sm bg-blue-500" /> {ep.inProgress} in progress
</span>
<span className="inline-flex items-center gap-1.5">
<i className="h-2 w-2 rounded-sm bg-zinc-700" /> {ep.notStarted} not started
</span>
</div>
</div>
<div className="flex items-center justify-between gap-3 border-t border-zinc-800/60 pt-3">
<div>
<div className="text-[11px] text-zinc-500">Needed / day</div>
<div className="font-mono text-[15px] tabular-nums text-white">
{perDay(ep.requiredPerDay)}
</div>
</div>
<div>
<div className="text-[11px] text-zinc-500">Last 7 days</div>
<div className="font-mono text-[15px] tabular-nums text-white">
{perDay(ep.actualPerDay)}
</div>
</div>
<span className={cn("rounded-full px-2.5 py-1 text-xs font-semibold", verdict.chip)}>
{ep.verdict === "behind" && ep.daysBehind
? `${ep.daysBehind} day${ep.daysBehind === 1 ? "" : "s"} behind`
: ep.verdict === "overdue" && ep.daysBehind
? `${ep.daysBehind} day${ep.daysBehind === 1 ? "" : "s"} over`
: verdict.label}
</span>
</div>
</Link>
);
})}
</div>
);
}
+55 -1
View File
@@ -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
+205
View File
@@ -180,6 +180,211 @@ export async function getBlockedExports(limit = 25): Promise<BlockedExport[]> {
});
}
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<EpisodeCountdown, "requiredPerDay" | "actualPerDay" | "verdict" | "daysBehind"> {
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<EpisodeCountdown[]> {
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<string, Date>();
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;