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:
@@ -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'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user