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
+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>
);
}