feat(report): drop the report sky cover; docs, checklist and progress for M1–M5 (M5+M3)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
This commit is contained in:
Jesse_Chen
2026-09-29 08:41:27 +08:00
co-authored by Claude Opus 5.5
parent 60b5542b7d
commit 447a19b561
16 changed files with 236 additions and 357 deletions
@@ -18,8 +18,6 @@ import { createSupabasePersonalReportJobService } from "@/lib/personal-report-jo
import { createPersonalReportSectionService } from "@/lib/personal-report-section-service";
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { subjectBirthFingerprint } from "@/lib/report-birth-binding";
import { loadSubjectBirth } from "@/lib/subject-birth";
export const runtime = "nodejs";
@@ -51,7 +49,6 @@ async function resolvePersistenceForUser() {
jobs: null,
listSections: undefined,
loadLongformAppendix: undefined,
loadCurrentBirthFingerprint: undefined,
};
}
const persistence = createSupabasePersonalReportService(supabase);
@@ -87,17 +84,12 @@ async function resolvePersistenceForUser() {
markdown: row.markdown,
};
},
// Report cover binding: the same stored-row fingerprint the chart page uses.
// A foreign, deleted or unreadable subject throws and resolves to no cover.
loadCurrentBirthFingerprint: async (ownerId: string, subjectId: string) => (
subjectBirthFingerprint((await loadSubjectBirth(supabase, ownerId, subjectId)).row)
),
};
}
export async function GET(request: Request, context: RouteContext) {
try {
const { userId, persistence, jobs, listSections, loadLongformAppendix, loadCurrentBirthFingerprint } = await resolvePersistenceForUser();
const { userId, persistence, jobs, listSections, loadLongformAppendix } = await resolvePersistenceForUser();
const { reportId } = await context.params;
if (!uuidPattern.test(reportId)) {
return NextResponse.json(
@@ -124,7 +116,6 @@ export async function GET(request: Request, context: RouteContext) {
jobs: jobs ?? undefined,
listSections,
loadLongformAppendix,
loadCurrentBirthFingerprint,
validateReadyDocument: (document) => {
const parsed = safeParseServerReportDocument(document);
return parsed.ok
@@ -1,31 +0,0 @@
/* Report cover (TASK-birth-sky-followup-20260928 F3). Loaded with the lazy
cover chunk on the report page only. Screen: night palette, as wide as the
reading column, 3:4. Print: paper palette, alone on the first A4 page. */
.report-sky-cover { margin: 0 0 var(--space-6); }
.report-sky-cover img {
display: block;
width: 100%;
height: auto;
aspect-ratio: 3 / 4;
border-radius: var(--radius-md);
}
.report-sky-cover-print { display: none !important; }
@media print {
.report-sky-cover {
margin: 0;
break-after: page;
page-break-after: always;
break-inside: avoid-page;
}
.report-sky-cover-screen { display: none !important; }
.report-sky-cover-print {
display: block !important;
/* A4 inside the report's @page margins is 186mm × 270mm; 3:4 at 186mm
wide is 248mm tall, so 246mm keeps it on one page. */
width: auto !important;
max-width: 100%;
height: 246mm !important;
margin: 0 auto;
border-radius: 0;
}
}
@@ -1,46 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import "./report-sky-cover.css";
import { loadReportSkyCover } from "@/lib/birth-sky/report-cover";
type ReportSkyCoverImages = Readonly<{ screen: string; print: string; description: string }>;
/**
* 「那一刻的天空」 as the report cover (TASK-birth-sky-followup-20260928 F3).
*
* Mounted only after the report body is on screen and only when the server
* said the report's birth data still matches (`coverSubject`). It fetches the
* sky and paints two PNGs with the same `drawBirthSky`: `night` for the screen,
* `paper` for print. Until both are painted, and on any failure, it renders
* nothing — no placeholder, no waiting state (DESIGN §9).
*/
export function ReportSkyCover({ subjectId }: { subjectId: string }) {
const [images, setImages] = useState<ReportSkyCoverImages | null>(null);
useEffect(() => {
const controller = new AbortController();
const urls: string[] = [];
void loadReportSkyCover(subjectId, controller.signal).then((pngs) => {
if (!pngs || controller.signal.aborted) return;
urls.push(URL.createObjectURL(pngs.screen), URL.createObjectURL(pngs.print));
setImages({ screen: urls[0], print: urls[1], description: pngs.description });
});
return () => {
controller.abort();
for (const url of urls) URL.revokeObjectURL(url);
};
}, [subjectId]);
if (!images) return null;
return (
<figure className="report-sky-cover">
{/* eslint-disable-next-line @next/next/no-img-element -- blob: URL of the painted cover */}
<img className="report-sky-cover-screen" src={images.screen} alt={images.description} width={1080} height={1440} />
{/* eslint-disable-next-line @next/next/no-img-element -- blob: URL of the painted cover, paper palette */}
<img className="report-sky-cover-print" src={images.print} alt="" width={1080} height={1440} />
</figure>
);
}
@@ -11,7 +11,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import dynamic from "next/dynamic";
import Link from "next/link";
import { Clock3, TriangleAlert } from "lucide-react";
@@ -41,12 +40,6 @@ import {
import { Button } from "@/components/ui/button";
import { useVisibilityAwarePoll } from "@/hooks/use-visibility-aware-poll";
// Mounted with the body, so the sky is only fetched once the report is on screen.
const ReportSkyCover = dynamic(
() => import("@/components/birth-sky/report-sky-cover").then((module) => module.ReportSkyCover),
{ ssr: false },
);
export type ReportLoadState =
| { phase: "loading" }
| { phase: "unauthorized" }
@@ -61,7 +54,7 @@ export type ReportLoadState =
| { phase: "failed"; failureCode: string | null; failureSummary?: string | null; appendixLastErrorCode?: string | null }
| { phase: "invalid"; message: string }
| { phase: "network-error" }
| { phase: "markdown-ready"; markdown: string; reportId: string; createdAt: string; factTables?: ReportFactTable[]; calculationCharts?: ReportDocumentV2["charts"]; coverSubject?: string }
| { phase: "markdown-ready"; markdown: string; reportId: string; createdAt: string; factTables?: ReportFactTable[]; calculationCharts?: ReportDocumentV2["charts"] }
| { phase: "legacy-unavailable" };
/** GET /api/reports/:id envelope view (mirrors the API route's reportView). */
@@ -79,8 +72,6 @@ export interface ReportEnvelopeView {
progressPercent?: number;
progressPhase?: string;
sections?: readonly ReportSectionProgress[];
/** Report cover: set by the server only when the report's birth data still matches. */
coverSubject?: string | null;
}
const SECTION_PROGRESS_STATES: readonly ReportSectionProgressState[] = [
@@ -140,11 +131,9 @@ export function classifyReportEnvelope(statusCode: number, json: unknown): Repor
if (markdown.length > 0) {
const facts = reportFactTablesSchema.safeParse(json.factTables);
const charts = reportDocumentV2Schema.shape.charts.safeParse(json.calculationCharts);
const coverSubject = typeof view.coverSubject === "string" && view.coverSubject.length > 0 ? view.coverSubject : null;
return {
...(facts.success && facts.data.length ? { factTables: facts.data } : {}),
...(charts.success ? { calculationCharts: charts.data } : {}),
...(coverSubject ? { coverSubject } : {}),
phase: "markdown-ready",
markdown,
reportId: typeof view.id === "string" ? view.id : "",
@@ -493,7 +482,6 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
reportDate={state.createdAt}
/>
<div className="personal-report-reader-body">
{state.coverSubject ? <ReportSkyCover subjectId={state.coverSubject} /> : null}
<PersonalReportMarkdownView markdown={state.markdown} />
{state.calculationCharts && <ReportCalculationCharts charts={state.calculationCharts} />}
{state.factTables && <ReportFactTables tables={state.factTables} />}
@@ -1,34 +0,0 @@
import type { BirthSkyOk } from "./contract.ts";
import { fetchBirthSky } from "./cover.ts";
import type { BirthSkyPaletteName } from "./draw.ts";
import { birthSkyFactLine, birthSkyHeadline } from "./sentence.ts";
/** Screen (`night`) and print (`paper`) pictures of one report cover, painted by the same `drawBirthSky`. */
export type ReportSkyCoverPngs = Readonly<{ screen: Blob; print: Blob; description: string }>;
export type ReportSkyCoverDeps = Readonly<{
fetchSky: (subjectId: string, signal?: AbortSignal) => Promise<BirthSkyOk | null>;
render: (sky: BirthSkyOk, palette: BirthSkyPaletteName) => Promise<Blob>;
}>;
/**
* Report cover loader (TASK-birth-sky-followup-20260928 F3). Resolves null on
* any failure or abort; the report shows no cover and nothing else changes.
*/
export async function loadReportSkyCover(
subjectId: string,
signal: AbortSignal,
deps?: ReportSkyCoverDeps,
): Promise<ReportSkyCoverPngs | null> {
try {
const fetchSky = deps?.fetchSky ?? fetchBirthSky;
const render = deps?.render ?? (await import("./render.ts")).renderBirthSkyPng;
const sky = await fetchSky(subjectId, signal);
if (!sky || signal.aborted) return null;
const [screen, print] = await Promise.all([render(sky, "night"), render(sky, "paper")]);
if (signal.aborted) return null;
return { screen, print, description: `${birthSkyHeadline(sky)}${birthSkyFactLine(sky)}。` };
} catch {
return null;
}
}
+5 -36
View File
@@ -27,12 +27,7 @@ import {
type SkillSnapshot,
} from "./personal-report-generation";
import { REPORT_STABLE_CODES } from "./personal-report-codes";
import {
readReportBirthFingerprint,
reportCoverSubject,
reportDocumentBirthFingerprint,
reportSubjectId,
} from "./report-birth-binding";
import { readReportBirthFingerprint } from "./report-birth-binding";
import { summarizePersonalReportFailure } from "./personal-report-failure-summary";
import {
deriveSectionProgressState,
@@ -155,7 +150,6 @@ export function reportView(
job?: PersonalReportJobRecord | null,
failure?: ReturnType<typeof summarizePersonalReportFailure> | null,
sections?: readonly ReportSectionProgress[] | null,
coverSubject?: string | null,
) {
return {
id: row.id,
@@ -173,8 +167,6 @@ export function reportView(
...(failure?.innerReason ? { innerReason: failure.innerReason } : {}),
...(failure && failure.lastErrorCodes.length > 0 ? { sectionErrorCodes: failure.lastErrorCodes } : {}),
...(failure?.appendixLastErrorCode ? { appendixLastErrorCode: failure.appendixLastErrorCode } : {}),
// Report cover binding: a subject id or null, never the fingerprint itself.
...(coverSubject !== undefined ? { coverSubject } : {}),
};
}
@@ -688,30 +680,8 @@ export type ReportReadCoreDeps = Readonly<{
lastErrorCode: string | null;
markdown: string | null;
} | null>;
/**
* Current birth fingerprint of one of the reader's own subjects (`"self"` or
* a chart profile id), read on the server. Null or a throw means no cover.
*/
loadCurrentBirthFingerprint?: (userId: string, subjectId: string) => Promise<string | null>;
}>;
/** Server-side comparison for the report cover; only its verdict leaves the server. */
async function readyReportCoverSubject(
deps: ReportReadCoreDeps,
userId: string,
row: PersonalReportRecord,
): Promise<string | null> {
const storedFingerprint = reportDocumentBirthFingerprint(row.reportDocument);
if (!storedFingerprint || !deps.loadCurrentBirthFingerprint) return null;
let currentFingerprint: string | null = null;
try {
currentFingerprint = await deps.loadCurrentBirthFingerprint(userId, reportSubjectId(row.chartProfileId));
} catch {
currentFingerprint = null;
}
return reportCoverSubject({ chartProfileId: row.chartProfileId, storedFingerprint, currentFingerprint });
}
export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<ReportRouteResponse> {
const originDecision = checkSameOrigin(deps.requestUrl, deps.origin, deps.allowedOrigins, deps.requestHeaders);
if (!originDecision.ok) {
@@ -759,7 +729,6 @@ export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<Repor
))
: null;
if (row.status === "ready") {
const coverSubject = await readyReportCoverSubject(deps, deps.userId, row);
const markdownFromAppendix = appendix?.status === "ready" && appendix.markdown?.trim()
? appendix.markdown
: null;
@@ -772,7 +741,7 @@ export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<Repor
return {
status: 200,
body: {
report: reportView(row, job, null, null, coverSubject),
report: reportView(row, job),
longformMarkdown: projectOrdinaryReportMarkdown(markdown),
...(validated.ok && validated.document && typeof validated.document === "object" && "charts" in validated.document
? { calculationCharts: validated.document.charts } : {}),
@@ -792,7 +761,7 @@ export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<Repor
if (deps.loadLongformMarkdown || deps.loadLongformAppendix) {
return {
status: 200,
body: { report: reportView(row, job, null, null, coverSubject), longformMarkdown: null },
body: { report: reportView(row, job), longformMarkdown: null },
};
}
if (!validated.ok) {
@@ -801,13 +770,13 @@ export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<Repor
body: {
error: "报告内容未通过合同校验",
code: REPORT_STABLE_CODES.schemaInvalid,
report: reportView(row, job, null, null, coverSubject),
report: reportView(row, job),
},
};
}
return {
status: 200,
body: { report: reportView(row, job, null, null, coverSubject), reportDocument: projectOrdinaryReportDocument(validated.document) },
body: { report: reportView(row, job), reportDocument: projectOrdinaryReportDocument(validated.document) },
};
}
return { status: 200, body: { report: reportView(row, job, failure, sectionProgress) } };
+4 -26
View File
@@ -8,8 +8,10 @@ import { resolveServerOwnedChartBirth } from "./chart-birth-truth.ts";
* `subject.birthFingerprint`. The fingerprint is the chart page's
* `profileFingerprint`: `resolveServerOwnedChartBirth` over the **stored** row,
* before any in-memory timezone fill. Reports generated before this field
* existed never get one and never show a cover. Neither the fingerprint nor
* any birth field leaves the server; the reader only receives `coverSubject`.
* existed never get one. Nothing reads it back yet: the report sky cover that
* compared it was removed (TASK-mobile-chart-and-confirmed-edit-20260929 M5,
* product 2026-09-29); the field is kept so a later binding can use it. It is
* never sent to a client or to the model.
*/
/** Server-only key stamped on the loaded profile row; never persisted, never serialized to a client. */
@@ -51,27 +53,3 @@ export function withReportBirthFingerprint<T>(document: T, fingerprint: string |
if (!source || !subject || !fingerprint) return document;
return { ...source, subject: { ...subject, birthFingerprint: fingerprint } } as T;
}
export function reportDocumentBirthFingerprint(document: unknown): string | null {
const value = record(record(document)?.subject)?.birthFingerprint;
return typeof value === "string" && FINGERPRINT_PATTERN.test(value) ? value : null;
}
/** The subject id the reader may ask `/api/birth-sky` for; `"self"` when the report has no chart profile. */
export function reportSubjectId(chartProfileId: string | null): string {
return chartProfileId && chartProfileId.trim() ? chartProfileId.trim() : "self";
}
/**
* `coverSubject` for `reportView`: the subject id only when the stored
* fingerprint equals the subject's current one; null otherwise (mismatch,
* legacy report without the field, foreign or deleted person, lookup failure).
*/
export function reportCoverSubject(input: Readonly<{
chartProfileId: string | null;
storedFingerprint: string | null;
currentFingerprint: string | null;
}>): string | null {
if (!input.storedFingerprint || !input.currentFingerprint) return null;
return input.storedFingerprint === input.currentFingerprint ? reportSubjectId(input.chartProfileId) : null;
}