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