diff --git a/app/(dashboard)/shot-status/ShotStatusClient.tsx b/app/(dashboard)/shot-status/ShotStatusClient.tsx index 29fddf1..8f6c082 100644 --- a/app/(dashboard)/shot-status/ShotStatusClient.tsx +++ b/app/(dashboard)/shot-status/ShotStatusClient.tsx @@ -60,6 +60,7 @@ import { } from "lucide-react"; import { format } from "date-fns"; import { shotThumbnail } from "@/lib/shot-thumbnail"; +import type { ShotDeliverySummary } from "@/lib/render-pipeline/shot-delivery-summary"; type ShotRow = { id: string; @@ -79,6 +80,7 @@ type ShotRow = { manualDeliveredAt: Date | string | null; manualDeliveryNote: string | null; shotVersion: string | null; + delivery: ShotDeliverySummary; artist: { id: string; name: string | null; image: string | null; email: string } | null; shotGroup: { id: string; name: string } | null; }; @@ -853,6 +855,57 @@ function ScaledShotToggle({ shot }: { shot: ShotRow }) { ); } +const PENDING_TONE = { + progress: "text-blue-300", + waiting: "text-amber-300", + failed: "text-red-400", +} as const; + +/** + * What last reached the client for this shot, with evidence — not what was + * rendered or built — plus whatever newer is still on its way. + */ +function DeliveryCell({ delivery, shotVersion }: { delivery: ShotDeliverySummary; shotVersion: string | null }) { + const { sent, review, pending } = delivery; + if (!sent && !review && !pending) { + return Never sent; + } + // The panel's version moving past what was delivered means a newer version + // exists that the client has not had, even before it has been exported. + const behind = sent?.version && shotVersion && sent.version !== shotVersion && !pending; + + return ( +
+ {sent ? ( +
+ + {sent.version && {sent.version}} + {format(new Date(sent.at), "d MMM")} + + {sent.via === "MANUAL" ? "· manual" : sent.to ? `· ${sent.to}` : ""} + +
+ ) : ( +
Not delivered
+ )} + {pending && ( +
+ {pending.version && {pending.version}} {pending.label} +
+ )} + {behind &&
current is {shotVersion}
} + {review && ( +
+ Review MOV {review.version} {format(new Date(review.at), "d MMM")} +
+ )} +
+ ); +} + function ShotTable({ shots, canManage, @@ -889,6 +942,7 @@ function ShotTable({ Status Artist Due Date + Last sent Notes @@ -970,15 +1024,6 @@ function ShotTable({ Scaled )} - {shot.manualDeliveredAt && ( - - - Delivered {format(new Date(shot.manualDeliveredAt), "d MMM")} - - )} {shot.description && (

@@ -1031,6 +1076,11 @@ function ShotTable({ )} + {/* Last delivery, and anything newer on its way */} + + + + {/* Notes */} diff --git a/app/(dashboard)/shot-status/page.tsx b/app/(dashboard)/shot-status/page.tsx index 50b34ef..cc6463f 100644 --- a/app/(dashboard)/shot-status/page.tsx +++ b/app/(dashboard)/shot-status/page.tsx @@ -2,6 +2,7 @@ import { db } from "@/lib/db"; import { auth } from "@/auth"; import { redirect } from "next/navigation"; import { ShotStatusClient } from "./ShotStatusClient"; +import { summariseShotDelivery } from "@/lib/render-pipeline/shot-delivery-summary"; async function getProjects() { return db.project.findMany({ @@ -29,7 +30,7 @@ async function getAssignableArtists() { } async function getShotsForProject(projectId: string) { - return db.shot.findMany({ + const shots = await db.shot.findMany({ where: { projectId }, orderBy: [ { episode: { sort: "asc", nulls: "last" } }, @@ -53,13 +54,55 @@ async function getShotsForProject(projectId: string) { isScaled: true, manualDeliveredAt: true, manualDeliveryNote: true, + manualDeliverySharedWith: true, // The shot-level version the panel burns into overlays and queueExport // renders under — not the count of review Versions uploaded. shotVersion: true, artist: { select: { id: true, name: true, image: true, email: true } }, shotGroup: { select: { id: true, name: true } }, + // Every export, for the packages each went into; the newest live one + // is what is on its way. + exports: { + orderBy: [{ versionNumber: "desc" }, { createdAt: "desc" }], + select: { + versionString: true, + status: true, + deliveryItems: { + select: { + versionString: true, + package: { + select: { + kind: true, + status: true, + packageNumber: true, + createdAt: true, + builtAt: true, + uploadedAt: true, + deliveredAt: true, + sharedWith: true, + }, + }, + }, + }, + }, + }, }, }); + + return shots.map(({ exports, ...shot }) => ({ + ...shot, + delivery: summariseShotDelivery({ + packages: exports.flatMap((e) => + e.deliveryItems.map((item) => ({ ...item.package, versionString: item.versionString })) + ), + manual: { + deliveredAt: shot.manualDeliveredAt, + sharedWith: shot.manualDeliverySharedWith, + note: shot.manualDeliveryNote, + }, + latestExport: exports.find((e) => e.status !== "CANCELLED" && e.status !== "ARCHIVED") ?? null, + }), + })); } export default async function ShotStatusPage({ diff --git a/lib/render-pipeline/shot-delivery-summary.test.ts b/lib/render-pipeline/shot-delivery-summary.test.ts new file mode 100644 index 0000000..9d65491 --- /dev/null +++ b/lib/render-pipeline/shot-delivery-summary.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; +import { summariseShotDelivery, type ShotDeliveryPackage } from "./shot-delivery-summary"; + +const day = (d: number) => new Date(Date.UTC(2026, 8, d, 12)); + +const pkg = (over: Partial): ShotDeliveryPackage => ({ + kind: "EXR_DELIVERY", + status: "DELIVERED", + packageNumber: 1, + versionString: "v001", + createdAt: day(1), + builtAt: day(1), + uploadedAt: day(1), + deliveredAt: day(1), + sharedWith: "Dana", + ...over, +}); + +const noManual = { deliveredAt: null, sharedWith: null, note: null }; + +describe("summariseShotDelivery", () => { + it("reports nothing for a shot that has never been exported", () => { + expect(summariseShotDelivery({ packages: [], manual: noManual, latestExport: null })).toEqual({ + sent: null, + review: null, + pending: null, + }); + }); + + it("takes the most recently shared EXR package as sent", () => { + const s = summariseShotDelivery({ + packages: [ + pkg({ packageNumber: 3, versionString: "v002", deliveredAt: day(5), createdAt: day(4) }), + pkg({ packageNumber: 1, versionString: "v001", deliveredAt: day(2) }), + ], + manual: noManual, + latestExport: { versionString: "v002", status: "DELIVERED" }, + }); + expect(s.sent).toMatchObject({ via: "PACKAGE", version: "v002", label: "Package #3", to: "Dana" }); + expect(s.pending).toBeNull(); + }); + + it("prefers a later manual delivery", () => { + const s = summariseShotDelivery({ + packages: [pkg({ deliveredAt: day(2) })], + manual: { deliveredAt: day(6), sharedWith: "Sam", note: "WeTransfer" }, + latestExport: null, + }); + expect(s.sent).toMatchObject({ via: "MANUAL", version: null, to: "Sam", note: "WeTransfer" }); + }); + + it("does not count a built but unshared package as sent", () => { + const s = summariseShotDelivery({ + packages: [pkg({ status: "READY", uploadedAt: null, deliveredAt: null, packageNumber: 7, versionString: "v003" })], + manual: noManual, + latestExport: { versionString: "v003", status: "PACKAGED" }, + }); + expect(s.sent).toBeNull(); + expect(s.pending).toEqual({ version: "v003", label: "built in #7, not uploaded", tone: "waiting" }); + }); + + it("flags an uploaded package whose link was never shared", () => { + const s = summariseShotDelivery({ + packages: [pkg({ status: "READY", deliveredAt: null, packageNumber: 7 })], + manual: noManual, + latestExport: null, + }); + expect(s.pending?.label).toBe("uploaded in #7, link not shared"); + }); + + it("ignores cancelled packages and open packages older than the last send", () => { + const s = summariseShotDelivery({ + packages: [ + pkg({ status: "CANCELLED", deliveredAt: null, createdAt: day(9) }), + pkg({ status: "FAILED", deliveredAt: null, createdAt: day(1) }), + pkg({ deliveredAt: day(3), createdAt: day(2) }), + ], + manual: noManual, + latestExport: { versionString: "v001", status: "DELIVERED" }, + }); + expect(s.pending).toBeNull(); + }); + + it("shows a newer export that has not gone out", () => { + const s = summariseShotDelivery({ + packages: [pkg({ deliveredAt: day(2) })], + manual: noManual, + latestExport: { versionString: "v002", status: "READY_FOR_QC" }, + }); + expect(s.pending).toEqual({ version: "v002", label: "in QC", tone: "waiting" }); + }); + + it("reports failed exports as failed", () => { + const s = summariseShotDelivery({ + packages: [], + manual: noManual, + latestExport: { versionString: "v002", status: "QC_FAILED" }, + }); + expect(s.pending?.tone).toBe("failed"); + }); + + it("keeps review MOVs apart from deliveries", () => { + const s = summariseShotDelivery({ + packages: [pkg({ kind: "MOV_REVIEW", status: "READY", packageNumber: 4, versionString: "v002", builtAt: day(8), deliveredAt: null })], + manual: noManual, + latestExport: null, + }); + expect(s.sent).toBeNull(); + expect(s.pending).toBeNull(); + expect(s.review).toMatchObject({ version: "v002", label: "Review #4" }); + }); +}); diff --git a/lib/render-pipeline/shot-delivery-summary.ts b/lib/render-pipeline/shot-delivery-summary.ts new file mode 100644 index 0000000..763b8dc --- /dev/null +++ b/lib/render-pipeline/shot-delivery-summary.ts @@ -0,0 +1,124 @@ +/** + * What has gone out for one shot, and what is on its way — the answer to + * "have we sent this?" on Shot Status. Kept free of database imports; the page + * gathers the rows and this decides what they mean. + */ + +export type ShotDeliveryPackage = { + kind: string; // EXR_DELIVERY | MOV_REVIEW + status: string; // DeliveryStatus + packageNumber: number; + versionString: string; + createdAt: Date; + builtAt: Date | null; + uploadedAt: Date | null; + deliveredAt: Date | null; + sharedWith: string | null; +}; + +export type PendingTone = "progress" | "waiting" | "failed"; + +export type ShotDeliverySummary = { + /** The last time the shot reached the client with evidence. */ + sent: { + via: "PACKAGE" | "MANUAL"; + version: string | null; // unknown for a manual delivery + at: Date; + to: string | null; + label: string; // "Package #15" | "Outside pipeline" + note: string | null; + } | null; + /** The last review MOV package the shot was built into. */ + review: { version: string; at: Date; label: string } | null; + /** Something newer than `sent` that has not gone out yet. */ + pending: { version: string | null; label: string; tone: PendingTone } | null; +}; + +const EXPORT_STAGE: Record = { + QUEUED: { label: "queued to render", tone: "progress" }, + RENDERING: { label: "rendering", tone: "progress" }, + RENDER_FAILED: { label: "render failed", tone: "failed" }, + VALIDATING: { label: "validating", tone: "progress" }, + VALIDATION_FAILED: { label: "failed validation", tone: "failed" }, + GENERATING_PREVIEW: { label: "building preview", tone: "progress" }, + PREVIEW_FAILED: { label: "preview failed", tone: "failed" }, + READY_FOR_QC: { label: "in QC", tone: "waiting" }, + QC_FAILED: { label: "failed QC", tone: "failed" }, + READY_FOR_DELIVERY: { label: "ready to package", tone: "waiting" }, +}; + +export function summariseShotDelivery(input: { + packages: ShotDeliveryPackage[]; + manual: { deliveredAt: Date | null; sharedWith: string | null; note: string | null }; + /** The shot's newest export, if any. */ + latestExport: { versionString: string; status: string } | null; +}): ShotDeliverySummary { + const { packages, manual, latestExport } = input; + const live = packages.filter((p) => p.status !== "CANCELLED"); + const exr = live.filter((p) => p.kind === "EXR_DELIVERY"); + + let sent: ShotDeliverySummary["sent"] = null; + for (const p of exr) { + if (p.status !== "DELIVERED" || !p.deliveredAt) continue; + if (!sent || p.deliveredAt > sent.at) { + sent = { + via: "PACKAGE", + version: p.versionString, + at: p.deliveredAt, + to: p.sharedWith, + label: `Package #${p.packageNumber}`, + note: null, + }; + } + } + if (manual.deliveredAt && (!sent || manual.deliveredAt > sent.at)) { + sent = { + via: "MANUAL", + version: null, + at: manual.deliveredAt, + to: manual.sharedWith, + label: "Outside pipeline", + note: manual.note, + }; + } + + let review: ShotDeliverySummary["review"] = null; + for (const p of live) { + if (p.kind !== "MOV_REVIEW" || p.status !== "READY") continue; + const at = p.builtAt ?? p.createdAt; + if (!review || at > review.at) { + review = { version: p.versionString, at, label: `Review #${p.packageNumber}` }; + } + } + + return { sent, review, pending: findPending(exr, sent, latestExport) }; +} + +function findPending( + exr: ShotDeliveryPackage[], + sent: ShotDeliverySummary["sent"], + latestExport: { versionString: string; status: string } | null +): ShotDeliverySummary["pending"] { + // The newest EXR package started after the last send and not delivered. + const open = exr + .filter((p) => p.status !== "DELIVERED" && (!sent || p.createdAt > sent.at)) + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0]; + if (open) { + const pkg = `#${open.packageNumber}`; + if (open.status === "READY") { + return open.uploadedAt + ? { version: open.versionString, label: `uploaded in ${pkg}, link not shared`, tone: "waiting" } + : { version: open.versionString, label: `built in ${pkg}, not uploaded`, tone: "waiting" }; + } + if (open.status === "FAILED") { + return { version: open.versionString, label: `package ${pkg} failed`, tone: "failed" }; + } + return { version: open.versionString, label: `package ${pkg} building`, tone: "progress" }; + } + + // Otherwise the newest export, if it is a version that has not gone out. + if (!latestExport) return null; + if (sent?.version === latestExport.versionString) return null; + const stage = EXPORT_STAGE[latestExport.status]; + return stage ? { version: latestExport.versionString, ...stage } : null; +}