Intake stores how sure the user is; rectification now searches that range, offers a one-click widen when event fit is low at the edge, and trisects windows longer than two hours before the minute grid. Co-authored-by: Cursor <cursoragent@cursor.com>
261 lines
10 KiB
TypeScript
261 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { useId, useState } from "react";
|
|
import { BirthDatePicker } from "@/components/birth-date-picker";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { birthTimeConsultationOptionsCopy } from "@/lib/birth-time-consultation-consent";
|
|
import {
|
|
approximateUncertaintyOptions,
|
|
birthTimeDisplayState,
|
|
birthTimeSourceDefaults,
|
|
birthTimeSourceOptions,
|
|
type BirthTimeDraft,
|
|
type BirthTimeDraftPatch,
|
|
type BirthTimeSource,
|
|
} from "@/lib/birth-time-intake-model";
|
|
|
|
type BirthTimeIntakeProps = {
|
|
readonly value: BirthTimeDraft;
|
|
readonly onPatch: (patch: BirthTimeDraftPatch) => void;
|
|
};
|
|
|
|
|
|
const hours = Array.from({ length: 24 }, (_, index) => String(index).padStart(2, "0"));
|
|
const minutes = Array.from({ length: 60 }, (_, index) => String(index).padStart(2, "0"));
|
|
|
|
function splitBirthTime(value: string) {
|
|
const [hour = "", minute = ""] = value.split(":");
|
|
return { hour, minute };
|
|
}
|
|
|
|
type BirthClockSelectProps = {
|
|
readonly value: string;
|
|
readonly onChange: (value: string) => void;
|
|
readonly ariaLabel?: string;
|
|
readonly hourLabel?: string;
|
|
readonly minuteLabel?: string;
|
|
};
|
|
|
|
function BirthClockSelect({
|
|
value,
|
|
onChange,
|
|
ariaLabel = "选择出生时间",
|
|
hourLabel = "出生时间小时",
|
|
minuteLabel = "出生时间分钟",
|
|
}: BirthClockSelectProps) {
|
|
const currentTime = value || "";
|
|
const [timeParts, setTimeParts] = useState(() => splitBirthTime(currentTime));
|
|
|
|
const patchTime = (part: "hour" | "minute", nextValue: string | null) => {
|
|
if (nextValue === null) return;
|
|
const nextParts = { ...timeParts, [part]: nextValue };
|
|
setTimeParts(nextParts);
|
|
if (nextParts.hour && nextParts.minute) onChange(`${nextParts.hour}:${nextParts.minute}`);
|
|
};
|
|
|
|
return (
|
|
<div className="birth-time-clock-selects" aria-label={ariaLabel}>
|
|
<Select required value={timeParts.hour || null} onValueChange={(nextValue) => patchTime("hour", nextValue)}>
|
|
<SelectTrigger aria-label={hourLabel}>
|
|
<SelectValue placeholder="时" />
|
|
</SelectTrigger>
|
|
<SelectContent className="birth-time-clock-menu" style={{ width: 108, minWidth: 108 }}>
|
|
{hours.map((hour) => <SelectItem key={hour} value={hour}>{hour}</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>
|
|
<span className="birth-time-clock-separator" aria-hidden="true">:</span>
|
|
<Select required value={timeParts.minute || null} onValueChange={(nextValue) => patchTime("minute", nextValue)}>
|
|
<SelectTrigger aria-label={minuteLabel}>
|
|
<SelectValue placeholder="分" />
|
|
</SelectTrigger>
|
|
<SelectContent className="birth-time-clock-menu" style={{ width: 108, minWidth: 108 }}>
|
|
{minutes.map((minute) => <SelectItem key={minute} value={minute}>{minute}</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps) {
|
|
const groupId = useId();
|
|
const source = value.birthTimeSource;
|
|
const isConfirmed = value.birthTimeStatus === "confirmed";
|
|
const displayState = birthTimeDisplayState(value);
|
|
const selectedSourceOption: BirthTimeSource | "" = source === "family_exact" || source === "approximate"
|
|
? "approximate"
|
|
: source === "unknown"
|
|
? "period_only"
|
|
: source === "hospital_record" || source === "period_only"
|
|
? source
|
|
: "";
|
|
const usesClockTime = source === "hospital_record"
|
|
|| source === "family_exact"
|
|
|| source === "approximate"
|
|
|| source === "legacy_import";
|
|
const approximateRadius = source === "family_exact"
|
|
? 15
|
|
: value.uncertaintyBeforeMinutes;
|
|
|
|
return (
|
|
<div className="birth-time-intake">
|
|
{displayState && (
|
|
<section className="birth-time-profile-result" aria-label="生时校正结果">
|
|
<div className="birth-time-profile-result-heading">
|
|
<span>出生时间记录</span>
|
|
<strong>{displayState.kind === "candidate" ? "候选时间" : displayState.kind === "accepted" ? "校正采用" : "已记录时间"}</strong>
|
|
</div>
|
|
<dl>
|
|
<div>
|
|
<dt>{displayState.kind === "candidate" ? "待验证候选" : "当前排盘时间"}</dt>
|
|
<dd>{displayState.activeTime}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>原始填报时间</dt>
|
|
<dd>{displayState.reportedLabel}</dd>
|
|
</div>
|
|
</dl>
|
|
{displayState.kind === "candidate" && (
|
|
<p>这仍是未确认候选,不会自动成为出生分钟;{birthTimeConsultationOptionsCopy(value)}</p>
|
|
)}
|
|
{displayState.kind === "accepted" && (
|
|
<p>这是你从候选中选择的校正采用时间,不是外部档案确认的出生时间;后续排盘会使用它。</p>
|
|
)}
|
|
</section>
|
|
)}
|
|
<BirthDatePicker
|
|
value={value.date}
|
|
disabled={isConfirmed}
|
|
onChange={(date) => onPatch({ date })}
|
|
/>
|
|
|
|
{source === "legacy_import" && (
|
|
<p className="birth-time-legacy-note">
|
|
这是账号里已有的确认时间。本阶段保留原始记录,不在资料表内直接覆盖。
|
|
</p>
|
|
)}
|
|
|
|
{!isConfirmed && <fieldset className="birth-time-source-fieldset">
|
|
<legend>你对这个时间有多确定?</legend>
|
|
<p className="birth-time-source-intro">只用在这里问一次。校正会按你选的范围去比,不再追问你有多确定。</p>
|
|
<div className="birth-time-source-list">
|
|
{birthTimeSourceOptions.map((option) => (
|
|
<label
|
|
className={`birth-time-source-option ${option.value === selectedSourceOption ? "is-selected" : ""}`}
|
|
key={option.value}
|
|
>
|
|
<input
|
|
checked={option.value === selectedSourceOption}
|
|
name={`birth-time-source-${groupId}`}
|
|
type="radio"
|
|
value={option.value}
|
|
onChange={() => onPatch({
|
|
birthTimeSource: option.value,
|
|
birthTimeStatus: "reported",
|
|
time: "",
|
|
...birthTimeSourceDefaults[option.value],
|
|
})}
|
|
/>
|
|
<span>
|
|
<b>{option.label}</b>
|
|
<small>{option.hint}</small>
|
|
</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</fieldset>}
|
|
|
|
{usesClockTime && (
|
|
<div className="birth-time-detail-grid onboarding-card-reveal">
|
|
<label>
|
|
<span>{isConfirmed ? "当前排盘时间" : source === "approximate" ? "大概时间" : "记录时间"}</span>
|
|
<BirthClockSelect
|
|
key={`${source}-${value.reportedTime || value.time}`}
|
|
value={value.reportedTime || value.time}
|
|
onChange={(reportedTime) => onPatch({ reportedTime })}
|
|
/>
|
|
</label>
|
|
{source === "hospital_record" && (
|
|
<p className="birth-time-detail-note">系统会无感检查前后 2 分钟的上升与 D9 / D10 稳定性。</p>
|
|
)}
|
|
{(source === "approximate" || source === "family_exact") && (
|
|
<fieldset className="birth-time-uncertainty-fieldset">
|
|
<legend>选一个最接近的范围</legend>
|
|
<div className="birth-time-uncertainty-list">
|
|
{approximateUncertaintyOptions.map((option) => (
|
|
<button
|
|
className={`birth-time-uncertainty-option ${approximateRadius === option.minutes ? "is-selected" : ""}`}
|
|
key={option.minutes}
|
|
type="button"
|
|
onClick={() => onPatch(
|
|
option.minutes === 15 && source === "family_exact"
|
|
? {
|
|
uncertaintyBeforeMinutes: 0,
|
|
uncertaintyAfterMinutes: 0,
|
|
}
|
|
: {
|
|
birthTimeSource: "approximate",
|
|
uncertaintyBeforeMinutes: option.minutes,
|
|
uncertaintyAfterMinutes: option.minutes,
|
|
},
|
|
)}
|
|
>{option.label}</button>
|
|
))}
|
|
</div>
|
|
</fieldset>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{source === "period_only" && (
|
|
<div className="birth-time-detail-grid birth-time-window-details onboarding-card-reveal">
|
|
<p className="birth-time-window-intro">选择你记得的开始和结束时间。可以跨午夜,例如 23:00 到 03:59。</p>
|
|
<label>
|
|
<span>开始</span>
|
|
<BirthClockSelect
|
|
key={`start-${value.declaredWindowStart}`}
|
|
value={value.declaredWindowStart}
|
|
ariaLabel="选择开始时间"
|
|
hourLabel="开始小时"
|
|
minuteLabel="开始分钟"
|
|
onChange={(declaredWindowStart) => onPatch({ declaredWindowStart })}
|
|
/>
|
|
</label>
|
|
<label>
|
|
<span>结束</span>
|
|
<BirthClockSelect
|
|
key={`end-${value.declaredWindowEnd}`}
|
|
value={value.declaredWindowEnd}
|
|
ariaLabel="选择结束时间"
|
|
hourLabel="结束小时"
|
|
minuteLabel="结束分钟"
|
|
onChange={(declaredWindowEnd) => onPatch({ declaredWindowEnd })}
|
|
/>
|
|
</label>
|
|
<button
|
|
className="button-secondary birth-time-skip-button"
|
|
type="button"
|
|
onClick={() => onPatch({
|
|
birthTimeSource: "unknown",
|
|
reportedTime: "",
|
|
birthTimePeriod: "",
|
|
declaredWindowStart: "",
|
|
declaredWindowEnd: "",
|
|
birthTimeClue: "",
|
|
uncertaintyBeforeMinutes: null,
|
|
uncertaintyAfterMinutes: null,
|
|
birthTimeStatus: "reported",
|
|
time: "",
|
|
})}
|
|
>完全不清楚,跳过出生时间</button>
|
|
</div>
|
|
)}
|
|
|
|
{source === "unknown" && (
|
|
<div className="birth-time-detail-note onboarding-card-reveal" role="status">
|
|
<p>可以直接开始生时校正:先从你记得的经历比出大致时段。</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|