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. */}
+
+
+
+
+
+ {ep.projectCode}
+ {ep.episode && · ep {ep.episode}}
+
+ );
+}
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;