Files
Jyotisha/frontend/src/components/rectification-board.tsx
T
jesseandClaude Opus 5.5 8e932959b5 feat: rectification screen — timeline-only minute, board on demand, result card (issues #146, #147)
- The working minute is written once, on the timeline; the board's title row
  no longer repeats it.
- 「看盘面」 sits in the header at every width; the board is closed by default
  and opens as a right column on desktop (bottom sheet on phones).

- A distinct result (D4) leads with 最可能的出生时间 + minute + Lagna; the range
  drops to the next line. Indistinct results keep the range headline (D3).
- The most likely column adopts with 「采用 HH:MM 并打开星盘」 and goes to /chart
  after the server saved it; other columns stay adoptable as 「改用这个」 under
  还不能排除. No confidence label. Record-conflict wording unchanged.
- 「我说错了,改一条」: confirmed events (from the case read's existing evidence
  list) prefill the composer; nothing is sent by the pick. No 「再补一件事」:
  the card must not invite more events (BUG-1084, issue #148 decision).
- The segment card keeps "which chart" (BUG-1151): no minute lead, same
  adopt label, same actions row.

Docs: DESIGN §10/§11/§12, VOICE, CHANGELOG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 18:23:04 +08:00

357 lines
13 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 { Button } from "@/components/ui/button";
import {
groupWindowTransitions,
houseTableToNorthIndianChart,
precisionStageBoardCopy,
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,
lagnaContrastIntervalLabel,
type WindowScanLayer,
} from "@/lib/rectification-agentic/v9/refinement-packet";
import { rectificationBoardEmptyCopy } from "@/lib/rectification-surface-state";
import { VedicChartSvg } from "@/components/personal-report/vedic-chart-svg";
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,
birthTimeSource,
savedStatus,
diff,
compact,
titleId,
onClose,
}: Readonly<{
result: RectificationCandidateResult | null;
declaredTime: string | null;
birthTimeSource?: 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, birthTimeSource);
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 = precisionStageBoardCopy(result?.precisionStage)
?? result?.natalRecast?.user_meaning
?? null;
const ledger = result?.eventDashaLedger ?? [];
const contrast = result?.lagnaContrast ?? null;
const agreement = result?.dashaAgreement ?? null;
const nakshatra = result?.nakshatraBoundary ?? null;
// Two sections, not five (D10). Nothing was dropped in the regrouping — the
// minutes moved under 候选分钟, and the house table, the confirmation gate,
// the technique audit, the two-lagna split, the event/dasha ledger with its
// dual-track agreement line and the nakshatra note all moved under 参数与口径.
// Each block keeps the render condition it had, so a state that showed
// nothing before still shows nothing: the gate and the audit stay behind
// `table`, exactly as when they sat inside `.rectification-snapshot`.
const hasMinuteDetail = scoringMinutes.length > 0 || displayMinutes.length > 0;
const hasParameterDetail = Boolean(
table
|| contrast
|| ledger.length > 0
|| (nakshatra?.near_boundary && nakshatra.user_meaning),
);
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>
{/* Issue #146: the working minute is written once, on the timeline
above the conversation. The board no longer repeats it. */}
{(
<Button
className="rectification-board__close"
type="button"
variant="ghost"
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">
<figure className="rectification-board__chart">
<VedicChartSvg
chart={houseTableToNorthIndianChart(table)}
ariaLabel={`当前本命盘,上升${table.lagna}`}
highlightHouseNumbers={new Set([
...changedHouses,
...(diff.lagnaChanged ? [1] : []),
])}
/>
</figure>
</div>
)}
{hasMinuteDetail && (
<details className="technique-audit" aria-label="候选分钟">
<summary>
候选分钟
<small>{grouped.length} 个时刻</small>
</summary>
{scoringMinutes.length > 0 && (
<>
<p className="rectification-board__group">换升时刻</p>
<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>
)}
</details>
)}
{hasParameterDetail && (
<details className="technique-audit" aria-label="参数与口径">
<summary>参数与口径</summary>
{table && (
<>
<details className="technique-audit">
<summary>本命宫位表</summary>
<RectificationHouseTableView
table={table}
changedHouses={changedHouses}
lagnaChanged={diff.lagnaChanged}
/>
</details>
{result && <ConfirmationGateDisclosure gate={result.confirmationGate} />}
{savedStatus === "accepted" && result && result.techniqueAudit.length > 0 && (
<TechniqueAuditDisclosure rows={result.techniqueAudit} />
)}
</>
)}
{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_at ?? interval.start}-${interval.end_at ?? interval.end}`}>
{lagnaContrastIntervalLabel(interval)}:{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>
)}
</details>
)}
</div>
</>
);
}
export function RectificationBoard({
result,
declaredTime,
birthTimeSource,
savedStatus,
diff,
compact,
open,
boardId,
titleId,
onClose,
}: Readonly<{
result: RectificationCandidateResult | null;
declaredTime: string | null;
birthTimeSource?: 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;
/* preventScroll for the same reason as the choice options: this sheet
lives in the panel's grid cell, and the browser would scroll the
panel to reach it. */
sheetRef.current?.focus({ preventScroll: true });
const handleKey = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [compact, open, onClose]);
// Issue #146: closed by default at every width; 「看盘面」 opens it.
if (!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}
birthTimeSource={birthTimeSource}
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>
);
}
/** Issue #146: the header button says what it does; the time lives on the timeline. */
export const RECTIFICATION_BOARD_OPEN_LABEL = "看盘面";
export function RectificationBoardPeek({
result,
declaredTime = null,
birthTimeSource,
expanded,
boardId,
onOpen,
}: Readonly<{
result: RectificationCandidateResult | null;
declaredTime?: string | null;
birthTimeSource?: string | null;
expanded: boolean;
boardId: string;
onOpen: () => void;
}>) {
const peek = rectificationBoardPeekCopy(result, declaredTime, birthTimeSource);
return (
<Button
className="rectification-board-peek"
type="button"
variant="ghost"
aria-label={peek.detail ? `${RECTIFICATION_BOARD_OPEN_LABEL},${peek.detail}` : RECTIFICATION_BOARD_OPEN_LABEL}
aria-expanded={expanded}
aria-controls={boardId}
onClick={onOpen}
>
<strong>{RECTIFICATION_BOARD_OPEN_LABEL}</strong>
</Button>
);
}