feat: make birth-time rectification a soft homepage flow

This commit is contained in:
Jesse_Chen
2026-07-21 06:28:47 +08:00
parent 5c8d965f16
commit fc1e9f1085
11 changed files with 727 additions and 115 deletions
@@ -0,0 +1,96 @@
import type { BirthTimeDraft } from "./birth-time-intake-model.ts";
export type BirthTimeConsultationConsentState = Readonly<Record<string, true>>;
export type AccountRectificationCaseState = Readonly<{
caseId: string;
journeyProtocol: "conversational-evidence-v3";
status: "starting" | "active" | "paused" | "confirming" | "completed" | "abandoned";
turnVersion: number;
isRevision: boolean;
preservesActiveTime: boolean;
}>;
export type RectificationCardAction = "start" | "resume" | "revise";
const concreteReportedSources = new Set<BirthTimeDraft["birthTimeSource"]>([
"hospital_record",
"family_exact",
"approximate",
]);
const unfinishedRectificationStatuses = new Set<AccountRectificationCaseState["status"]>([
"starting",
"active",
"paused",
"confirming",
]);
export function createBirthTimeConsultationConsentState(): BirthTimeConsultationConsentState {
return Object.freeze({});
}
export function hasBirthTimeConsultationConsent(
state: BirthTimeConsultationConsentState,
sessionId: string,
): boolean {
return Boolean(sessionId && state[sessionId] === true);
}
export function grantBirthTimeConsultationConsent(
state: BirthTimeConsultationConsentState,
sessionId: string,
): BirthTimeConsultationConsentState {
if (!sessionId || state[sessionId]) return state;
return Object.freeze({ ...state, [sessionId]: true });
}
export function clearBirthTimeConsultationConsent(
state: BirthTimeConsultationConsentState,
sessionId: string,
): BirthTimeConsultationConsentState {
if (!sessionId || !state[sessionId]) return state;
return Object.freeze(Object.fromEntries(
Object.entries(state).filter(([candidate]) => candidate !== sessionId),
) as Record<string, true>);
}
export function unverifiedBirthTime(profile: BirthTimeDraft): string | null {
if (profile.birthTimeStatus === "confirmed") return null;
if (!concreteReportedSources.has(profile.birthTimeSource)) return null;
const time = profile.time || profile.reportedTime;
return /^([01]\d|2[0-3]):[0-5]\d$/.test(time) ? time : null;
}
export function canUseUnverifiedBirthTime(profile: BirthTimeDraft): boolean {
return unverifiedBirthTime(profile) !== null;
}
export function requiresBirthTimeConsent(profile: BirthTimeDraft): boolean {
return canUseUnverifiedBirthTime(profile);
}
export function resolveRectificationCardAction(input: Readonly<{
rectificationCase: AccountRectificationCaseState | null;
hasConfirmedBirthTime: boolean;
}>): RectificationCardAction {
if (input.rectificationCase
&& unfinishedRectificationStatuses.has(input.rectificationCase.status)) {
return "resume";
}
if (input.hasConfirmedBirthTime) return "revise";
return "start";
}
export function parseRectificationPriceCredits(raw: string | undefined): number {
if (raw === undefined) return 1;
const normalized = raw.trim();
if (!/^\d+$/.test(normalized)) {
throw new Error("RECTIFICATION_PRICE_CREDITS must be an integer from 1 through 100");
}
const price = Number(normalized);
if (!Number.isSafeInteger(price) || price < 1 || price > 100) {
throw new Error("RECTIFICATION_PRICE_CREDITS must be an integer from 1 through 100");
}
return price;
}
@@ -152,6 +152,14 @@ export function isBirthTimeDraftReady(draft: BirthTimeDraft) {
}
}
/**
* 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) {
return isBirthTimeDraftReady(draft);
}
export function isBirthTimeReadyForConsultation(draft: BirthTimeDraft) {
return Boolean(draft.time)
&& (draft.birthTimeStatus === "candidate" || draft.birthTimeStatus === "confirmed");