Shows what last reached the client with evidence (shared EXR package or manual delivery), anything newer still on its way, and the last review MOV, so it's clear on Shot Status what has actually been sent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 <span className="text-xs text-zinc-600 italic">Never sent</span>;
|
||||
}
|
||||
// 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 (
|
||||
<div className="space-y-0.5 text-xs leading-tight">
|
||||
{sent ? (
|
||||
<div
|
||||
className="flex items-center gap-1 text-emerald-300"
|
||||
title={[sent.label, sent.to && `sent to ${sent.to}`, sent.note].filter(Boolean).join(" · ")}
|
||||
>
|
||||
<PackageCheck className="h-3 w-3 shrink-0" />
|
||||
{sent.version && <span className="font-mono">{sent.version}</span>}
|
||||
<span className="text-zinc-300">{format(new Date(sent.at), "d MMM")}</span>
|
||||
<span className="truncate text-zinc-500">
|
||||
{sent.via === "MANUAL" ? "· manual" : sent.to ? `· ${sent.to}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-zinc-600 italic">Not delivered</div>
|
||||
)}
|
||||
{pending && (
|
||||
<div className={cn("truncate", PENDING_TONE[pending.tone])} title={pending.label}>
|
||||
{pending.version && <span className="font-mono">{pending.version}</span>} {pending.label}
|
||||
</div>
|
||||
)}
|
||||
{behind && <div className="text-zinc-500">current is <span className="font-mono">{shotVersion}</span></div>}
|
||||
{review && (
|
||||
<div className="truncate text-zinc-500" title={review.label}>
|
||||
Review MOV <span className="font-mono">{review.version}</span> {format(new Date(review.at), "d MMM")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ShotTable({
|
||||
shots,
|
||||
canManage,
|
||||
@@ -889,6 +942,7 @@ function ShotTable({
|
||||
<th className="text-left py-2 px-3 text-xs font-medium text-zinc-500 w-32">Status</th>
|
||||
<th className="text-left py-2 px-3 text-xs font-medium text-zinc-500 w-40">Artist</th>
|
||||
<th className="text-left py-2 px-3 text-xs font-medium text-zinc-500 w-32">Due Date</th>
|
||||
<th className="text-left py-2 px-3 text-xs font-medium text-zinc-500 w-48">Last sent</th>
|
||||
<th className="text-left py-2 px-3 text-xs font-medium text-zinc-500">Notes</th>
|
||||
<th className="py-2 px-3 text-xs font-medium text-zinc-500 w-20 text-center">
|
||||
<span className="flex items-center gap-1 justify-center">
|
||||
@@ -970,15 +1024,6 @@ function ShotTable({
|
||||
Scaled
|
||||
</span>
|
||||
)}
|
||||
{shot.manualDeliveredAt && (
|
||||
<span
|
||||
title={shot.manualDeliveryNote ?? "Delivered outside the render pipeline"}
|
||||
className="flex items-center gap-1 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-300 shrink-0"
|
||||
>
|
||||
<PackageCheck className="h-2.5 w-2.5" />
|
||||
Delivered {format(new Date(shot.manualDeliveredAt), "d MMM")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{shot.description && (
|
||||
<p className="text-xs text-zinc-500 mt-0.5 truncate max-w-[200px]">
|
||||
@@ -1031,6 +1076,11 @@ function ShotTable({
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Last delivery, and anything newer on its way */}
|
||||
<td className="py-2 px-3">
|
||||
<DeliveryCell delivery={shot.delivery} shotVersion={shot.shotVersion} />
|
||||
</td>
|
||||
|
||||
{/* Notes */}
|
||||
<td className="py-2 px-3 min-w-[220px]">
|
||||
<NotesCell shot={shot} canManage={canManage} />
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>): 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" });
|
||||
});
|
||||
});
|
||||
@@ -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<string, { label: string; tone: PendingTone }> = {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user