6bfc013e35
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>
124 lines
6.0 KiB
TypeScript
124 lines
6.0 KiB
TypeScript
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>
|
|
);
|
|
}
|