Files
Jyotisha/frontend/src/lib/birth-time-intake-model.ts
T
Jesse_Chen 8ed6118b9a fix(web): persist uncertain birth time as a start/end clock window
Users pick a clock range instead of a coarse period plus notes, so
rectification and window consult scan that range instead of a leftover afternoon bucket.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 20:48:01 +08:00

436 lines
15 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.
import { format, isValid, parse } from "date-fns";
import type { JourneySnapshot } from "./birth-time-journey.ts";
import {
declaredClockRange,
matchingDeclaredPeriod,
} from "./declared-birth-window.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 declaredWindowStart: string;
readonly declaredWindowEnd: string;
readonly birthTimeClue: string;
readonly uncertaintyBeforeMinutes: number | null;
readonly uncertaintyAfterMinutes: number | null;
readonly birthTimeStatus: BirthTimeStatus;
};
export type BirthTimeDraftPatch = Partial<BirthTimeDraft>;
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 birthTimeSourceDefaults = {
hospital_record: {
birthTimePeriod: "",
declaredWindowStart: "",
declaredWindowEnd: "",
birthTimeClue: "",
uncertaintyBeforeMinutes: 2,
uncertaintyAfterMinutes: 2,
},
family_exact: {
birthTimePeriod: "",
declaredWindowStart: "",
declaredWindowEnd: "",
birthTimeClue: "",
uncertaintyBeforeMinutes: 0,
uncertaintyAfterMinutes: 0,
},
approximate: {
birthTimePeriod: "",
declaredWindowStart: "",
declaredWindowEnd: "",
birthTimeClue: "",
uncertaintyBeforeMinutes: 30,
uncertaintyAfterMinutes: 30,
},
period_only: {
reportedTime: "",
birthTimePeriod: "",
declaredWindowStart: "",
declaredWindowEnd: "",
birthTimeClue: "",
uncertaintyBeforeMinutes: null,
uncertaintyAfterMinutes: null,
},
unknown: {
reportedTime: "",
birthTimePeriod: "",
declaredWindowStart: "",
declaredWindowEnd: "",
uncertaintyBeforeMinutes: null,
uncertaintyAfterMinutes: null,
},
} as const satisfies Record<Exclude<BirthTimeSource, "" | "legacy_import">, BirthTimeDraftPatch>;
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") {
const range = declaredWindowRangeForDraft(draft);
return range ? range.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 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<JourneySnapshot["assistantIntent"], string>;
export function assistantIntentCopy(intent: JourneySnapshot["assistantIntent"]) {
return intentCopy[intent];
}
export function declaredWindowRangeForDraft(draft: Pick<BirthTimeDraft, "birthTimeSource" | "birthTimePeriod" | "declaredWindowStart" | "declaredWindowEnd">) {
return declaredClockRange({
source: draft.birthTimeSource || "period_only",
period: draft.birthTimePeriod || null,
startTime: draft.declaredWindowStart || null,
endTime: draft.declaredWindowEnd || null,
});
}
export function hydrateDeclaredWindowDraft(input: {
readonly period?: string | null;
readonly start?: string | null;
readonly end?: string | null;
}): Pick<BirthTimeDraft, "birthTimePeriod" | "declaredWindowStart" | "declaredWindowEnd"> {
const period = birthTimePeriodOptions.some((option) => option.value === input.period)
? input.period as BirthTimePeriod
: "";
const range = declaredClockRange({
source: "period_only",
period: period || null,
startTime: input.start,
endTime: input.end,
});
return {
birthTimePeriod: range
? matchingDeclaredPeriod(range.startTime, range.endTime) ?? ""
: period,
declaredWindowStart: range?.startTime ?? "",
declaredWindowEnd: range?.endTime ?? "",
};
}
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)
&& draft.uncertaintyBeforeMinutes === 0
&& draft.uncertaintyAfterMinutes === 0;
case "approximate":
return isBirthClockTime(draft.reportedTime)
&& (draft.uncertaintyBeforeMinutes === null
|| [15, 30, 60].includes(draft.uncertaintyBeforeMinutes))
&& (draft.uncertaintyAfterMinutes === null
|| draft.uncertaintyBeforeMinutes === draft.uncertaintyAfterMinutes);
case "period_only":
return Boolean(declaredWindowRangeForDraft(draft))
&& !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;
}
}
}
export function birthTimeDraftReadyHint(draft: BirthTimeDraft) {
if (!parseBirthDate(draft.date)) return "请先选择出生日期";
if (!draft.birthTimeSource) return "请选择你对出生时间了解多少";
if (isBirthTimeDraftReady(draft)) return "";
if (draft.birthTimeSource === "period_only") {
if (draft.declaredWindowStart && draft.declaredWindowEnd
&& draft.declaredWindowStart === draft.declaredWindowEnd) {
return "开始和结束不能是同一分钟";
}
return "请选择开始和结束时间";
}
if (draft.birthTimeSource === "unknown") return "";
return "请填写时和分";
}
/**
* 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",
"declaredWindowStart",
"declaredWindowEnd",
"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<T extends BirthTimeDraft>(
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"
? 0
: draft.birthTimeSource === "approximate"
? draft.uncertaintyBeforeMinutes ?? birthTimeSourceDefaults.approximate.uncertaintyBeforeMinutes
: null;
const declaredRange = draft.birthTimeSource === "period_only"
? declaredWindowRangeForDraft(draft)
: null;
const matchedPeriod = declaredRange
? matchingDeclaredPeriod(declaredRange.startTime, declaredRange.endTime)
: null;
return {
reported_birth_time: reportedTime,
birth_time_source: draft.birthTimeSource || null,
birth_time_period: matchedPeriod,
declared_window_start: declaredRange?.startTime ?? null,
declared_window_end: declaredRange?.endTime ?? null,
birth_time_clue: null,
uncertainty_before_minutes: uncertainty,
uncertainty_after_minutes: uncertainty,
};
}
const persistedBirthTimeStatuses = [
"reported", "assessing", "rectifying", "candidate", "accepted", "confirmed",
] as const satisfies readonly Exclude<BirthTimeStatus, "">[];
/**
* Reconciles a submitted declaration with the birth-time truth the account write
* returned. The server decides whether a declaration is already usable as the
* active minute, so consultation mode must never be derived from the draft alone.
*/
export function applyPersistedBirthTime<T extends BirthTimeDraft>(
draft: T,
applied: unknown,
): T {
if (applied === null || typeof applied !== "object") return draft;
const { status, activeTime } = applied as { status?: unknown; activeTime?: unknown };
const persistedStatus = persistedBirthTimeStatuses.find((candidate) => candidate === status);
if (!persistedStatus) return draft;
const clock = typeof activeTime === "string" ? activeTime.slice(0, 5) : "";
return {
...draft,
time: isBirthClockTime(clock) ? clock : "",
birthTimeStatus: persistedStatus,
};
}
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}`;
case "period_only": {
const range = declaredWindowRangeForDraft(draft);
return `${date}${range ? range.label : "未选择时间范围"}`;
}
case "unknown":
return `${date},具体时间未知`;
case "legacy_import":
return `${date} ${draft.reportedTime || draft.time}(既有已确认资料)`;
case "":
return date;
default: {
const exhaustive: never = draft.birthTimeSource;
return exhaustive;
}
}
}