import { format, isValid, parse } from "date-fns"; import type { JourneySnapshot } from "./birth-time-journey.ts"; const birthDatePattern = "yyyy-MM-dd"; const birthClockPattern = /^(?:[01]\d|2[0-3]):[0-5]\d$/; const earliestBirthYear = 1900; const latestBirthYear = 2100; export type BirthTimeSource = | "" | "hospital_record" | "family_exact" | "approximate" | "period_only" | "unknown" | "legacy_import"; export type BirthTimePeriod = | "" | "early_morning" | "morning" | "afternoon" | "evening" | "late_night"; export type BirthTimeStatus = | "" | "reported" | "assessing" | "rectifying" | "candidate" | "accepted" | "confirmed"; export type BirthTimeDraft = { readonly date: string; readonly time: string; readonly reportedTime: string; readonly birthTimeSource: BirthTimeSource; readonly birthTimePeriod: BirthTimePeriod; readonly birthTimeClue: string; readonly uncertaintyBeforeMinutes: number | null; readonly uncertaintyAfterMinutes: number | null; readonly birthTimeStatus: BirthTimeStatus; }; export type BirthTimeDraftPatch = Partial; export type DeclaredBirthPlace = Readonly<{ label: string; lat: number; lon: number; tz: number | null; timezoneId?: string; }>; export function parseBirthDate(value: string): Date | undefined { if (value === "") return undefined; const parsed = parse(value, birthDatePattern, new Date(2000, 0, 1)); if (!isValid(parsed) || format(parsed, birthDatePattern) !== value || parsed.getFullYear() < earliestBirthYear || parsed.getFullYear() > latestBirthYear) return undefined; return parsed; } export function normalizePersistedBirthDate(value: unknown): string { if (value instanceof Date) { return Number.isNaN(value.getTime()) ? "" : value.toISOString().slice(0, 10); } if (typeof value !== "string") return ""; const persisted = value.trim(); const date = persisted.slice(0, 10); return parseBirthDate(date) && (persisted.length === 10 || persisted[10] === "T") ? date : ""; } export function isBirthClockTime(value: string): boolean { return birthClockPattern.test(value); } export function formatBirthDate(value: Date): string { return format(value, birthDatePattern); } export const birthTimeSourceOptions = [ { value: "family_exact", label: "我知道准确出生时间", hint: "填写后直接用于当前分析,无需先做生时校正" }, { value: "period_only", label: "我不确定准确时间", hint: "告诉我们大致时段;完全不清楚也可以直接跳过" }, ] as const; export const birthTimePeriodOptions = [ { value: "early_morning", label: "凌晨 / 清晨(04:00—07:59)" }, { value: "morning", label: "上午(08:00—11:59)" }, { value: "afternoon", label: "下午(12:00—17:59)" }, { value: "evening", label: "晚上(18:00—22:59)" }, { value: "late_night", label: "深夜(23:00—03:59)" }, ] as const; export type BirthTimeDisplayState = { readonly kind: "candidate" | "accepted" | "confirmed"; readonly activeTime: string; readonly reportedLabel: string; }; function reportedBirthTimeLabel(draft: BirthTimeDraft): string { if (draft.birthTimeSource === "period_only") { return birthTimePeriodOptions.find((option) => option.value === draft.birthTimePeriod)?.label ?? "未选择时段"; } if (draft.birthTimeSource === "unknown") return "具体时间未知"; return draft.reportedTime || draft.time || "尚未填报"; } export function birthTimeDisplayState(draft: BirthTimeDraft): BirthTimeDisplayState | null { const kind = draft.birthTimeStatus; if (!draft.time || (kind !== "candidate" && kind !== "accepted" && kind !== "confirmed")) { return null; } return { kind, activeTime: draft.time, reportedLabel: reportedBirthTimeLabel(draft), }; } const periodLabels = { "": "未选择时段", early_morning: "凌晨或清晨", morning: "上午", afternoon: "下午", evening: "晚上", late_night: "深夜", } as const satisfies Record; const intentCopy = { confirm_stable_record: "医院记录前后两分钟内结构稳定,可以直接作为当前排盘时间。", explain_sensitive_boundary: "这个时间靠近敏感边界,需要先做轻量校正,暂不直接排盘。", explain_assessment_unavailable: "暂时无法完成稳定性检查,系统不会冒险应用这个具体时间。", start_light_rectification: "先用几个高区分度问题检查家人记忆范围。", start_standard_rectification: "这个误差范围内存在多个候选,需要先回答几个生活经历问题。", start_period_rectification: "已保留你知道的时段,接下来先做粗粒度候选筛选。", collect_time_clues: "不会要求你猜一个具体时间,先从家人线索和大致时段开始。", continue_rectification_questions: "已记录这条线索,还需要更多证据才能形成候选范围。", present_saved_candidate_range: "目前只能保存候选范围,还没有足够证据应用到具体分钟。", collect_dated_life_events: "选择题已经完成,请补充几条带日期的关键经历,用于比较实际候选时间。", explain_event_evidence_insufficient: "现有事件还不能稳定区分候选时间,可以调整日期或补充其他领域的经历。", present_candidate_result: "确定性评分已经形成候选范围,但当前证据只允许保存,不能直接应用。", confirm_candidate_time: "候选结果达到应用门槛,仍需你明确确认后才会成为当前排盘时间。", confirmed_candidate_time: "候选时间已按你的确认设为当前排盘使用时间。", } as const satisfies Record; export function assistantIntentCopy(intent: JourneySnapshot["assistantIntent"]) { return intentCopy[intent]; } export function isBirthTimeDraftReady(draft: BirthTimeDraft) { if (!parseBirthDate(draft.date) || draft.birthTimeClue.length > 240) return false; switch (draft.birthTimeSource) { case "hospital_record": return isBirthClockTime(draft.reportedTime) && draft.uncertaintyBeforeMinutes === 2 && draft.uncertaintyAfterMinutes === 2; case "legacy_import": return isBirthClockTime(draft.reportedTime || draft.time); case "family_exact": return isBirthClockTime(draft.reportedTime) && [5, 10, 15].includes(draft.uncertaintyBeforeMinutes ?? -1) && draft.uncertaintyBeforeMinutes === draft.uncertaintyAfterMinutes; case "approximate": return isBirthClockTime(draft.reportedTime) && [15, 30, 60].includes(draft.uncertaintyBeforeMinutes ?? -1) && draft.uncertaintyBeforeMinutes === draft.uncertaintyAfterMinutes; case "period_only": return birthTimePeriodOptions.some((option) => option.value === draft.birthTimePeriod) && !draft.reportedTime && draft.uncertaintyBeforeMinutes === null && draft.uncertaintyAfterMinutes === null; case "unknown": return !draft.reportedTime && !draft.birthTimePeriod && draft.uncertaintyBeforeMinutes === null && draft.uncertaintyAfterMinutes === null; case "": return false; default: { const exhaustive: never = draft.birthTimeSource; return exhaustive; } } } /** * Whether the user has finished declaring what they actually know about birth time. * This is an onboarding condition, not a claim that an exact chart minute is ready. */ export function isDeclaredBirthProfileComplete( draft: BirthTimeDraft, place?: DeclaredBirthPlace | null, ) { if (!isBirthTimeDraftReady(draft)) return false; if (place === undefined) return true; return Boolean(place && place.label.trim() && Number.isFinite(place.lat) && place.lat >= -90 && place.lat <= 90 && Number.isFinite(place.lon) && place.lon >= -180 && place.lon <= 180 && ((Number.isFinite(place.tz) && (place.tz as number) >= -12 && (place.tz as number) <= 14) || Boolean(place.timezoneId?.trim()))); } export function isBirthTimeReadyForConsultation(draft: BirthTimeDraft) { return isBirthClockTime(draft.time) && (draft.birthTimeStatus === "accepted" || draft.birthTimeStatus === "confirmed"); } const declaredBirthInputKeys = [ "date", "reportedTime", "birthTimeSource", "birthTimePeriod", "birthTimeClue", "uncertaintyBeforeMinutes", "uncertaintyAfterMinutes", ] as const satisfies readonly (keyof BirthTimeDraft)[]; export function declaredBirthInputChanged( current: BirthTimeDraft, next: BirthTimeDraft, ): boolean { return declaredBirthInputKeys.some((key) => current[key] !== next[key]); } /** * Applies an intake edit without allowing a stale, unconfirmed candidate minute * to survive changes to the declaration it was calculated from. * Engine-confirmed active time belongs to the account and is changed only by explicit * rectification confirmation. User-accepted time is invalidated when its birth declaration changes. */ export function applyBirthTimeDraftPatch( current: T, patch: BirthTimeDraftPatch, ): T { const next = { ...current, ...patch }; const declarationChanged = declaredBirthInputKeys.some((key) => ( Object.hasOwn(patch, key) && next[key] !== current[key] )); if (!declarationChanged || current.birthTimeStatus === "confirmed") return next; if (current.birthTimeStatus !== "candidate" && !current.time) return next; return { ...next, time: "", birthTimeStatus: "reported", } as T; } export function birthTimePersistenceValues(draft: BirthTimeDraft) { const reportedTime = draft.birthTimeSource === "legacy_import" ? draft.reportedTime || draft.time || null : draft.birthTimeSource === "hospital_record" || draft.birthTimeSource === "family_exact" || draft.birthTimeSource === "approximate" ? draft.reportedTime || null : null; const uncertainty = draft.birthTimeSource === "hospital_record" ? 2 : draft.birthTimeSource === "family_exact" || draft.birthTimeSource === "approximate" ? draft.uncertaintyBeforeMinutes : null; return { reported_birth_time: reportedTime, birth_time_source: draft.birthTimeSource || null, birth_time_period: draft.birthTimePeriod || null, birth_time_clue: draft.birthTimeClue.trim() || null, uncertainty_before_minutes: uncertainty, uncertainty_after_minutes: uncertainty, }; } export function describeBirthTimeDraft(draft: BirthTimeDraft) { const [year, month, day] = draft.date.split("-").map(Number); const date = `${year}年${month}月${day}日`; switch (draft.birthTimeSource) { case "hospital_record": return `${date} ${draft.reportedTime}(医院记录)`; case "family_exact": return `${date} ${draft.reportedTime}(填报准确时间)`; case "approximate": return `${date},约 ${draft.reportedTime}(前后 ${draft.uncertaintyBeforeMinutes} 分钟)`; case "period_only": return `${date},${periodLabels[draft.birthTimePeriod]}${draft.birthTimeClue.trim() ? `(${draft.birthTimeClue.trim()})` : ""}`; case "unknown": return `${date},具体时间未知`; case "legacy_import": return `${date} ${draft.reportedTime || draft.time}(既有已确认资料)`; case "": return date; default: { const exhaustive: never = draft.birthTimeSource; return exhaustive; } } }