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:
@@ -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