From c74c0bd70f6f9bbd4d8ea31625ae7464550b8740 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sun, 19 Jul 2026 10:39:47 +0800 Subject: [PATCH] feat: add click-first birth time questions --- frontend/src/app/birth-time-choice.css | 26 +++ frontend/src/app/layout.tsx | 1 + .../birth-time-candidate-result.tsx | 36 +++- .../components/birth-time-choice-question.tsx | 127 ++++++++++++++ .../birth-time-legacy-rectification.tsx | 83 +++++++++ .../components/birth-time-rectification.tsx | 143 ++++++---------- .../hooks/use-birth-time-guided-journey.ts | 20 ++- .../lib/birth-time-choice-question-model.ts | 50 ++++++ .../src/lib/birth-time-dynamic-preview.ts | 162 ++++++++++++++++++ frontend/src/lib/birth-time-guided-preview.ts | 2 + .../tests/birth-time-choice-question.test.ts | 93 ++++++++++ frontend/tests/birth-time-guide-flow.test.ts | 13 +- .../birth-time-rectification-contract.test.ts | 13 +- 13 files changed, 664 insertions(+), 105 deletions(-) create mode 100644 frontend/src/app/birth-time-choice.css create mode 100644 frontend/src/components/birth-time-choice-question.tsx create mode 100644 frontend/src/components/birth-time-legacy-rectification.tsx create mode 100644 frontend/src/lib/birth-time-choice-question-model.ts create mode 100644 frontend/src/lib/birth-time-dynamic-preview.ts create mode 100644 frontend/tests/birth-time-choice-question.test.ts diff --git a/frontend/src/app/birth-time-choice.css b/frontend/src/app/birth-time-choice.css new file mode 100644 index 00000000..7d1626e3 --- /dev/null +++ b/frontend/src/app/birth-time-choice.css @@ -0,0 +1,26 @@ +.birth-time-question-progress { flex-wrap: wrap; gap: var(--space-2) var(--space-4); line-height: 1.5; } +.birth-time-question-progress b { color: var(--color-ink); font-weight: 600; } +.birth-time-choice-surface { min-width: 0; display: grid; gap: var(--space-4); } +.birth-time-choice-question { min-width: 0; display: grid; gap: var(--space-3); margin: 0; padding: 0; border: 0; } +.birth-time-choice-question legend { width: 100%; margin: 0 0 var(--space-1); color: var(--color-ink); font-family: var(--font-display); font-size: var(--type-title-md); font-weight: 400; line-height: 1.55; text-wrap: pretty; word-break: auto-phrase; } +.birth-time-primary-choices { display: grid; gap: var(--space-2); } +.birth-time-special-choices, .birth-time-reframe-actions, .birth-time-journey-controls { display: flex; flex-wrap: wrap; gap: var(--space-2); } +.birth-time-choice-option { min-height: 52px; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); color: var(--color-ink); cursor: pointer; font-size: var(--type-body-sm); line-height: 1.5; text-align: left; text-wrap: pretty; word-break: auto-phrase; transition: border-color 120ms ease-out, background-color 120ms ease-out, transform 120ms ease-out; } +.birth-time-choice-option.is-primary { width: 100%; padding: var(--space-3) var(--space-4); } +.birth-time-choice-option.is-secondary { min-height: 44px; flex: 1 1 0; padding: var(--space-2) var(--space-4); color: var(--color-ink-secondary); text-align: center; } +.birth-time-choice-option:hover:not(:disabled) { border-color: var(--color-border-strong); background: var(--color-canvas-soft); } +.birth-time-choice-option:active:not(:disabled) { transform: translateY(1px); } +.birth-time-choice-option[data-selected="true"] { border-color: var(--color-action); background: var(--color-action-soft); color: var(--color-ink); } +.birth-time-choice-option:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; } +.birth-time-choice-question:disabled .birth-time-choice-option { cursor: wait; opacity: .62; } +.birth-time-choice-pending { margin: 0; color: var(--color-action); font-size: var(--type-caption); } +.birth-time-unmatched-note { display: grid; gap: var(--space-2); color: var(--color-ink-secondary); font-size: var(--type-caption); font-weight: 600; } +.birth-time-unmatched-note textarea { min-height: var(--space-24); resize: vertical; color: var(--color-ink); font-size: var(--type-body-sm); font-weight: 400; line-height: 1.55; } +.birth-time-reframe-actions, .birth-time-journey-controls { justify-content: flex-end; } +.birth-time-new-assessment { display: grid; justify-items: start; gap: var(--space-2); } +.birth-time-new-assessment small { color: var(--color-ink-secondary); font-size: var(--type-caption); line-height: 1.5; } + +@media (max-width: 767px) { + .birth-time-special-choices, .birth-time-reframe-actions, .birth-time-journey-controls { display: grid; grid-template-columns: 1fr; } + .birth-time-choice-option.is-secondary, .birth-time-reframe-actions > button, .birth-time-journey-controls > button { width: 100%; } +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 67e6e9ac..fae44c11 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import Script from "next/script"; import "./globals.css"; +import "./birth-time-choice.css"; export const metadata: Metadata = { title: "Jyotisha · 印度占星", diff --git a/frontend/src/components/birth-time-candidate-result.tsx b/frontend/src/components/birth-time-candidate-result.tsx index eebfa190..036e3eb2 100644 --- a/frontend/src/components/birth-time-candidate-result.tsx +++ b/frontend/src/components/birth-time-candidate-result.tsx @@ -18,13 +18,14 @@ const confidenceLabels = { export function BirthTimeCandidateResult({ journey, controller }: CandidateResultProps) { const result = journey.candidateResult; const action = journey.nextAction; + const dynamic = journey.journeyProtocol === "dynamic-choice-v2"; const terminalPath = guidedTerminalPath(journey); if (!result && action.kind === "present_low_result") { return (
-

目前证据不足,只能保留已填报的时间范围 {journey.snapshot.reportedRange.label}。

+

目前没有足够的新信息继续稳定缩小范围,本次评估已结束并保存当前候选范围。

系统不会选择或应用未经证据支持的具体分钟,当前排盘使用时间保持不变。

- {terminalPath && } + {terminalPath && }
); } @@ -34,15 +35,15 @@ export function BirthTimeCandidateResult({ journey, controller }: CandidateResul return (
- 关键经历评分结果 + {dynamic ? "候选范围评估结果" : "关键经历评分结果"} {confidenceLabels[result.confidence]}
{winner ? (
候选时间范围
{winner.startTime}—{winner.endTime}
候选代表时间
{winner.representativeTime}
-
有效经历
{result.eventCount} 条 / {result.domainCount} 个领域
-
领先幅度
{result.marginPercent}%
+ {!dynamic &&
有效经历
{result.eventCount} 条 / {result.domainCount} 个领域
} + {!dynamic &&
领先幅度
{result.marginPercent}%
}
) : (

候选仍然并列或缺少必要计算层,系统不会选择具体分钟。

@@ -51,11 +52,17 @@ export function BirthTimeCandidateResult({ journey, controller }: CandidateResul {action.kind === "present_low_result" && (
-

本轮校正已安全结束,只保留候选范围,当前排盘使用时间保持不变。

- {terminalPath && } +

{dynamic ? "目前没有足够的新信息继续稳定缩小范围,本次评估已结束并保存当前候选范围。" : "本轮校正已安全结束,只保留候选范围,当前排盘使用时间保持不变。"}

+ {terminalPath && }
)} - {action.kind === "present_medium_result" && winner && ( + {action.kind === "present_medium_result" && winner && dynamic && ( +
+

已形成较窄的候选范围,本次评估已结束;它不会自动改动当前排盘使用时间

+ +
+ )} + {action.kind === "present_medium_result" && winner && !dynamic && (

可以保存候选时间范围,代表时间不会用于排盘。

} + {terminalPath && }
)} {action.kind === "request_candidate_confirmation" && winner && ( @@ -87,3 +94,14 @@ export function BirthTimeCandidateResult({ journey, controller }: CandidateResul
); } + +function NewAssessmentAction({ controller }: { + readonly controller: BirthTimeGuidedController; +}) { + return ( +
+ + 会建立新的记录,当前结果仍会保留。 +
+ ); +} diff --git a/frontend/src/components/birth-time-choice-question.tsx b/frontend/src/components/birth-time-choice-question.tsx new file mode 100644 index 00000000..21d6ebcd --- /dev/null +++ b/frontend/src/components/birth-time-choice-question.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { useState } from "react"; +import { + choiceQuestionGroups, + choiceSelectionIntent, + normalizeUnmatchedNote, + rangeLabel, +} from "@/lib/birth-time-choice-question-model"; +import type { + DynamicJourneyProgress, + PublicDynamicChoiceQuestion, +} from "@/lib/birth-time-journey-turn-protocol"; + +type SharedQuestionProps = { + readonly progress: DynamicJourneyProgress; + readonly pending: boolean; + readonly error: string; + readonly onPause: () => void; + readonly onFinish: () => void; +}; + +type ChoiceQuestionProps = SharedQuestionProps & { + readonly question: PublicDynamicChoiceQuestion; + readonly onSelect: (optionId: string) => void; +}; + +type ClarificationProps = SharedQuestionProps & { + readonly onReframe: (note: string) => void; +}; + +export function BirthTimeQuestionProgress({ progress }: { + readonly progress: DynamicJourneyProgress; +}) { + return ( +
+ 已完成 {progress.effectiveAnswerCount} 个有效判断 + 当前候选范围:{rangeLabel(progress.currentRange)} +
+ ); +} + +export function BirthTimeChoiceQuestion(props: ChoiceQuestionProps) { + const groups = choiceQuestionGroups(props.question); + const [selectedId, setSelectedId] = useState(""); + const select = (option: PublicDynamicChoiceQuestion["options"][number]) => { + const intent = choiceSelectionIntent(option); + setSelectedId(intent.optionId); + props.onSelect(intent.optionId); + }; + + return ( +
+ +
+ {props.question.prompt} +
+ {groups.primary.map((option) => ( + + ))} +
+
+ {[groups.unknown, groups.unmatched].map((option) => ( + + ))} +
+
+ {props.pending &&

正在缩小候选范围…

} + +
+ ); +} + +export function BirthTimeUnmatchedClarification(props: ClarificationProps) { + const [note, setNote] = useState(""); + return ( +
+ +
+ 这道题都不符合你的情况吗? +