Files
Jyotisha/frontend/src/components/rectification-board.tsx
T
Jesse_Chen ef11f6e335 feat(rectification): confirm the tap on the choice card and show the declared minute on the empty board
The selected option carries a check and 已选择, and a card whose tap is
being recorded shows one inline-spinner row 正在记录… (the timeline row's
shape, not a new vocabulary). Before any candidate exists the board's clock
shows the profile's declared birth minute with two lines of copy, the peek
reads 当前盘面 · 填报 HH:MM, and the column narrows until a result arrives.
The snapshot API has no declared-time house table, so none is invented.

BUG-482, BUG-483

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-02 04:53:59 +00:00

298 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { X } from "lucide-react";
import { useEffect, useRef } from "react";
import {
groupWindowTransitions,
rectificationBoardPeekCopy,
workingRectificationTime,
type RectificationBoardDiff,
} from "@/lib/rectification-board-model";
import { engineMeaningToDisplayCopy } from "@/lib/rectification-agentic/user-copy";
import {
workingRectificationHouseTable,
type RectificationCandidateResult,
} from "@/lib/rectification-candidate-result";
import {
windowScanLayerLabel,
type WindowScanLayer,
} from "@/lib/rectification-agentic/v9/refinement-packet";
import { rectificationBoardEmptyCopy } from "@/lib/rectification-surface-state";
import { RectificationHouseTableView } from "./rectification-house-table";
import { ConfirmationGateDisclosure, TechniqueAuditDisclosure } from "./technique-audit-disclosure";
function LayerChips({ layers }: Readonly<{ layers: readonly WindowScanLayer[] }>) {
if (layers.length === 0) return null;
return (
<ul className="rectification-board__chips">
{layers.map((layer) => (
<li key={layer}>
<span className="rectification-board__chip">{windowScanLayerLabel(layer)}</span>
</li>
))}
</ul>
);
}
function RectificationBoardBody({
result,
declaredTime,
savedStatus,
diff,
compact,
titleId,
onClose,
}: Readonly<{
result: RectificationCandidateResult | null;
declaredTime: string | null;
savedStatus: "accepted" | "confirmed" | null;
diff: RectificationBoardDiff;
compact: boolean;
titleId: string;
onClose: () => void;
}>) {
const table = result ? workingRectificationHouseTable(result) : null;
const workingTime = workingRectificationTime(result);
const emptyCopy = rectificationBoardEmptyCopy(declaredTime);
const grouped = result ? groupWindowTransitions(result.windowTransitions) : [];
const scoringMinutes = grouped.filter((row) => row.scoringLayers.length > 0);
const displayMinutes = grouped.filter((row) => row.displayLayers.length > 0);
const changedHouses = new Set(diff.houseNumbers);
const changedMinutes = new Set(diff.transitionMinutes);
const stage = result?.precisionStage?.user_meaning
?? result?.natalRecast?.user_meaning
?? null;
const ledger = result?.eventDashaLedger ?? [];
const contrast = result?.lagnaContrast ?? null;
const agreement = result?.dashaAgreement ?? null;
const nakshatra = result?.nakshatraBoundary ?? null;
return (
<>
{compact ? <div className="rectification-board__handle" aria-hidden="true" /> : null}
<header className="rectification-board__header">
<div className="rectification-board__title-row">
<h2 id={titleId}></h2>
{workingTime
? <span className="rectification-board__clock">{workingTime}</span>
: emptyCopy.clock
? <span className="rectification-board__clock is-declared">{emptyCopy.clock}</span>
: null}
{compact && (
<button
className="rectification-board__close"
type="button"
aria-label="关闭盘面"
onClick={onClose}
>
<X aria-hidden="true" strokeWidth={2} />
</button>
)}
</div>
{stage ? <p className="rectification-board__stage">{engineMeaningToDisplayCopy(stage)}</p> : null}
</header>
<div className="rectification-board__body">
<p className="sr-only" role="status" aria-live="polite" aria-atomic="true">{diff.liveMessage}</p>
{!table && (
<div className="rectification-board__empty">
{emptyCopy.lines.map((line) => <p key={line}>{line}</p>)}
</div>
)}
{table && (
<div className="rectification-snapshot">
<RectificationHouseTableView
table={table}
changedHouses={changedHouses}
lagnaChanged={diff.lagnaChanged}
/>
{result && <ConfirmationGateDisclosure gate={result.confirmationGate} />}
{savedStatus === "accepted" && result && result.techniqueAudit.length > 0 && (
<TechniqueAuditDisclosure rows={result.techniqueAudit} />
)}
</div>
)}
{(scoringMinutes.length > 0 || displayMinutes.length > 0) && (
<section className="rectification-board__section" aria-label="换升时刻">
<h3></h3>
{scoringMinutes.length > 0 && (
<ol className="rectification-board__minutes">
{scoringMinutes.map((row) => {
const changed = changedMinutes.has(row.at);
const working = row.at === workingTime;
return (
<li
key={row.at}
className={[
"rectification-board__minute",
changed ? "is-changed" : "",
working ? "is-working" : "",
].filter(Boolean).join(" ")}
>
<time dateTime={row.at}>{row.at}</time>
{working ? <span className="rectification-board__minute-note"></span> : null}
{changed ? <span className="rectification-board__minute-note"></span> : null}
<LayerChips layers={row.scoringLayers} />
</li>
);
})}
</ol>
)}
{displayMinutes.length > 0 && (
<details className="technique-audit">
<summary>
<small>{displayMinutes.length} </small>
</summary>
<ol className="rectification-board__minutes">
{displayMinutes.map((row) => (
<li
key={`display-${row.at}`}
className={changedMinutes.has(row.at) ? "rectification-board__minute is-changed" : "rectification-board__minute"}
>
<time dateTime={row.at}>{row.at}</time>
{changedMinutes.has(row.at) ? <span className="rectification-board__minute-note"></span> : null}
<LayerChips layers={row.displayLayers} />
</li>
))}
</ol>
</details>
)}
</section>
)}
{contrast && (
<details className="technique-audit">
<summary></summary>
<div className="rectification-board__disclosure">
<p>{engineMeaningToDisplayCopy(contrast.user_meaning)}</p>
<ul>
{contrast.intervals.map((interval) => (
<li key={`${interval.start}-${interval.end}`}>
{interval.start}-{interval.end}{interval.lagna}
{interval.lords.l1 ? `;一/四/七/十宫主 ${[interval.lords.l1, interval.lords.l4, interval.lords.l7, interval.lords.l10].filter(Boolean).join("、")}` : ""}
</li>
))}
</ul>
</div>
</details>
)}
{ledger.length > 0 && (
<details className="technique-audit">
<summary>
<small>{ledger.length} </small>
</summary>
<ul className="rectification-board__disclosure">
{ledger.map((item) => (
<li key={item.summary}>{engineMeaningToDisplayCopy(item.user_meaning)}</li>
))}
</ul>
{agreement ? <p className="rectification-board__disclosure">{engineMeaningToDisplayCopy(agreement.user_meaning)}</p> : null}
</details>
)}
{nakshatra?.near_boundary && nakshatra.user_meaning && (
<details className="technique-audit">
<summary></summary>
<p className="rectification-board__disclosure">{engineMeaningToDisplayCopy(nakshatra.user_meaning)}</p>
</details>
)}
</div>
</>
);
}
export function RectificationBoard({
result,
declaredTime,
savedStatus,
diff,
compact,
open,
boardId,
titleId,
onClose,
}: Readonly<{
result: RectificationCandidateResult | null;
declaredTime: string | null;
savedStatus: "accepted" | "confirmed" | null;
diff: RectificationBoardDiff;
compact: boolean;
open: boolean;
boardId: string;
titleId: string;
onClose: () => void;
}>) {
const sheetRef = useRef<HTMLElement>(null);
useEffect(() => {
if (!compact || !open) return;
sheetRef.current?.focus();
const handleKey = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [compact, open, onClose]);
if (compact && !open) return null;
const sheet = (
<aside
ref={sheetRef}
id={boardId}
className={`rectification-board${compact ? " is-sheet" : ""}`}
aria-labelledby={titleId}
role={compact ? "dialog" : undefined}
aria-modal={compact ? true : undefined}
tabIndex={compact ? -1 : undefined}
>
<RectificationBoardBody
result={result}
declaredTime={declaredTime}
savedStatus={savedStatus}
diff={diff}
compact={compact}
titleId={titleId}
onClose={onClose}
/>
</aside>
);
if (!compact) return sheet;
return (
<div className="rectification-board-overlay">
<div className="rectification-board-scrim" aria-hidden="true" onClick={onClose} />
{sheet}
</div>
);
}
export function RectificationBoardPeek({
result,
declaredTime = null,
expanded,
boardId,
onOpen,
}: Readonly<{
result: RectificationCandidateResult | null;
declaredTime?: string | null;
expanded: boolean;
boardId: string;
onOpen: () => void;
}>) {
const peek = rectificationBoardPeekCopy(result, declaredTime);
return (
<button
className="rectification-board-peek"
type="button"
aria-label={peek.detail ? `${peek.title}${peek.detail}` : peek.title}
aria-expanded={expanded}
aria-controls={boardId}
onClick={onOpen}
>
<strong>{peek.title}</strong>
<span>{peek.detail}</span>
</button>
);
}