Complete the multi-round chart choice page and acceptance checks
Independent Staging Quality Gate / validate-frontend (pull_request) Successful in 7m8s
Independent Staging Quality Gate / validate-python (pull_request) Successful in 7m57s
Independent Staging Quality Gate / publish (pull_request) Skipped

This commit is contained in:
jesse-ux
2026-11-13 18:59:00 +08:00
parent d7e88154b6
commit 19be81eaeb
14 changed files with 2684 additions and 69 deletions
+3 -1
View File
@@ -3,7 +3,9 @@
## #239 · 多轮选盘页面
- ~~缺产品确认的五页画布与逐字文案,v2 续问规则待定。~~ 评论 43045 已补齐原稿并确认规则。续问的接口与引擎已实现。
- 五页原稿还依赖引擎提供仅供展示的非关键换盘线、代表时刻的六盘上升、未定盘对应的换座时刻及逐件事件匹配结果。现有接口只返回关键线、未定盘编号和匹配总数。页面展示字段超出本轮续问范围,待确认由哪一单提供。
- ~~五页原稿还缺非关键线、六盘上升、未定盘换座和逐件事件匹配。~~ 评论 43159 授权本单补齐。截图待产品负责人走查。
- Docker Desktop 的 Linux 引擎返回 HTTP 500。使用现有隔离 PostgreSQL 18.6 验证选盘历史和秒级读回。前端全量在相同的无 Docker 配置下与 staging 比较。
- Python 快速门三项 Kaala / PyJHora 测试与同机 staging 的失败一致,完整绿灯待独立验收环境复核。
- 报告秒级区间已在隔离 PostgreSQL 验证。详见 [#239 进度](docs/tasks/PROGRESS-chart-choice-rounds-page-20261113.md)。
## #243 · Windows 全量门禁
+2 -2
View File
@@ -1,8 +1,8 @@
# 印度占星 Skill 更新日志
## 2026-11-13 — 个人报告按秒使用多轮选盘结果
## 2026-11-13 — 生时纠正逐处确认换盘线
- 报告用已采纳的候选时间段和代表秒值计算误差。Skill 版本沿用当前版本(BUG-1873)。
- 每次最多确认三处,可提前结束,也可从当前候选段继续。结果显示六盘上升、未定的换座时刻和事件匹配。个人报告按候选段与代表秒值计算误差。Skill 版本沿用当前版本(BUG-1873)。
## 2026-11-13 — 个人报告用盘面事实补齐解读章节
+3 -3
View File
@@ -25258,15 +25258,15 @@
## BUG-1873 | 选盘页面未接入多轮,报告未读取秒级采纳段
- 状态:blocked(报告与续问已有验证,页面缺展示数据)。
- 状态:fixed-pending-verify(待独立验收)。
- 首次发现 / 最近更新:2026-11-13 / 2026-11-13。
- 影响面:多轮选盘页面、历史恢复与个人报告。
- 现象及触发:选择或复用 v2 历史后,页面仍按单轮处理,报告只认 v1 区间,并把保存时刻截到分钟。
- 根因:#238 与 #241 已落地引擎和 API 合同。报告请求和历史匹配仍使用原来的分钟级表示。现有恢复入口只接受 v1 历史。
- 进展:报告用 v2 的最终候选段和保存秒值,保留未定分盘。保留 v1 原逻辑。复用已采纳的记录时检查日期和准确秒值。
- 续问:从当前采纳记录的秒级区间再问最多三轮。已确认轮次与已记事件继续保留。服务核对档案的当前记录、日期、秒和段端点,资料改动或区间不一致时拒绝恢复。
- 页面阻塞:原稿已到。现有响应缺非关键换盘线、六盘上升与逐件匹配数据,未定盘也缺逐盘换座时刻。页面待上游提供展示字段。
- 验证与防复发:合成回归覆盖跨午夜、段外回退、错秒历史、续问三轮上限和历史事件保留。真实 PostgreSQL 覆盖报告秒级读回、现行记录续问和档案区间错配拒绝。见 [#239 进度](tasks/PROGRESS-chart-choice-rounds-page-20261113.md)。
- 页面:范围页画出非关键线和关键线顺序。逐轮使用事件题和可跳过的补充题,最多三处。结果按代表时刻列六盘上升、逐盘未定换座和逐件事件匹配。支持提前结束以及从当前记录续问。
- 验证与防复发:真实引擎与接口测试覆盖展示字段和代表时刻的逐件匹配。Chromium 在手机宽度复走三轮、提前结束和续问。隔离 PostgreSQL 覆盖报告秒级读回、现行记录续问和档案区间错配拒绝。见 [#239 进度](tasks/PROGRESS-chart-choice-rounds-page-20261113.md)。
- 关联记录:#227、#229、#238、#239、#241、BUG-1865、BUG-1872。
- 修复版本:`codex/chart-choice-rounds-page-20261113`,未发布。
@@ -20,9 +20,29 @@
- TypeScript 和生产构建通过,首页仍为静态。首屏引用的 JS 515860 B、CSS 90906 B(gzip),与 staging 一致。ESLint 0 error,62 条既有 warning。
- Windows 目录和定向预检超时。首轮快速门因同目录构建替换 .next 文件失败,已在独立 worktree 复跑。前端全量使用同配置与 staging 对照。
## 页面需拍板
## 页面需拍板(修订二时)
- 范围页:`ordered_lines` 只列关键线,无法标注原稿要求的仅有 D4、D12、D24 换座的浅色线。
- 首页与结果页:结果只有 `undetermined_charts` 和剩余条数,缺按盘对应的剩余换座时刻,以及代表时刻的六张盘上升星座。单轮对比数据只覆盖当轮换座的分盘。
- 结果依据:`evidence_summary` 只返回总数、匹配数和把握等级,无法区分每件事是否对上。真实引擎回放已核对上述三处响应形状。
- 建议保持原稿,由引擎返回现有扫描和打分的展示字段。修订二限定引擎改动在续问,页面及截图待上游投影或修订任务书后交付。
## 修订三
- 评论 43159 授权本单补齐三类展示字段。沿用原分支的提交并同步 staging `aea610939`。原工作树另有五个未提交的外部对拍文件,本轮从远端任务书提交建立独立工作树。
- `minor_lines` 仅投影段内只翻 D4、D12、D24 的扫描线。`key_charts` 用同段代表时刻算六盘上升,已定盘沿用相关轮次的 `decided_by`,未定盘显示当前段内最早换座。`events_detail` 逐件取保存代表时刻的 `score_minute`,与匹配总数一致。原区间排名、分级、选线、判边和保存时刻算法沿用。
- 页面以 `resolved` 请求 v2 包,从范围进入每轮。事件日期和类别随当轮提交,已选内容在返回改答案时保留。提前结束提交 `stop: true`,已保存的历史按当前段续问。结果和采纳都沿用服务端给出的秒级段。
- 画布采用接口真实事件题。结果页的「再补几件事」返回当轮改答案。历史仍使用同一 JSON 记录列,数据库结构沿用。
| 既有断言 | 原值 | 新值与原因 |
| --- | --- | --- |
| `chart-choice-astrologer-20261107.test.ts` | 选盘页有三个独立核对入口,并渲染 `AstrologerStage` | 三个入口收进统一流程,页面渲染 `RoundStep`。按修订一的合并方案执行。独立组件和 API 的原合同断言保留。 |
## 修订三验证
- Python 选盘定向 37 项通过。对本地真实 Python HTTP 路由发送虚构数据,`packet` 和 `decide` 返回 200,三个字段与实际引擎夹具一致。
- 前端最终定向 42 项通过,涵盖页面、接口转发、持久化和既有报告秒级段。隔离 PostgreSQL 18.6 的两个用例通过,覆盖旧记录复用、秒级读回和错段拒绝。
- TypeScript、生产构建通过。ESLint 0 错误,62 条既有警告。首页仍为静态,首屏 gzip JS 515860 B,CSS 90895 → 90952 B,均在基线 ±2% 内。
- 同配置无 Docker 前端全量 3718 项,3635 通过、46 失败、37 跳过、0 取消。staging 为 3712 项,3630 通过、45 失败、37 跳过、0 取消。新增 6 项,既有用例无缺失。45 项失败在本次基线复现;咨询计时用例曾在前一 staging 全量出现同一断言失败,本次定向复跑通过。
- 390 px Chromium 用真实引擎的虚构资料回放实际组件,核对三轮请求、提前结束、最终采纳和续问 ID,截图附 PR,待产品负责人走查。预检远端、Python、适配器通过,目录镜像路径的一项既有断言失败。
- 隔离 Python 快速门退出 1,1479 项通过、2 项跳过、29 个子测试通过。三项 Kaala / PyJHora 失败与同机 staging 记录逐项一致。
+1 -1
View File
@@ -33,7 +33,7 @@
| 任务书 | 进度 | 主题 | 状态 | 落点 |
| --- | --- | --- | --- | --- |
| [#239 修订任务书](FIX-chart-choice-rounds-page-2.md) | [执行进度](PROGRESS-chart-choice-rounds-page-20261113.md) | 多轮选盘页面与报告秒级区间(BUG-1873) | 需拍板 | `codex/chart-choice-rounds-page-20261113` |
| [#239 修订任务书](FIX-chart-choice-rounds-page-3.md) | [执行进度](PROGRESS-chart-choice-rounds-page-20261113.md) | 多轮选盘页面与报告秒级区间(BUG-1873) | 待验收 | `codex/chart-choice-rounds-page-20261113` |
| [#243 任务书](TASK-reading-computed-fallback-20261113.md) | [执行进度](PROGRESS-reading-computed-fallback-20261113.md) | 个人报告解读计算兜底(BUG-1877) | 待验收 | `codex/reading-computed-fallback-20261113` |
| [#242 任务书](TASK-reading-rebind-repair-20261113.md) | [执行进度](PROGRESS-reading-rebind-repair-20261113.md) | 报告解读重绑与逐节重写(BUG-1876) | 待验收 | `codex/reading-rebind-repair-20261113` |
| [#241 修订任务书](FIX-chart-choice-questions-1.md) | [执行进度](PROGRESS-chart-choice-questions-20261113.md) | 多轮选盘事件题与判边(BUG-1875) | 待验收 | `codex/chart-choice-questions-20261113` |
+2
View File
@@ -350,6 +350,8 @@ The self-profile editor in 星盘资料 (`ChartLibraryPanel`) owns the ayanamsa
### Rectification surface
- **多轮选盘(#239,BUG-1873):** 时间尺以编号深线显示关键换座,住处、父母和学业盘以浅线标出。逐轮复用对比卡与选项,事件日期就地填写,性格对比折叠。结果保留候选段和代表秒值,逐盘列上升、已定依据或未定换座,逐件列事件匹配。首页保留 v1 历史行,并按当前段扫描结果显示续问入口。
The birth-time rectification session is the consultation transcript plus a house board; questions live inside the assistant message that asked them. It shares every waiting vocabulary with the consultation surface; nothing here spins or says "loading" after the reveal.
- **Reveal:** the surface mounts once per session/Case binding. Opening a Case (homepage card, sidebar row, deep link or refresh) reads the turns and the snapshot in one Case request before the switch, under the same 4-second budget as the home reveal (`RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS` is `BOOTSTRAP_PREPARE_TIMEOUT_MS`); a session selected at bootstrap is hydrated during the prepare phase. The entry shows a static note meanwhile (card footer “正在打开…”, sidebar row “打开中”, `cursor: progress`) and the previous view stays put. Turns and snapshot are initial state; anything that arrives later is a prop or state update, never a remount. A late or failed read still reveals, with the composer notice “校正记录没有完全加载,可以继续”.
+23 -7
View File
@@ -125,6 +125,28 @@
height: 34px;
background: var(--color-action-hover);
}
.chart-choice-ruler-line.is-minor {
top: 25px;
height: 20px;
background: var(--color-canvas-strong);
}
.chart-choice-ruler-line > .chart-choice-tag {
position: absolute;
top: -18px;
left: 50%;
transform: translateX(-50%);
padding-inline: 5px;
}
.chart-choice-input {
display: block;
width: 100%;
min-height: 44px;
border: 1px solid var(--color-canvas-strong);
border-radius: 10px;
padding-inline: 10px;
background: var(--color-canvas);
color: var(--color-ink);
}
.chart-choice-ruler-line-label,
.chart-choice-ruler-reported {
position: absolute;
@@ -571,7 +593,7 @@
}
.chart-choice-ruler-saved-label {
position: absolute;
top: 50px;
top: 4px;
transform: translateX(-50%);
color: var(--color-action);
font-size: var(--type-caption);
@@ -579,12 +601,6 @@
font-weight: 600;
white-space: nowrap;
}
.chart-choice-ruler-reported.is-secondary {
top: 50px;
transform: none;
color: var(--color-ink-secondary);
}
@media (max-width: 640px) {
.chart-choice-page { padding: var(--space-4) var(--space-4) var(--space-8); }
.chart-choice-compare { gap: var(--space-3); }
@@ -1,18 +1,26 @@
"use client";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { ChevronLeft } from "lucide-react";
import { Fragment, useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { AppLink } from "@/components/app-link";
import { ChartDrawLoader } from "@/components/chart-draw-loader";
import { AstrologerStage } from "@/components/chart-choice/astrologer-stage";
import { MiniVargaChart } from "@/components/chart-choice/mini-varga-chart";
import { SecondaryPageShell } from "@/components/secondary-page-shell";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
RECORD_RANGE_CHOICES,
type ChartChoiceAnswer,
type ChartChoiceAdoptRequest,
type ChartChoiceDatedEvent,
type ChartChoiceDecision,
type ChartChoiceCompletion,
type ChartChoiceEnginePacket,
type ChartChoiceEvidenceInput,
type ChartChoiceKeyChart,
type ChartChoiceRequest,
type ChartChoiceHistory,
type ChartChoicePacket,
type ChartChoiceRecord,
@@ -24,11 +32,13 @@ import {
SIDE_TEXT,
clockLabel,
distanceText,
eventSides,
minutesFrom,
otherSide,
rangeLabel,
recordSummary,
savedAtLabel,
segmentLabel,
windowAround,
} from "@/lib/chart-choice/view";
import { useCurrentSubject, useCurrentSubjectReady } from "@/lib/current-subject";
@@ -37,11 +47,16 @@ import { cn } from "@/lib/utils";
type Choose = Extract<ChartChoicePacket, { status: "choose" }>;
type Ready = Extract<ChartChoiceStart, { status: "ready" }>;
type Complete = Extract<ChartChoiceEnginePacket, { status: "certain" | "complete" }>;
type FinalDecision = (ChartChoiceDecision & { segment: ChartChoiceWindow }) | ChartChoiceCompletion;
type RoundAnswers = NonNullable<ChartChoiceEvidenceInput["event_answers"]>;
type RoundDraft = { answers: Record<string, ChartChoiceAnswer>; evidence: ChartChoiceEvidenceInput; optionIds: Record<string, string> };
type ResultRequest = ChartChoiceAdoptRequest & { mode: "preview" | "choice" };
/** What 重试 re-runs; data, not a closure, so the callbacks never refer to themselves. */
type Retry =
| { kind: "start" }
| { kind: "packet"; window: ChartChoiceWindow | undefined }
| { kind: "packet"; request: ChartChoiceRequest; showRange: boolean; previous?: Step }
| { kind: "back"; step: Step };
type Step =
@@ -53,10 +68,10 @@ type Step =
| { kind: "packet_loading" }
| { kind: "narrow"; packet: Extract<ChartChoicePacket, { status: "needs_narrowing" }> }
| { kind: "certain"; packet: Extract<ChartChoicePacket, { status: "certain" }> }
| { kind: "choose"; packet: Choose }
| { kind: "range"; packet: Choose | Complete; request: ChartChoiceRequest }
| { kind: "choose"; packet: Choose; request: ChartChoiceRequest; previous?: Step; draft?: RoundDraft }
| { kind: "result"; packet: Choose; decision: ChartChoiceDecision; saving: boolean; overrideSide?: ChartChoiceSide }
// 占星师的方法(产品 2026-11-07):在选盘之外按她的 skill 再核对,结果并排给出。
| { kind: "astrologer"; chartChoiceTime: string | null; back: Step; window?: ChartChoiceWindow };
| { kind: "final"; packet: Choose | Complete; decision: FinalDecision; body: ResultRequest; back: Step };
const COPY = {
title: "生时纠正",
@@ -116,29 +131,41 @@ async function readStart(subjectId: string): Promise<Step | { readonly ready: Re
}
}
function ChartChoiceFlow({ subjectId }: { readonly subjectId: string }) {
export function ChartChoiceFlow({ subjectId }: { readonly subjectId: string }) {
const [step, setStep] = useState<Step>({ kind: "loading" });
const [start, setStart] = useState<Ready | null>(null);
const [window, setWindow] = useState<ChartChoiceWindow | undefined>(undefined);
const [answers, setAnswers] = useState<Record<string, ChartChoiceAnswer>>({});
const [evidence, setEvidence] = useState<ChartChoiceEvidenceInput>({});
const [optionIds, setOptionIds] = useState<Record<string, string>>({});
const [pending, setPending] = useState(false);
const [resume, setResume] = useState<{ id: string; packet: ChartChoiceEnginePacket } | null>(null);
// No range in the profile: ±15 min by default, adjustable in one tap; a tapped range is saved back (BUG-1649).
const [rangeChosen, setRangeChosen] = useState(false);
const generation = useRef(0);
const loadPacket = useCallback(async (nextWindow: ChartChoiceWindow | undefined) => {
const loadPacket = useCallback(async (request: ChartChoiceRequest, showRange = true, previous?: Step) => {
const ticket = ++generation.current;
setWindow(nextWindow);
setWindow(request.window);
setAnswers({});
setEvidence({});
setOptionIds({});
setStep({ kind: "packet_loading" });
try {
const packet = await postJson<ChartChoicePacket>("/api/chart-choice/packet", { subjectId, ...(nextWindow ? { window: nextWindow } : {}) });
const packet = await postJson<ChartChoiceEnginePacket>("/api/chart-choice/packet", { subjectId, resolved: [], ...request });
if (ticket !== generation.current) return;
if (packet.status === "needs_narrowing") setStep({ kind: "narrow", packet });
else if (packet.engine_version === "chart-choice-v2" && showRange) setStep({ kind: "range", packet, request });
else if (packet.status === "complete" || (packet.status === "certain" && packet.decision)) {
const decision = packet.decision;
if (decision) setStep({ kind: "final", packet, decision, body: { subjectId, resolved: [], ...request, mode: "choice" },
back: previous ?? { kind: "range", packet, request } });
}
else if (packet.status === "certain") setStep({ kind: "certain", packet });
else setStep({ kind: "choose", packet });
else setStep({ kind: "choose", packet, request, previous });
} catch (error) {
if (ticket !== generation.current) return;
setStep({ kind: "failed", message: (error as Error).message, retry: { kind: "packet", window: nextWindow } });
setStep({ kind: "failed", message: (error as Error).message, retry: { kind: "packet", request, showRange, previous } });
}
}, [subjectId]);
@@ -150,7 +177,7 @@ function ChartChoiceFlow({ subjectId }: { readonly subjectId: string }) {
}
setStart(next.ready);
if (next.ready.history.records.length > 0) setStep({ kind: "home" });
else void loadPacket(undefined);
else void loadPacket({});
}, [loadPacket]);
const reloadStart = useCallback(() => {
@@ -170,9 +197,18 @@ function ChartChoiceFlow({ subjectId }: { readonly subjectId: string }) {
};
}, [applyStart, subjectId]);
const current = start?.history.records.find((record) => record.id === start.history.current_record_id && record.reusable);
useEffect(() => {
if (!current || (!current.detail.decision.segment && !current.detail.line)) return;
let live = true;
void postJson<ChartChoiceEnginePacket>("/api/chart-choice/packet", { subjectId, resolved: [], resumeRecordId: current.id })
.then((packet) => { if (live) setResume({ id: current.id, packet }); }, () => { if (live) setResume(null); });
return () => { live = false; };
}, [current, subjectId]);
const startOver = useCallback(() => {
setRangeChosen(false);
void loadPacket(undefined);
void loadPacket({});
}, [loadPacket]);
const afterSave = useCallback((time: string) => {
@@ -192,7 +228,7 @@ function ChartChoiceFlow({ subjectId }: { readonly subjectId: string }) {
afterSave(clockLabel(decision.saved_at, packet.reported_at));
}
} catch (error) {
setStep({ kind: "failed", message: (error as Error).message, retry: { kind: "back", step: { kind: "choose", packet } } });
setStep({ kind: "failed", message: (error as Error).message, retry: { kind: "back", step: { kind: "choose", packet, request: {} } } });
}
}, [afterSave, answers, rangeChosen, step, subjectId, window]);
@@ -216,7 +252,7 @@ function ChartChoiceFlow({ subjectId }: { readonly subjectId: string }) {
const pickRange = useCallback((reported: string, minutes: number) => {
setRangeChosen(true);
void loadPacket(windowAround(reported, minutes));
void loadPacket({ window: windowAround(reported, minutes) });
}, [loadPacket]);
const history: ChartChoiceHistory = start?.history ?? { current_record_id: null, records: [] };
@@ -232,8 +268,51 @@ function ChartChoiceFlow({ subjectId }: { readonly subjectId: string }) {
if (target.kind === "start") {
setStep({ kind: "loading" });
reloadStart();
} else if (target.kind === "packet") void loadPacket(target.window);
else setStep(target.step);
} else if (target.kind === "packet") void loadPacket(target.request, target.showRange, target.previous);
else restore(target.step);
}
function restore(previous: Step) {
if (previous.kind === "choose") {
setAnswers(previous.draft?.answers ?? {});
setEvidence(previous.draft?.evidence ?? {});
setOptionIds(previous.draft?.optionIds ?? {});
}
setStep(previous);
}
async function advance(round: Extract<Step, { kind: "choose" }>, stop = false) {
const body: ResultRequest = {
subjectId, resolved: [], ...round.request, mode: "preview",
...(rangeChosen ? { rangeChosen: true } : {}),
...(stop ? { stop: true } : { answers, ...evidence }),
};
setPending(true);
const back = { ...round, draft: { answers, evidence, optionIds } };
try {
const { decision } = await postJson<{ decision: FinalDecision }>("/api/chart-choice/adopt", body);
if (decision.has_next && decision.resolved) {
await loadPacket({ ...round.request, resolved: decision.resolved }, false, back);
} else setStep({ kind: "final", packet: round.packet, decision, body, back });
} catch (error) {
setStep({ kind: "failed", message: (error as Error).message, retry: { kind: "back", step: back } });
} finally {
setPending(false);
}
}
async function saveFinal(final: Extract<Step, { kind: "final" }>) {
setPending(true);
try {
const { decision } = await postJson<{ decision: FinalDecision }>("/api/chart-choice/adopt", {
...final.body, mode: "choice", ...(rangeChosen ? { rangeChosen: true } : {}),
});
afterSave(clockLabel(decision.saved_at, final.packet.reported_at, !decision.saved_at.endsWith(":00") && decision.saved_at.length === 19));
} catch (error) {
setStep({ kind: "failed", message: (error as Error).message, retry: { kind: "back", step: final } });
} finally {
setPending(false);
}
}
function renderStep(): ReactNode {
@@ -265,12 +344,12 @@ function ChartChoiceFlow({ subjectId }: { readonly subjectId: string }) {
history={history}
onStartOver={startOver}
onOpen={(record) => setStep({ kind: "record", record })}
onAstrologer={(record) => setStep({
kind: "astrologer",
chartChoiceTime: record?.saved_time ?? null,
back: { kind: "home" },
...(record ? { window: record.detail.window } : {}),
})}
remaining={resume?.id === current?.id ? resume?.packet.remaining_key_lines ?? 0 : 0}
resumedCharts={resume?.id === current?.id ? resume?.packet.key_charts : undefined}
onResume={current ? () => {
setRangeChosen(false);
void loadPacket({ resumeRecordId: current.id }, false, { kind: "home" });
} : undefined}
/>
);
case "record":
@@ -295,7 +374,7 @@ function ChartChoiceFlow({ subjectId }: { readonly subjectId: string }) {
type="button"
variant="outline"
className="chart-choice-range-choice"
onClick={() => void loadPacket({ start_at: option.start_at, end_at: option.end_at })}
onClick={() => void loadPacket({ window: { start_at: option.start_at, end_at: option.end_at } })}
>
{option.text}
</Button>
@@ -310,12 +389,25 @@ function ChartChoiceFlow({ subjectId }: { readonly subjectId: string }) {
{rangeChips(step.packet)}
<h2 className="chart-choice-heading">{rangeLabel(step.packet.window)} 里没有分界线,盘都一样</h2>
<div className="chart-choice-foot is-ruled is-end">
<Secondary onClick={() => setStep({ kind: "astrologer", chartChoiceTime: clockLabel(step.packet.saved_at), back: step })}>用占星师的方法核对</Secondary>
<Primary onClick={() => void saveCertain(step.packet)}>用 {clockLabel(step.packet.saved_at)}</Primary>
</div>
</Card>
);
case "range":
return <RangeStep packet={step.packet} rangeChips={rangeChips(step.packet)} onBack={hasHistory ? toHome : null}
onStart={() => {
if (step.packet.status === "choose") setStep({ kind: "choose", packet: step.packet, request: step.request, previous: step });
else if (step.packet.decision) setStep({ kind: "final", packet: step.packet, decision: step.packet.decision,
body: { subjectId, resolved: [], ...step.request, mode: "choice" }, back: step });
}} />;
case "choose":
if (step.packet.engine_version === "chart-choice-v2") return (
<RoundStep packet={step.packet} answers={answers} evidence={evidence} onEvidence={setEvidence}
optionIds={optionIds} onOption={(key, id) => setOptionIds((prev) => ({ ...prev, [key]: id }))} pending={pending}
onAnswer={(domain, value) => setAnswers((prev) => ({ ...prev, [domain]: value }))}
onBack={step.previous ? () => restore(step.previous!) : hasHistory ? toHome : null}
onNext={() => void advance(step)} onStop={() => void advance(step, true)} />
);
return (
<ChooseStep
packet={step.packet}
@@ -335,12 +427,12 @@ function ChartChoiceFlow({ subjectId }: { readonly subjectId: string }) {
saving={step.saving}
onAccept={() => void runDecision(step.packet, "choice", step.overrideSide ? { overrideSide: step.overrideSide } : {})}
onFlip={() => void runDecision(step.packet, "preview", { overrideSide: otherSide(step.decision.chosen_side) })}
onBack={() => setStep({ kind: "choose", packet: step.packet })}
onAstrologer={() => setStep({ kind: "astrologer", chartChoiceTime: clockLabel(step.decision.saved_at, step.packet.reported_at), back: step })}
onBack={() => setStep({ kind: "choose", packet: step.packet, request: {} })}
/>
);
case "astrologer":
return <AstrologerStage subjectId={subjectId} window={step.window ?? window} chartChoiceTime={step.chartChoiceTime} onBack={() => setStep(step.back)} />;
case "final":
return <FinalStep packet={step.packet} decision={step.decision} pending={pending}
onBack={() => restore(step.back)} onAccept={() => void saveFinal(step)} />;
}
}
}
@@ -387,6 +479,8 @@ function TimeRuler({
savedAt,
sides,
reportedSide,
ordered,
minor,
}: {
readonly window: ChartChoiceWindow;
readonly lineAt: string | null;
@@ -394,9 +488,11 @@ function TimeRuler({
readonly savedAt?: string;
readonly sides?: Choose["sides"];
readonly reportedSide?: ChartChoiceSide;
readonly ordered?: Choose["ordered_lines"];
readonly minor?: Choose["minor_lines"];
}) {
const start = minutesFrom(window.start_at);
const span = Math.max(1, minutesFrom(window.end_at) - start);
const span = Math.max(1 / 60, minutesFrom(window.end_at) - start);
const at = (value: string) => `${Math.min(100, Math.max(0, ((minutesFrom(value) - start) / span) * 100))}%`;
const width = (from: string, to: string) => `${Math.max(0, ((minutesFrom(to) - minutesFrom(from)) / span) * 100)}%`;
const highlighted: ChartChoiceSide | undefined = savedAt && lineAt
@@ -418,21 +514,260 @@ function TimeRuler({
<span className="chart-choice-ruler-line-label" style={{ left: at(lineAt) }}>分界 {clockLabel(lineAt, reportedAt, true)}</span>
</>
) : null}
{minor?.map((line) => <span key={line.at} className="chart-choice-ruler-line is-minor" style={{ left: at(line.at) }} />)}
{ordered?.map((line, index) => (
<span key={line.at} className="chart-choice-ruler-line" style={{ left: at(line.at) }}>
<span className="chart-choice-tag">{index + 1}</span>
</span>
))}
{savedAt ? (
<>
<div className="chart-choice-ruler-saved" style={{ left: at(savedAt) }} />
<span className="chart-choice-ruler-saved-label" style={{ left: at(savedAt) }}>存 {clockLabel(savedAt, reportedAt)}</span>
<span className="chart-choice-ruler-saved-label" style={{ left: at(savedAt) }}>存 {clockLabel(savedAt, reportedAt, savedAt.length === 19)}</span>
</>
) : null}
<span className="chart-choice-ruler-edge is-start">{clockLabel(window.start_at, reportedAt)}</span>
<span className="chart-choice-ruler-edge is-end">{clockLabel(window.end_at, reportedAt)}</span>
{savedAt === reportedAt ? null : (
<span className={cn("chart-choice-ruler-reported", savedAt && "is-secondary")} style={{ left: at(reportedAt) }}>▲ 填 {clockLabel(reportedAt)}</span>
{savedAt || minutesFrom(reportedAt) <= start || minutesFrom(reportedAt) >= minutesFrom(window.end_at) ? null : (
<span className="chart-choice-ruler-reported" style={{ left: at(reportedAt) }}>▲ 填 {clockLabel(reportedAt)}</span>
)}
</div>
);
}
const CHART_NAMES: Readonly<Record<string, string>> = {
D1: "主盘", D9: "婚恋", D10: "事业", D2: "钱财", D6: "日常", D8: "突变",
};
const EVENT_LABELS: Readonly<Record<string, string>> = {
relationship: "开始恋爱", marriage: "结婚", divorce: "分开", career: "入职或升职",
job_change: "换工作", business: "创业", finance_pos: "收入明显增加", finance_neg: "较大支出或损失", travel: "搬家 / 出国 / 去外地",
};
function KeyCharts({ charts, undetermined, reference }: {
readonly charts: readonly ChartChoiceKeyChart[] | undefined;
readonly undetermined: readonly string[] | undefined;
readonly reference: string;
}) {
const rows = charts ?? Object.keys(CHART_NAMES).map((chart): ChartChoiceKeyChart => ({ chart, sign: "", decided_by: null }));
return <dl className="chart-choice-plan">
{rows.map((row) => {
const uncertain = Boolean(row.flips_at || undetermined?.includes(row.chart));
const source = row.decided_by === "event" ? "(靠事件定)"
: row.decided_by === "supplement" || row.decided_by === "appearance" ? "(靠补充题定)" : "";
return <Fragment key={row.chart}>
<dt>{CHART_NAMES[row.chart]}</dt>
<dd>{row.sign ? `${row.sign}上升 · ` : ""}{uncertain ? "还没定" : "已定"}
{row.flips_at ? `(${clockLabel(row.flips_at, reference, true)} 换)` : source}
</dd>
</Fragment>;
})}
</dl>;
}
export function RangeStep({ packet, rangeChips, onBack, onStart }: {
readonly packet: Choose | Complete;
readonly rangeChips: ReactNode;
readonly onBack: (() => void) | null;
readonly onStart: () => void;
}) {
const ordered = packet.ordered_lines ?? [];
return <>
<Card>
{onBack ? <BackLink onClick={onBack}>返回</BackLink> : null}
<h2 className="chart-choice-heading">你填的 {clockLabel(packet.reported_at)} 前后,有 {ordered.length} 处会换盘</h2>
{rangeChips}
<TimeRuler window={packet.segment ?? packet.window} reportedAt={packet.reported_at} lineAt={null}
ordered={ordered} minor={packet.minor_lines} />
<p className="chart-choice-fine">深色竖线是关键盘换座;浅色是住处、父母、学业盘,不在这次确认里。</p>
</Card>
{ordered.length ? <Card>
<h3 className="chart-choice-subheading">按这个顺序问</h3>
<ol className="chart-choice-qa">
{ordered.map((line, index) => <li key={line.at}>
<b>{index + 1}. {line.charts.filter((key) => CHART_NAMES[key]).map((key) => CHART_NAMES[key]).join("、")}盘换座</b>
<span className="chart-choice-fine"> {clockLabel(line.at, packet.reported_at, true)}</span>
</li>)}
</ol>
<p className="chart-choice-fine">顺序按占星师的做法:先主盘上升,再婚恋、事业,最后钱财和其他。</p>
</Card> : null}
<div className="chart-choice-foot is-end"><Primary onClick={onStart}>{ordered.length ? "开始确认" : "看结果"}</Primary></div>
{ordered.length ? <p className="chart-choice-fine">最多问 3 处,每处 1–3 个问题,随时可以停</p> : null}
</>;
}
function EventChips({ events }: { readonly events: readonly (ChartChoiceDatedEvent & { readonly matched?: boolean })[] }) {
return <div className="chart-choice-chips">
{events.map((event, index) => <span key={`${event.category}-${event.date}-${index}`} className={cn("chart-choice-chip", event.matched && "is-accent")}>
{event.date} · {EVENT_LABELS[event.category] ?? event.category}
{event.matched === undefined ? "" : event.matched ? " · 对上" : " · 没对上"}
</span>)}
</div>;
}
export function RoundStep({ packet, answers, evidence, onEvidence, optionIds = {}, onOption, onAnswer, pending, onBack, onNext, onStop }: {
readonly packet: Choose;
readonly answers: Readonly<Record<string, ChartChoiceAnswer>>;
readonly evidence: ChartChoiceEvidenceInput;
readonly onEvidence: (value: ChartChoiceEvidenceInput) => void;
readonly optionIds?: Readonly<Record<string, string>>;
readonly onOption?: (key: string, id: string) => void;
readonly onAnswer: (domain: string, value: ChartChoiceAnswer) => void;
readonly pending: boolean;
readonly onBack: (() => void) | null;
readonly onNext: () => void;
readonly onStop: () => void;
}) {
const round = packet.round ?? 1;
const total = Math.min(packet.max_rounds ?? 3, round - 1 + (packet.remaining_key_lines ?? 1));
const eventAnswers: RoundAnswers = evidence.event_answers ?? {};
const marks = evidence.birthmarks ?? [];
const events = [...(packet.events ?? []), ...Object.values(eventAnswers).flatMap((answer) =>
Object.values(answer.dates ?? {}).filter((event) => event.date && event.category))];
const eventReady = Object.values(eventAnswers).every((answer) => eventSides(answer.choice).every((side) => {
const event = answer.dates?.[side];
return Boolean(event?.category && /^\d{4}(?:-(?:0[1-9]|1[0-2]))?$/.test(event.date));
}));
const comparison = packet.comparisons.find((row) => CHART_NAMES[row.chart]);
const chartName = comparison && CHART_NAMES[comparison.chart];
return <>
<Card>
{onBack && !pending ? <BackLink onClick={onBack}>返回</BackLink> : null}
<p className="chart-choice-label">第 {round} 处,共 {total} 处</p>
<h2 className="chart-choice-heading">{clockLabel(packet.line.at, packet.reported_at, true)} 前后,{chartName}盘换星座</h2>
<TimeRuler window={packet.segment ?? packet.window} lineAt={packet.line.at} reportedAt={packet.reported_at}
sides={packet.sides} reportedSide={packet.reported_known && !packet.reported_neutral ? packet.reported_side : undefined} />
{comparison ? <dl className="chart-choice-sides">
{(["before", "after"] as const).map((side) => {
const reported = packet.reported_known && !packet.reported_neutral && packet.reported_side === side;
return <div key={side} className={cn(reported && "is-reported")}>
<dt>{SIDE_TEXT[side]}{reported ? " · 你填的" : ""}</dt><dd>{comparison[side].ascendant}上升</dd>
</div>;
})}
</dl> : null}
</Card>
{packet.appearance_question ? <Card>
<h3 className="chart-choice-subheading">哪个更接近你给人的第一印象?</h3>
<div className="chart-choice-answers" role="group" aria-label={packet.appearance_question.prompt}>
{packet.appearance_question.options.map((option) => <Button key={option.value} type="button" variant="outline"
aria-pressed={evidence.appearance === option.value}
className={cn("chart-choice-answer", evidence.appearance === option.value && "is-selected")}
onClick={() => onEvidence({ ...evidence, appearance: option.value })}>{option.text}</Button>)}
</div>
</Card> : null}
{packet.birthmark_question ? <Card>
<h3 className="chart-choice-subheading">身上有比较明显的胎记或伤疤吗?在哪里?</h3>
<p className="chart-choice-fine">可多选,没有就跳过</p>
<div className="chart-choice-chips" role="group" aria-label={packet.birthmark_question.prompt}>
{packet.birthmark_question.options.map((option) => <Button key={option.value} type="button" variant="outline"
aria-pressed={marks.includes(option.value)} className={cn("chart-choice-chip", marks.includes(option.value) && "is-accent")}
onClick={() => onEvidence({ ...evidence, birthmarks: marks.includes(option.value)
? marks.filter((mark) => mark !== option.value)
: option.value === "none" ? ["none"] : [...marks.filter((mark) => mark !== "none"), option.value] })}>{option.text}</Button>)}
</div>
</Card> : null}
{packet.event_questions?.map((question) => {
const answer = eventAnswers[question.id];
const update = (next: RoundAnswers[string]) => onEvidence({ ...evidence, event_answers: { ...eventAnswers, [question.id]: next } });
return <Card key={question.id}>
<h3 className="chart-choice-subheading">{question.prompt}</h3>
<div className="chart-choice-answers" role="group" aria-label={question.prompt}>
{question.options.map((option) => <Button key={option.value} type="button" variant="outline"
aria-pressed={answer?.choice === option.value}
className={cn("chart-choice-answer", answer?.choice === option.value && "is-selected")}
onClick={() => update({ choice: option.value, dates: Object.fromEntries(eventSides(option.value).map((side) =>
[side, answer?.dates?.[side] ?? { date: "", category: "" }])) })}>{option.text}</Button>)}
</div>
{answer ? eventSides(answer.choice).map((side) => {
const event = answer.dates?.[side] ?? { date: "", category: "" };
const key = `${question.id}:${side}`;
const change = (next: ChartChoiceDatedEvent) => update({ ...answer, dates: { ...answer.dates, [side]: next } });
return <fieldset key={side} className="chart-choice-ask">
<legend className="chart-choice-label">{SIDE_TEXT[side]}</legend>
<label className="chart-choice-question">大概哪一年
<Input className="chart-choice-input" type="text" inputMode="numeric" maxLength={7} placeholder="YYYY 或 YYYY-MM"
value={event.date} onChange={(e) => change({ ...event, date: e.target.value.trim() })} />
</label>
<div className="chart-choice-question">是哪种
<Select value={event.category ? optionIds[key] ?? question.follow_up.categories.find((option) => option.value === event.category)?.id ?? null : null}
onValueChange={(id) => {
const option = question.follow_up.categories.find((item) => item.id === id);
if (option) {
onOption?.(key, option.id);
change({ ...event, category: option.value });
}
}}>
<SelectTrigger aria-label={`${SIDE_TEXT[side]}是哪种`}>
<SelectValue placeholder="选一类" />
</SelectTrigger>
<SelectContent>
{question.follow_up.categories.map((option) => <SelectItem key={option.id} value={option.id}>{option.text}</SelectItem>)}
</SelectContent>
</Select>
</div>
</fieldset>;
}) : null}
</Card>;
})}
{packet.comparisons.length ? <details className="chart-choice-card">
<summary className="chart-choice-subheading">补充:两边的{chartName}盘读起来是什么样(可跳过)</summary>
{packet.comparisons.map((item) => <CompareCard key={item.chart} comparison={item}
question={packet.questions.find((q) => q.domain === item.before.domain) ?? null}
answer={answers[item.before.domain]} onAnswer={onAnswer} />)}
</details> : null}
{events.length ? <Card><p className="chart-choice-label">你提到的事 · {events.length} 件</p><EventChips events={events} /></Card> : null}
<div className="chart-choice-foot">
<Secondary disabled={pending} onClick={onStop}>先到这里</Secondary>
<Primary disabled={pending || !eventReady} onClick={onNext}>{round < total ? "接着确认下一处" : "看结果"}</Primary>
</div>
</>;
}
const CONFIDENCE = { tendency_only: "倾向", high: "高", medium: "中", low: "低", none: "暂时分不出" };
export function FinalStep({ packet, decision, pending, onBack, onAccept }: {
readonly packet: Choose | Complete;
readonly decision: FinalDecision;
readonly pending: boolean;
readonly onBack: () => void;
readonly onAccept: () => void;
}) {
const summary = decision.evidence_summary;
const seconds = decision.saved_at.length === 19 && !decision.saved_at.endsWith(":00");
const saved = clockLabel(decision.saved_at, packet.reported_at, seconds);
return <>
<Card>
{!pending ? <BackLink onClick={onBack}>改答案</BackLink> : null}
<p className="chart-choice-label">确认了 {decision.rounds?.length ?? 0} 处</p>
<p className="chart-choice-fine">候选时间段</p>
<h2 className="chart-choice-heading">{segmentLabel(decision.segment)}</h2>
<p className="chart-choice-fine">代表时刻 {saved}
{minutesFrom(decision.saved_at) === minutesFrom(packet.reported_at) ? " · 就是你填的时间" : ""}</p>
<TimeRuler window={decision.segment} reportedAt={packet.reported_at} lineAt={null} savedAt={decision.saved_at} />
<p className="chart-choice-note">这是按你的回答选出的一段,不是确认过的出生分钟。
{decision.undetermined_charts?.length ? "" : "真实时间在这段里任何一处,下面六张盘都一样。"}</p>
</Card>
<Card>
<h3 className="chart-choice-subheading">六张盘</h3>
<KeyCharts charts={decision.key_charts} undetermined={decision.undetermined_charts} reference={decision.saved_at} />
</Card>
{summary ? <Card>
<h3 className="chart-choice-subheading">依据</h3>
<p className="chart-choice-note">你提到 {summary.event_count} 件有日期的事,{summary.matched} 件和这一段的大运对得上。</p>
<EventChips events={decision.events_detail ?? []} />
<p className="chart-choice-note">{summary.event_count < 3 ? "倾向" : `占星师的把握分级:${CONFIDENCE[summary.confidence]}。`}</p>
{packet.status === "choose" && !pending ? <Button type="button" variant="link" className="chart-choice-text-link"
onClick={onBack}>再补几件事 <ChevronRight aria-hidden="true" /></Button> : null}
</Card> : null}
<details className="chart-choice-card">
<summary className="chart-choice-subheading">这个方法有多准</summary>
<p className="chart-choice-note">我们在 77 个出生时间有记录的公开案例上回测过:30 分钟范围里,按事件打分选对的占 19%,随机挑占 20%,直接用填写的时间占 34%。所以这里的结果是你根据自己经历做的选择,不是验证结果。</p>
</details>
<div className="chart-choice-foot is-end"><Primary disabled={pending} onClick={onAccept}>用这个时间段继续</Primary></div>
<p className="chart-choice-fine">星盘、报告、聊天都按这一段读;你填的出生时间不改</p>
</>;
}
/** 按 X 读 / 两版都读 / 不受影响 for a decision. */
function ReadingPlan({
chosen,
@@ -457,18 +792,23 @@ function ReadingPlan({
);
}
function HomeView({
export function HomeView({
history,
onStartOver,
onOpen,
onAstrologer,
remaining,
onResume,
resumedCharts,
}: {
readonly history: ChartChoiceHistory;
readonly onStartOver: () => void;
readonly onOpen: (record: ChartChoiceRecord) => void;
readonly onAstrologer: (record: ChartChoiceRecord | null) => void;
readonly remaining: number;
readonly onResume?: () => void;
readonly resumedCharts?: readonly ChartChoiceKeyChart[];
}) {
const current = history.records.find((record) => record.id === history.current_record_id) ?? null;
const decision = current?.detail.decision;
return (
<>
{current ? (
@@ -477,24 +817,33 @@ function HomeView({
<p className="chart-choice-label">当前使用</p>
<p className="chart-choice-fine">{savedAtLabel(current.created_at)} 保存</p>
</div>
<BigTime time={current.saved_time} side={current.chosen_side} />
<TimeRuler
{decision?.segment ? <>
<h2 className="chart-choice-heading">{segmentLabel(decision.segment)}</h2>
<p className="chart-choice-fine">代表时刻 {clockLabel(decision.saved_at, current.detail.reported_at, decision.saved_at.length === 19)} · 你填的是 {clockLabel(current.detail.reported_at)}</p>
<KeyCharts charts={decision.key_charts ?? resumedCharts} undetermined={decision.undetermined_charts} reference={decision.saved_at} />
</> : <>
<BigTime time={current.saved_time} side={current.chosen_side} />
<TimeRuler
window={current.detail.window}
lineAt={current.detail.line?.at ?? null}
reportedAt={current.detail.reported_at}
savedAt={current.detail.decision.saved_at}
/>
/>
<ReadingPlan
chosen={current.chosen_side}
charts={current.detail.charts}
bothVersions={current.detail.decision.both_versions_domains}
unchanged={current.detail.unchanged}
/>
/>
</>}
{remaining > 0 && onResume ? <div className="chart-choice-foot">
<p className="chart-choice-note">还有 {remaining} 处没确认</p>
<Secondary onClick={onResume}>继续 <ChevronRight aria-hidden="true" /></Secondary>
</div> : null}
<div className="chart-choice-button-pair">
<AppLink href="/chart" className="chart-choice-link">看星盘</AppLink>
<Primary onClick={onStartOver}>重新纠正</Primary>
</div>
<div className="chart-choice-foot is-end"><Secondary onClick={() => onAstrologer(current)}>用占星师的方法核对</Secondary></div>
</Card>
) : (
<Card>
@@ -509,10 +858,12 @@ function HomeView({
{history.records.map((record) => (
<Button key={record.id} type="button" variant="ghost" className="chart-choice-history-row" onClick={() => onOpen(record)}>
<span className="chart-choice-history-main">
<b>{record.saved_time}{record.chosen_side !== "none" ? ` · ${SIDE_TEXT[record.chosen_side]}` : ""}</b>
<b>{record.detail.decision.segment ? segmentLabel(record.detail.decision.segment) : `${record.saved_time}${record.chosen_side !== "none" ? ` · ${SIDE_TEXT[record.chosen_side]}` : ""}`}</b>
{record.id === history.current_record_id ? <span className="chart-choice-tag">当前</span> : null}
</span>
<span className="chart-choice-history-sub">{savedAtLabel(record.created_at)} · {recordSummary(record)}</span>
<span className="chart-choice-history-sub">{savedAtLabel(record.created_at)} · {record.detail.decision.segment
? `确认 ${record.detail.decision.rounds?.length ?? 0} 处 · 提到 ${record.detail.decision.evidence_summary?.event_count ?? record.detail.decision.events?.length ?? 0} 件事`
: recordSummary(record)}</span>
</Button>
))}
</section>
@@ -711,7 +1062,6 @@ function ResultStep({
onAccept,
onFlip,
onBack,
onAstrologer,
}: {
readonly packet: Choose;
readonly decision: ChartChoiceDecision;
@@ -720,7 +1070,6 @@ function ResultStep({
readonly onAccept: () => void;
readonly onFlip: () => void;
readonly onBack: () => void;
readonly onAstrologer: () => void;
}) {
const chosen = decision.chosen_side;
const other = otherSide(chosen);
@@ -758,9 +1107,6 @@ function ResultStep({
<Secondary onClick={onFlip} disabled={saving}>{flipText}</Secondary>
<Primary onClick={onAccept} disabled={saving}>{saving ? "保存中…" : "保存"}</Primary>
</div>
<div className="chart-choice-foot is-end">
<Secondary onClick={onAstrologer} disabled={saving}>用占星师的方法核对</Secondary>
</div>
</Card>
);
}
+13 -1
View File
@@ -44,6 +44,14 @@ export type ChartChoiceResolved = {
readonly override_side?: ChartChoiceSide;
} & ChartChoiceEvidenceInput;
export type ChartChoiceLine = { readonly at: string; readonly degree: number; readonly charts: readonly string[] };
export type ChartChoiceMinorLine = Pick<ChartChoiceLine, "at" | "charts">;
export type ChartChoiceKeyChart = {
readonly chart: string;
readonly sign: string;
readonly decided_by: NonNullable<ChartChoiceEvidence["decided_by"]> | null;
readonly flips_at?: string;
};
export type ChartChoiceEventDetail = ChartChoiceDatedEvent & { readonly matched: boolean };
export type ChartChoiceRound = {
readonly line_at: string;
readonly charts: readonly string[];
@@ -57,6 +65,8 @@ export type ChartChoiceRoundResult = {
readonly undetermined_charts: readonly string[];
readonly events: readonly ChartChoiceDatedEvent[];
readonly evidence_summary?: ChartChoiceEvidenceSummary;
readonly events_detail?: readonly ChartChoiceEventDetail[];
readonly key_charts?: readonly ChartChoiceKeyChart[];
readonly remaining_key_lines: number;
readonly has_next: boolean;
readonly resolved: readonly ChartChoiceResolved[];
@@ -97,7 +107,7 @@ export type ChartChoiceQuestion = {
};
type ChartChoiceOption<T extends string> = { readonly value: T; readonly text: string };
type ChartChoiceEventQuestion = {
export type ChartChoiceEventQuestion = {
readonly id: string;
readonly chart: string;
readonly before_ages: readonly [number, number];
@@ -119,6 +129,8 @@ type PacketBase = {
readonly max_rounds?: number;
readonly remaining_key_lines?: number;
readonly ordered_lines?: readonly ChartChoiceLine[];
readonly minor_lines?: readonly ChartChoiceMinorLine[];
readonly key_charts?: readonly ChartChoiceKeyChart[];
readonly segment?: ChartChoiceWindow;
readonly resolved?: readonly ChartChoiceResolved[];
readonly events?: readonly ChartChoiceDatedEvent[];
+2
View File
@@ -348,6 +348,8 @@ function roundFields(decision: Partial<ChartChoiceRoundResult>): Partial<ChartCh
segment: decision.segment, rounds: decision.rounds, undetermined_charts: decision.undetermined_charts,
events: decision.events, remaining_key_lines: decision.remaining_key_lines, has_next: decision.has_next,
...(decision.evidence_summary ? { evidence_summary: decision.evidence_summary } : {}),
...(decision.events_detail ? { events_detail: decision.events_detail } : {}),
...(decision.key_charts ? { key_charts: decision.key_charts } : {}),
};
}
+11 -1
View File
@@ -2,7 +2,7 @@
* Chart choice page: pure helpers (clock labels, request shapes) kept out of
* the component so they can be tested without a browser.
*/
import type { ChartChoiceAnswer, ChartChoicePacket, ChartChoiceSide, ChartChoiceWindow } from "./contract.ts";
import type { ChartChoiceAnswer, ChartChoiceEventChoice, ChartChoicePacket, ChartChoiceSide, ChartChoiceWindow } from "./contract.ts";
/** "1997-08-08T04:59:43" → "04:59:43"; with a date only when it differs from ``sameDayAs``. */
export function clockLabel(at: string, sameDayAs?: string, withSeconds = false): string {
@@ -17,6 +17,16 @@ export function rangeLabel(window: ChartChoiceWindow, reference?: string): strin
return `${clockLabel(window.start_at, reference)}–${clockLabel(window.end_at, reference)}`;
}
export function segmentLabel(segment: ChartChoiceWindow, reference = segment.start_at): string {
const seconds = [segment.start_at, segment.end_at].some((at) => at.length === 19 && !at.endsWith(":00"));
return `${clockLabel(segment.start_at, reference, seconds)}–${clockLabel(segment.end_at, reference, seconds)}`;
}
export function eventSides(choice: ChartChoiceEventChoice): readonly ChartChoiceSide[] {
if (choice === "both") return ["before", "after"];
return choice === "before" || choice === "after" ? [choice] : [];
}
/** Symmetric range around a time given without one (minutes either side). */
export function windowAround(reportedAt: string, minutes: number): ChartChoiceWindow {
const center = Date.parse(`${reportedAt}:00Z`);
@@ -58,8 +58,8 @@ test("offset and side-by-side text never call either result verified", () => {
test("the page offers the stage from the result, the no-line case and the current record", () => {
const page = readFileSync(new URL("../src/components/chart-choice/chart-choice-page.tsx", import.meta.url), "utf8");
assert.equal(page.match(/用占星师的方法核对/g)?.length, 3);
assert.match(page, /<AstrologerStage /);
assert.doesNotMatch(page, /用占星师的方法核对|<AstrologerStage /);
assert.match(page, /<RoundStep /);
const stage = readFileSync(new URL("../src/components/chart-choice/astrologer-stage.tsx", import.meta.url), "utf8");
for (const part of ["选择题", "外表体质", "胎记", "有日期的大事", "父母、兄弟姐妹、孩子", "packet.caveat", "match_tier", "engine_confidence"]) {
assert.ok(stage.includes(part), part);
@@ -0,0 +1,90 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { register } from "node:module";
import test from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import { FinalStep, HomeView, RangeStep, RoundStep } from "../src/components/chart-choice/chart-choice-page";
import type { ChartChoiceEnginePacket, ChartChoiceCompletion, ChartChoiceRecord } from "../src/lib/chart-choice/contract";
import { eventSides } from "../src/lib/chart-choice/view";
type Choice = Extract<ChartChoiceEnginePacket, { status: "choose" }>;
type Complete = Extract<ChartChoiceEnginePacket, { status: "complete" }>;
const fixture = JSON.parse(readFileSync(new URL("./fixtures/chart-choice-rounds-display-20261113.json", import.meta.url), "utf8")) as {
packets: Choice[];
final: Complete;
};
const noop = () => {};
test("real chart choice packets render minor lines, three rounds and the final evidence", () => {
const packet = fixture.packets[0];
assert.ok(packet.minor_lines?.length);
const range = renderToStaticMarkup(<RangeStep packet={packet} rangeChips={null} onBack={null} onStart={noop} />);
assert.equal((range.match(/is-minor/g) ?? []).length, packet.minor_lines.length);
assert.match(range, /按这个顺序问/);
assert.match(range, /开始确认/);
for (const [index, round] of fixture.packets.entries()) {
const html = renderToStaticMarkup(<RoundStep packet={round} answers={{}} evidence={{}} onEvidence={noop}
onAnswer={noop} pending={false} onBack={null} onNext={noop} onStop={noop} />);
assert.match(html, new RegExp(`第 ${index + 1} 处,共 3 处`));
assert.match(html, /先到这里/);
assert.match(html, index < 2 ? /接着确认下一处/ : /看结果/);
}
assert.deepEqual(eventSides("both"), ["before", "after"]);
const final = renderToStaticMarkup(<FinalStep packet={fixture.final} decision={fixture.final.decision}
pending={false} onBack={noop} onAccept={noop} />);
assert.match(final, /六张盘/);
assert.match(final, /用这个时间段继续/);
assert.match(final, /19%.*20%.*34%/);
assert.equal((final.match(/上升 · /g) ?? []).length, 6);
assert.equal((final.match(/ · 对上/g) ?? []).length, fixture.final.decision.evidence_summary?.matched);
});
test("the current record shows a continuation only when the scan has remaining lines", () => {
const { decision, window, reported_at } = fixture.final;
const record: ChartChoiceRecord = {
id: "test-record", created_at: "2026-11-13T03:00:00Z", saved_date: decision.saved_at.slice(0, 10),
saved_time: decision.saved_at.slice(11), chosen_side: "none", rule: decision.rule, reusable: true,
detail: { window, reported_at, line: null, other_lines: decision.remaining_key_lines, questions: [], answers: {},
unchanged: [], decision: { ...decision, chosen_side: "none", votes: null, both_versions_domains: [] }, charts: [] },
};
const history = { current_record_id: record.id, records: [record] };
const props = { history, onOpen: noop, onStartOver: noop, onResume: noop };
assert.match(renderToStaticMarkup(<HomeView {...props} remaining={2} />), /还有 2 处没确认/);
assert.doesNotMatch(renderToStaticMarkup(<HomeView {...props} remaining={0} />), /处没确认/);
});
test("the chart choice API service passes through the three display fields and saves final details", async () => {
register(new URL("./support/stub-server-only.mjs", import.meta.url).href, import.meta.url);
const { adoptChartChoice, buildChartChoicePacket, previewChartChoice } = await import("../src/lib/chart-choice/server");
const profile = {
name: "虚构甲", birth_date: "1997-08-08", reported_birth_time: "05:00", birth_time_source: "approximate",
birth_time_status: "reported", declared_window_start: "04:30", declared_window_end: "05:30",
uncertainty_before_minutes: 30, uncertainty_after_minutes: 30, birth_time_uncertainty_known: true,
latitude: 36.42, longitude: 114.21, timezone_offset: 8, timezone_id: "Asia/Shanghai", birth_place_label: "虚构市",
};
const caller = { from: () => ({ select: () => ({ eq: () => ({ maybeSingle: async () => ({ data: profile, error: null }) }) }) }) };
const fetchEngine: typeof fetch = async (url, options) => {
const body = JSON.parse(String(options?.body));
assert.deepEqual(body.resolved, []);
if (String(url).endsWith("/packet")) return Response.json(fixture.packets[0]);
assert.ok(String(url).endsWith("/decide"));
assert.equal(body.stop, true);
return Response.json(fixture.final);
};
const packet = await buildChartChoicePacket(caller, "fixture", { resolved: [] }, fetchEngine);
assert.deepEqual(packet.minor_lines, fixture.packets[0].minor_lines);
assert.deepEqual(packet.key_charts, fixture.packets[0].key_charts);
const request = { resolved: [], stop: true };
const preview = await previewChartChoice(caller, "fixture", request, fetchEngine);
assert.deepEqual(preview.decision.events_detail, fixture.final.decision.events_detail);
let saved: Partial<ChartChoiceCompletion> | undefined;
const admin = { rpc: async (name: string, args: Record<string, unknown>) => {
assert.equal(name, "chart_choice_adopt_v4");
saved = (args.p_detail as { decision: ChartChoiceCompletion }).decision;
return { data: {}, error: null };
} };
await adoptChartChoice(caller, admin, "fixture", request, fetchEngine);
assert.deepEqual(saved?.events_detail, fixture.final.decision.events_detail);
assert.deepEqual(saved?.key_charts, fixture.final.decision.key_charts);
});
File diff suppressed because it is too large Load Diff