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;
+}
|