Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions app/lib/cartoon-production-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import type { CartoonChecklist, CartoonChecklistStep } from "./cartoon-readiness";

export type CartoonProductionStepKey =
| CartoonChecklistStep["key"]
| "assemble"
| "ready";

export interface CartoonProductionStep {
key: CartoonProductionStepKey;
label: string;
status: "done" | "current" | "todo";
detail: string | null;
}

export interface CartoonProductionStatus {
steps: CartoonProductionStep[];
activeStep: CartoonProductionStep | null;
statusLabel: string | null;
completedCount: number;
totalCount: number;
outstandingCount: number;
}

export function buildCartoonProductionStatus(input: {
checklist: CartoonChecklist | null;
markdownReady?: boolean;
published?: boolean;
}): CartoonProductionStatus | null {
const { checklist, markdownReady = false, published = false } = input;
if (!checklist || checklist.steps.length === 0) return null;

const uploadDone =
checklist.steps.find((step) => step.key === "upload")?.status === "done";
const ready = uploadDone && markdownReady && !published;
const assembleStatus: CartoonProductionStep["status"] = published || markdownReady
? "done"
: uploadDone
? "current"
: "todo";
const readyStatus: CartoonProductionStep["status"] = published
? "done"
: ready
? "current"
: "todo";

const steps: CartoonProductionStep[] = [
...checklist.steps.filter((step) => step.key !== "publish"),
{
key: "assemble",
label: "Episode sequence prepared",
status: assembleStatus,
detail: null,
},
{
key: "ready",
label: published ? "Published to PlotLink" : "Ready to publish",
status: readyStatus,
detail: null,
},
];

const activeStep =
steps.find((step) => step.status === "current")
?? steps.find((step) => step.status === "todo")
?? steps[steps.length - 1]
?? null;
const completedCount = steps.filter((step) => step.status === "done").length;

return {
steps,
activeStep,
statusLabel: activeStep?.label ?? null,
completedCount,
totalCount: steps.length,
outstandingCount: steps.filter((step) => step.status !== "done").length,
};
}
142 changes: 142 additions & 0 deletions app/web/components/CartoonProductionStatus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { groupCartoonIssues, type CartoonChecklist } from "@app-lib/cartoon-readiness";
import { buildCartoonProductionStatus } from "@app-lib/cartoon-production-status";
import type { ReactNode } from "react";

const STATUS_MARK: Record<"done" | "current" | "todo", string> = {
done: "✓",
current: "▸",
todo: "○",
};

interface CartoonProductionStatusProps {
checklist: CartoonChecklist | null;
markdownReady?: boolean;
published?: boolean;
issues?: string[];
title?: string;
subtitle?: ReactNode;
action?: ReactNode;
rootTestId?: string;
detailsTestId?: string;
stepTestIdPrefix?: string;
issuesTestId?: string;
issueGroupTestIdPrefix?: string;
detailsLabel?: string;
summaryTestId?: string;
}

export function CartoonProductionStatus({
checklist,
markdownReady = false,
published = false,
issues = [],
title = "Episode production",
subtitle,
action,
rootTestId = "cartoon-production-status",
detailsTestId = "cartoon-production-details",
stepTestIdPrefix = "cartoon-production-step",
issuesTestId,
issueGroupTestIdPrefix,
detailsLabel,
summaryTestId,
}: CartoonProductionStatusProps) {
const production = buildCartoonProductionStatus({
checklist,
markdownReady,
published,
});
if (!production) return null;

const groups = groupCartoonIssues(issues);
const issuesCount = groups.reduce((sum, group) => sum + group.lines.length, 0);
const statusLabel = production.statusLabel ?? "Review cuts";
const progressLabel = `${production.completedCount} / ${production.totalCount} steps done`;
const detailsText = detailsLabel
?? (production.outstandingCount === 0
? "Production details"
: `${production.outstandingCount} step${production.outstandingCount === 1 ? "" : "s"} left`);

return (
<div
className="rounded-lg border border-border bg-background/80 px-3 py-2"
data-testid={rootTestId}
>
<div className="flex flex-wrap items-start gap-2 justify-between">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-1.5 text-[10px]">
<span className="font-medium text-foreground">{title}</span>
<span
className="rounded-full border border-accent/30 bg-accent/10 px-2 py-0.5 font-medium text-accent"
data-testid={summaryTestId}
>
Active: {statusLabel}
</span>
<span className="rounded-full border border-border bg-background px-2 py-0.5 text-muted">
{progressLabel}
</span>
{production.activeStep?.detail && (
<span className="text-muted">{production.activeStep.detail}</span>
)}
</div>
{subtitle ? (
<div className="text-[10px] text-muted">{subtitle}</div>
) : null}
</div>
{action ? <div className="flex-shrink-0">{action}</div> : null}
</div>

<details className="mt-2" data-testid={detailsTestId}>
<summary className="cursor-pointer select-none text-[10px] text-muted hover:text-foreground">
{detailsText}
{issuesCount > 0 ? ` · ${issuesCount} blocker${issuesCount === 1 ? "" : "s"}` : ""}
</summary>

<div className="mt-1.5 space-y-1.5">
<ol className="flex flex-wrap gap-1.5">
{production.steps.map((step) => (
<li
key={step.key}
data-testid={`${stepTestIdPrefix}-${step.key}`}
data-status={step.status}
className={`flex items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] ${
step.status === "current"
? "border-accent/40 bg-accent/10 text-accent"
: step.status === "done"
? "border-border bg-background/70 text-foreground"
: "border-border/70 bg-background/40 text-muted"
}`}
>
<span aria-hidden>{STATUS_MARK[step.status]}</span>
<span>{step.label}</span>
{step.detail && <span className="text-muted">· {step.detail}</span>}
</li>
))}
</ol>

{groups.length > 0 && (
<div
className="space-y-1.5"
data-testid={issuesTestId ?? `${stepTestIdPrefix}-issues`}
>
{groups.map((group) => (
<div
key={group.key}
data-testid={`${issueGroupTestIdPrefix ?? `${stepTestIdPrefix}-issue-group`}-${group.key}`}
className="text-[10px]"
>
<p className="font-medium text-amber-700">{group.title}</p>
<ul className="ml-3 list-disc text-muted">
{group.lines.map((line, i) => (
<li key={i}>{line}</li>
))}
</ul>
</div>
))}
</div>
)}
</div>
</details>
</div>
);
}
55 changes: 46 additions & 9 deletions app/web/components/CartoonPublishPage.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, cleanup, fireEvent, waitFor, within } from "@testing-library/react";
import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react";
import { CartoonPublishPage } from "./CartoonPublishPage";
import type { StoryProgress, EpisodeProgress } from "@app-lib/story-progress";
import type { Cut } from "@app-lib/cuts";

afterEach(cleanup);

Expand All @@ -25,6 +26,46 @@ function progress(o: Partial<StoryProgress> & { episodes: EpisodeProgress[] }):
};
}

function makeCut(id: number, overrides: Partial<Cut> = {}): Cut {
return {
id,
shotType: "medium",
description: "scene",
characters: [],
dialogue: [],
narration: "",
sfx: "",
cleanImagePath: null,
finalImagePath: null,
exportedAt: null,
uploadedCid: null,
uploadedUrl: null,
overlays: [],
...overrides,
};
}

function cleanCuts(count: number): Cut[] {
return Array.from({ length: count }, (_, i) =>
makeCut(i + 1, {
cleanImagePath: `assets/genesis/cut-${i + 1}-clean.webp`,
}),
);
}

function uploadedCuts(count: number): Cut[] {
return Array.from({ length: count }, (_, i) =>
makeCut(i + 1, {
cleanImagePath: `assets/genesis/cut-${i + 1}-clean.webp`,
finalImagePath: `assets/genesis/cut-${i + 1}-final.webp`,
exportedAt: "2026-06-08T00:00:00Z",
uploadedCid: `Qm${i + 1}`,
uploadedUrl: `https://ipfs.example/${i + 1}`,
overlays: [{ id: `o-${i + 1}`, type: "speech", x: 0.1, y: 0.1, width: 0.2, height: 0.1, text: "hi" }],
}),
);
}

// A publishable Genesis opening (real H1 + multi-paragraph prose) so the migrated
// title (#358) and prologue-readiness (#359) diagnostics don't block publish in
// the ready-state tests. Override per test via the `files` map.
Expand All @@ -37,7 +78,7 @@ By dawn, nothing in the building — or the city beyond it — will be the same.
function makeAuthFetch(p: StoryProgress | null, files?: Record<string, unknown>) {
const map: Record<string, unknown> = {
content: { content: GOOD_GENESIS },
cuts: { cuts: [], title: null },
cuts: { cuts: uploadedCuts(2), title: null },
structure: { content: "# The Awakening\n" },
...files,
};
Expand All @@ -56,12 +97,10 @@ const MIDWAY_CUTS = { total: 10, needClean: 10, withClean: 10, withText: 0, expo
describe("CartoonPublishPage (#449)", () => {
it("summarizes readiness for the active episode and disables Publish until ready", async () => {
const p = progress({ cover: "present", episodes: [ep({ file: "genesis.md", state: "in-progress", summary: "3 / 10 cuts have uploaded images", cuts: MIDWAY_CUTS })] });
render(<CartoonPublishPage storyName="god-cell" authFetch={makeAuthFetch(p)} onOpenFile={vi.fn()} onOpenStoryInfo={vi.fn()} />);
render(<CartoonPublishPage storyName="god-cell" authFetch={makeAuthFetch(p, { cuts: { cuts: cleanCuts(10), title: null } })} onOpenFile={vi.fn()} onOpenStoryInfo={vi.fn()} />);

expect(await screen.findByTestId("cartoon-publish-page")).toHaveTextContent("Publish Episode 1 / Genesis");
const checklist = screen.getByTestId("publish-checklist");
expect(checklist).toHaveTextContent("Cuts lettered");
expect(checklist).toHaveTextContent("Final images uploaded");
expect(await screen.findByTestId("publish-production-status")).toHaveTextContent("Active: Add speech bubbles & captions");
// Not ready → the publish CTA is disabled and a reason is shown.
expect(screen.getByTestId("publish-cta")).toBeDisabled();
expect(screen.getByTestId("publish-blocked-reason")).toBeInTheDocument();
Expand Down Expand Up @@ -140,9 +179,7 @@ describe("CartoonPublishPage (#449)", () => {

fireEvent.click(await screen.findByTestId("publish-add-cover"));
expect(onOpenStoryInfo).toHaveBeenCalled();
// The cover check reads as not-done.
const coverRow = within(screen.getByTestId("publish-checklist")).getByText("Cover image");
expect(coverRow.closest("[data-testid='publish-check']")).toHaveAttribute("data-status", "todo");
expect(screen.getByTestId("publish-cover-status")).toHaveTextContent("Cover image: Missing");
});

it("shows an all-published state when every episode is published", async () => {
Expand Down
Loading
Loading