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>
This commit is contained in:
Jesse_Chen
2026-08-22 20:48:01 +08:00
co-authored by Cursor
parent f3197ce3f6
commit 8ed6118b9a
31 changed files with 760 additions and 144 deletions
+16
View File
@@ -5339,3 +5339,19 @@
- 复发自:无
- 修复版本:97620634
## BUG-353 | 不确定时间只扫五档时段,补充描述里的钟点纠正用不上
- 状态:resolved
- 首次发现:2026-08-22
- 最近更新:2026-08-22
- 影响面:出生时间表单、`profiles.declared_window_start/end`、V9 Case 候选窗、普通咨询 declared window、账户 PATCH
- 用户现象:用户已把不确定时间改成 14:30–15:00,或写在补充描述里,生时纠正和 Agent 仍按下午 12:00–17:59 扫描。
- 触发条件:`birth_time_source=period_only` 且用户记得的是一段钟点而不是五档粗时段;旧资料把钟点写在 `birth_time_clue`。
- 根因:`period_only` 只把 `birth_time_period` 映射成五档固定窗。`birth_time_clue` 不进 Case fingerprint、不进 `candidate_range`、也不被解析。Case 窗在创建时冻结,聊天工具不能改。正则解析备注会把“6—8 点”这类歧义话当成扫描分钟。
- 修复:不确定路径改为开始/结束钟点,不再收集备注。保存 `declared_window_start/end`;自定义钟点优先于五档时段;开始结束相同则拒绝;允许跨午夜。旧 `afternoon` 等行回填对应钟点,用户可以收紧到 14:30–15:00。收紧后 fingerprint 变化,首页开新 Case。不解析 clue,保存时把 clue 写成 null。
- 验证:`frontend/tests/birth-time-intake.test.ts` 锁定开始结束表单、14:30–15:00 持久化且不留下 afternoon 桶;`frontend/tests/declared-birth-window.test.ts` 锁定自定义钟点覆盖 afternoon;`frontend/tests/rectification-v9-case-service.test.ts` 锁定 Case 开窗 14:30–15:00 且 fingerprint 变化;`frontend/tests/consultation-route-service.test.ts` 锁定咨询窗;`frontend/tests/account-api.test.ts` 锁定 PATCH 接受开始结束、拒绝同一分钟。
- 防复发:不得把备注正则解析成扫描窗。不得只扫五档时段而丢掉用户选择的开始结束。不得把自定义窗的中点写成 `reported_birth_time`。改窗必须开新 Case,旧会话继续展示冻结窗。
- 相关记录:BUG-197、BUG-198
- 复发自:BUG-197(不确定入口恢复后仍用五档时段加备注,备注从未进入扫描窗)
- 修复版本:待提交
+93 -29
View File
@@ -49,26 +49,43 @@ export async function GET() {
const admin = createAdminSupabaseClient();
let { data: profile, error: profileError } = await supabase
.from("profiles")
.select("credits,active_birth_time,birth_time_status,birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source,name,birth_time,rectification_case_id")
.select("credits,active_birth_time,birth_time_status,birth_date,reported_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source,name,birth_time,rectification_case_id")
.eq("id", userId)
.single();
if (profileError && isMissingProfileColumn(profileError)) {
const fallback = await supabase
const withoutDeclaredWindow = await supabase
.from("profiles")
.select("credits,active_birth_time,birth_time_status,birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,name,birth_time,rectification_case_id")
.select("credits,active_birth_time,birth_time_status,birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source,name,birth_time,rectification_case_id")
.eq("id", userId)
.single();
profile = fallback.data ? {
...fallback.data,
birth_place_label: undefined,
birth_place_type: undefined,
birth_place_provider: undefined,
birth_place_provider_id: undefined,
timezone_id: undefined,
timezone_source: undefined,
} : null;
profileError = fallback.error;
if (!withoutDeclaredWindow.error && withoutDeclaredWindow.data) {
profile = {
...withoutDeclaredWindow.data,
declared_window_start: undefined,
declared_window_end: undefined,
};
profileError = withoutDeclaredWindow.error;
} else if (withoutDeclaredWindow.error && isMissingProfileColumn(withoutDeclaredWindow.error)) {
const fallback = await supabase
.from("profiles")
.select("credits,active_birth_time,birth_time_status,birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,name,birth_time,rectification_case_id")
.eq("id", userId)
.single();
profile = fallback.data ? {
...fallback.data,
birth_place_label: undefined,
birth_place_type: undefined,
birth_place_provider: undefined,
birth_place_provider_id: undefined,
timezone_id: undefined,
timezone_source: undefined,
} : null;
profileError = fallback.error;
} else {
profile = withoutDeclaredWindow.data;
profileError = withoutDeclaredWindow.error;
}
}
if (profileError || !profile) {
@@ -170,28 +187,45 @@ export async function PATCH(request: Request) {
const admin = createAdminSupabaseClient();
let { data: currentProfile, error: currentProfileError } = await admin
.from("profiles")
.select("birth_date,birth_time,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,birth_time_status,rectification_case_id,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source")
.select("birth_date,birth_time,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,birth_time_status,rectification_case_id,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source")
.eq("id", userId)
.maybeSingle();
if (currentProfileError && isMissingProfileColumn(currentProfileError)) {
const fallback = await admin
const withoutDeclaredWindow = await admin
.from("profiles")
.select("birth_date,birth_time,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,birth_time_status,rectification_case_id,country_code,province_code,city_code,district_code")
.select("birth_date,birth_time,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,birth_time_status,rectification_case_id,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source")
.eq("id", userId)
.maybeSingle();
currentProfile = fallback.data ? {
...fallback.data,
latitude: undefined,
longitude: undefined,
timezone_offset: undefined,
birth_place_label: undefined,
birth_place_type: undefined,
birth_place_provider: undefined,
birth_place_provider_id: undefined,
timezone_id: undefined,
timezone_source: undefined,
} : null;
currentProfileError = fallback.error;
if (!withoutDeclaredWindow.error && withoutDeclaredWindow.data) {
currentProfile = {
...withoutDeclaredWindow.data,
declared_window_start: undefined,
declared_window_end: undefined,
};
currentProfileError = withoutDeclaredWindow.error;
} else if (withoutDeclaredWindow.error && isMissingProfileColumn(withoutDeclaredWindow.error)) {
const fallback = await admin
.from("profiles")
.select("birth_date,birth_time,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,birth_time_status,rectification_case_id,country_code,province_code,city_code,district_code")
.eq("id", userId)
.maybeSingle();
currentProfile = fallback.data ? {
...fallback.data,
latitude: undefined,
longitude: undefined,
timezone_offset: undefined,
birth_place_label: undefined,
birth_place_type: undefined,
birth_place_provider: undefined,
birth_place_provider_id: undefined,
timezone_id: undefined,
timezone_source: undefined,
} : null;
currentProfileError = fallback.error;
} else {
currentProfile = withoutDeclaredWindow.data;
currentProfileError = withoutDeclaredWindow.error;
}
}
if (currentProfileError) {
return NextResponse.json({ error: "暂时无法核对现有出生资料" }, { status: 500 });
@@ -210,6 +244,12 @@ export async function PATCH(request: Request) {
...(payload.birth_time_period !== undefined
? { birth_time_period: payload.birth_time_period }
: {}),
...(payload.declared_window_start !== undefined
? { declared_window_start: payload.declared_window_start }
: {}),
...(payload.declared_window_end !== undefined
? { declared_window_end: payload.declared_window_end }
: {}),
...(payload.birth_time_clue !== undefined
? { birth_time_clue: payload.birth_time_clue }
: {}),
@@ -261,6 +301,30 @@ export async function PATCH(request: Request) {
data = fallback.data;
error = fallback.error;
}
if (error && isMissingProfileColumn(error)) {
const {
declared_window_start: _start,
declared_window_end: _end,
...withoutDeclaredWindow
} = withCoordinates;
void _start;
void _end;
const fallback = await writeProfile(withoutDeclaredWindow);
data = fallback.data;
error = fallback.error;
}
if (error && isMissingProfileColumn(error)) {
const {
declared_window_start: _start,
declared_window_end: _end,
...withoutCoordinatesOrWindow
} = withoutCoordinates;
void _start;
void _end;
const fallback = await writeProfile(withoutCoordinatesOrWindow);
data = fallback.data;
error = fallback.error;
}
if (!error && !data && invalidatesUnconfirmedApplication) {
return NextResponse.json({
+1 -1
View File
@@ -341,7 +341,7 @@ export async function POST(request: Request) {
async loadProfile(profileUserId) {
const { data, error } = await supabase
.from("profiles")
.select("name,birth_date,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,birth_time_status,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source")
.select("name,birth_date,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,birth_time_status,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source")
.eq("id", profileUserId)
.single();
if (error || !data) throw new ConsultationProfileTruthError("profile_unavailable");
+1 -1
View File
@@ -20,7 +20,7 @@ function createProfileRepository(
async loadProfile(userId) {
return admin
.from("profiles")
.select("id,name,birth_date,birth_time,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,birth_time_status,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source,onboarding_payload,onboarding_version,onboarding_generated_at")
.select("id,name,birth_date,birth_time,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,birth_time_status,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source,onboarding_payload,onboarding_version,onboarding_generated_at")
.eq("id", userId)
.maybeSingle();
},
+12 -5
View File
@@ -2050,8 +2050,17 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.birth-time-source-option b { font-size: var(--type-body-sm); line-height: 1.35; }
.birth-time-source-option small { font-size: var(--type-caption); line-height: 1.5; }
.birth-time-detail-grid { grid-template-columns: minmax(0, .9fr) minmax(0, 1.1fr); gap: var(--space-3); padding: var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas-soft); }
.birth-time-period-details { align-items: start; }
.birth-time-period-details > label { align-self: start; }
.birth-time-window-details { align-items: start; }
.birth-time-window-details > label { align-self: start; }
.birth-time-window-intro {
grid-column: 1 / -1;
margin: 0;
color: var(--color-ink-secondary);
font-size: var(--type-caption);
line-height: 1.5;
}
.birth-time-window-details .birth-time-skip-button { grid-column: 1 / -1; justify-self: start; min-height: 40px; padding-inline: 13px; border-color: transparent; background: transparent; color: var(--color-action); font-size: var(--type-caption); }
.birth-time-window-details .birth-time-skip-button:hover { background: color-mix(in srgb, var(--color-action-soft) 72%, transparent); }
.birth-time-detail-grid > label { gap: 7px; }
.birth-time-detail-grid > label > span { color: var(--color-ink-secondary); font-size: var(--type-caption); font-weight: 700; }
.birth-time-detail-grid textarea {
@@ -2081,8 +2090,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.birth-time-detail-grid textarea::-webkit-resizer {
background: color-mix(in srgb, var(--color-border-strong) 70%, transparent);
}
.birth-time-period-details .birth-time-skip-button { grid-column: 1 / -1; justify-self: start; min-height: 40px; padding-inline: 13px; border-color: transparent; background: transparent; color: var(--color-action); font-size: var(--type-caption); }
.birth-time-period-details .birth-time-skip-button:hover { background: color-mix(in srgb, var(--color-action-soft) 72%, transparent); }
.birth-time-detail-note[role="status"] { display: grid; gap: var(--space-3); padding: var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas-soft); }
.birth-time-detail-note[role="status"] p { margin: 0; }
.onboarding-card-actions { align-items: center; justify-content: space-between; gap: var(--space-4); padding-top: var(--space-2); }
@@ -2091,7 +2098,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
@media (max-width: 620px) {
.onboarding-card-heading { align-items: flex-start; flex-direction: column; gap: var(--space-2); }
.birth-time-source-list, .birth-time-detail-grid { grid-template-columns: 1fr; }
.birth-time-period-details .birth-time-skip-button { grid-column: auto; justify-self: stretch; }
.birth-time-window-details .birth-time-skip-button { grid-column: auto; justify-self: stretch; }
.onboarding-card-actions { align-items: stretch; flex-direction: column; }
.onboarding-card-actions .button-primary { width: 100%; }
}
+19 -3
View File
@@ -61,6 +61,7 @@ import {
birthTimePersistenceValues,
declaredBirthInputChanged,
describeBirthTimeDraft,
hydrateDeclaredWindowDraft,
isDeclaredBirthProfileComplete,
isBirthTimeDraftReady,
birthTimeDraftReadyHint,
@@ -358,6 +359,8 @@ const emptyProfile: Profile = {
reportedTime: "",
birthTimeSource: "",
birthTimePeriod: "",
declaredWindowStart: "",
declaredWindowEnd: "",
birthTimeClue: "",
uncertaintyBeforeMinutes: null,
uncertaintyAfterMinutes: null,
@@ -659,6 +662,8 @@ function readProfile(value: unknown): Profile {
active_birth_time?: unknown;
birth_time_source?: unknown;
birth_time_period?: unknown;
declared_window_start?: unknown;
declared_window_end?: unknown;
birth_time_clue?: unknown;
uncertainty_before_minutes?: unknown;
uncertainty_after_minutes?: unknown;
@@ -696,6 +701,13 @@ function readProfile(value: unknown): Profile {
const reportedTime = persistedReportedTime || (source === "legacy_import" ? time : "");
const knownPeriods = ["early_morning", "morning", "afternoon", "evening", "late_night"] as const;
const period = knownPeriods.find((item) => item === profile.birth_time_period) ?? "";
const windowStart = typeof profile.declared_window_start === "string" ? profile.declared_window_start.slice(0, 5) : "";
const windowEnd = typeof profile.declared_window_end === "string" ? profile.declared_window_end.slice(0, 5) : "";
const declaredWindow = hydrateDeclaredWindowDraft({
period,
start: windowStart,
end: windowEnd,
});
const knownStatuses = ["reported", "assessing", "rectifying", "candidate", "accepted", "confirmed"] as const;
const status = knownStatuses.find((item) => item === profile.birth_time_status)
?? (time ? "confirmed" : "");
@@ -723,8 +735,10 @@ function readProfile(value: unknown): Profile {
time: typeof time === "string" ? time : "",
reportedTime: typeof reportedTime === "string" ? reportedTime : "",
birthTimeSource: source,
birthTimePeriod: period,
birthTimeClue: typeof profile.birth_time_clue === "string" ? profile.birth_time_clue.slice(0, 240) : "",
birthTimePeriod: declaredWindow.birthTimePeriod,
declaredWindowStart: declaredWindow.declaredWindowStart,
declaredWindowEnd: declaredWindow.declaredWindowEnd,
birthTimeClue: "",
uncertaintyBeforeMinutes: typeof profile.uncertainty_before_minutes === "number" ? profile.uncertainty_before_minutes : null,
uncertaintyAfterMinutes: typeof profile.uncertainty_after_minutes === "number" ? profile.uncertainty_after_minutes : null,
birthTimeStatus: status,
@@ -1475,7 +1489,9 @@ export default function Home() {
time: isCompletedCandidatePreview ? "04:53" : isRectificationPreview || isAssessmentLoadingPreview ? "" : "12:30",
reportedTime: isRectificationPreview ? "14:30" : isAssessmentLoadingPreview || isCompletedCandidatePreview ? "" : "12:30",
birthTimeSource: isRectificationPreview ? "approximate" : isAssessmentLoadingPreview || isCompletedCandidatePreview ? "period_only" : "legacy_import",
birthTimePeriod: isAssessmentLoadingPreview || isCompletedCandidatePreview ? "early_morning" : "",
...hydrateDeclaredWindowDraft({
period: isAssessmentLoadingPreview || isCompletedCandidatePreview ? "early_morning" : "",
}),
birthTimeClue: "",
uncertaintyBeforeMinutes: isRectificationPreview ? 30 : null,
uncertaintyAfterMinutes: isRectificationPreview ? 30 : null,
+35 -33
View File
@@ -6,7 +6,6 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { birthTimeConsultationOptionsCopy } from "@/lib/birth-time-consultation-consent";
import {
birthTimeDisplayState,
birthTimePeriodOptions,
birthTimeSourceDefaults,
birthTimeSourceOptions,
type BirthTimeDraft,
@@ -30,9 +29,18 @@ function splitBirthTime(value: string) {
type BirthClockSelectProps = {
readonly value: string;
readonly onChange: (value: string) => void;
readonly ariaLabel?: string;
readonly hourLabel?: string;
readonly minuteLabel?: string;
};
function BirthClockSelect({ value, onChange }: BirthClockSelectProps) {
function BirthClockSelect({
value,
onChange,
ariaLabel = "选择出生时间",
hourLabel = "出生时间小时",
minuteLabel = "出生时间分钟",
}: BirthClockSelectProps) {
const currentTime = value || "";
const [timeParts, setTimeParts] = useState(() => splitBirthTime(currentTime));
@@ -44,9 +52,9 @@ function BirthClockSelect({ value, onChange }: BirthClockSelectProps) {
};
return (
<div className="birth-time-clock-selects" aria-label="选择出生时间">
<div className="birth-time-clock-selects" aria-label={ariaLabel}>
<Select required value={timeParts.hour || null} onValueChange={(nextValue) => patchTime("hour", nextValue)}>
<SelectTrigger aria-label="出生时间小时">
<SelectTrigger aria-label={hourLabel}>
<SelectValue placeholder="时" />
</SelectTrigger>
<SelectContent className="birth-time-clock-menu" style={{ width: 108, minWidth: 108 }}>
@@ -55,7 +63,7 @@ function BirthClockSelect({ value, onChange }: BirthClockSelectProps) {
</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="出生时间分钟">
<SelectTrigger aria-label={minuteLabel}>
<SelectValue placeholder="分" />
</SelectTrigger>
<SelectContent className="birth-time-clock-menu" style={{ width: 108, minWidth: 108 }}>
@@ -168,36 +176,28 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)
)}
{source === "period_only" && (
<div className="birth-time-detail-grid birth-time-period-details onboarding-card-reveal">
<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>
<Select
required
value={value.birthTimePeriod || null}
onValueChange={(nextValue) => {
if (typeof nextValue === "string") onPatch({ birthTimePeriod: nextValue });
}}
>
<SelectTrigger aria-label="最接近的时间范围">
<SelectValue placeholder="请选择大致时段">
{(selectedValue) => birthTimePeriodOptions.find((option) => option.value === selectedValue)?.label ?? "请选择大致时段"}
</SelectValue>
</SelectTrigger>
<SelectContent>
{birthTimePeriodOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
<span>开始</span>
<BirthClockSelect
key={`start-${value.declaredWindowStart}`}
value={value.declaredWindowStart}
ariaLabel="选择开始时间"
hourLabel="开始小时"
minuteLabel="开始分钟"
onChange={(declaredWindowStart) => onPatch({ declaredWindowStart })}
/>
</label>
<label>
<span>补充描述(可选)</span>
<textarea
maxLength={240}
placeholder="例如:天刚亮、午饭前后、家人记得大约 6—8 点"
rows={2}
value={value.birthTimeClue}
onChange={(event) => onPatch({ birthTimeClue: event.target.value })}
<span>结束</span>
<BirthClockSelect
key={`end-${value.declaredWindowEnd}`}
value={value.declaredWindowEnd}
ariaLabel="选择结束时间"
hourLabel="结束小时"
minuteLabel="结束分钟"
onChange={(declaredWindowEnd) => onPatch({ declaredWindowEnd })}
/>
</label>
<button
@@ -207,6 +207,8 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)
birthTimeSource: "unknown",
reportedTime: "",
birthTimePeriod: "",
declaredWindowStart: "",
declaredWindowEnd: "",
birthTimeClue: "",
uncertaintyBeforeMinutes: null,
uncertaintyAfterMinutes: null,
@@ -224,7 +226,7 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)
className="button-secondary"
type="button"
onClick={() => onPatch({ birthTimeSource: "period_only", birthTimeStatus: "reported" })}
>我可以描述一个时间范围</button>
>我可以选一段时间范围</button>
</div>
)}
</div>
+31 -2
View File
@@ -34,6 +34,8 @@ export const accountProfilePatchSchema = z.object({
reported_birth_time: nullableBirthClock.optional(),
birth_time_source: birthTimeSourceSchema.nullable().optional(),
birth_time_period: birthTimePeriodSchema.nullable().optional(),
declared_window_start: nullableBirthClock.optional(),
declared_window_end: nullableBirthClock.optional(),
birth_time_clue: nullableTrimmedString(240).optional(),
uncertainty_before_minutes: z.number().int().min(0).max(720).nullable().optional(),
uncertainty_after_minutes: z.number().int().min(0).max(720).nullable().optional(),
@@ -66,6 +68,8 @@ export const accountProfilePatchSchema = z.object({
"reported_birth_time",
"birth_time_source",
"birth_time_period",
"declared_window_start",
"declared_window_end",
"birth_time_clue",
"uncertainty_before_minutes",
"uncertainty_after_minutes",
@@ -95,7 +99,8 @@ export const accountProfilePatchSchema = z.object({
if (value.birth_date !== undefined && value.birth_date !== null) {
addIssue("birth_time_source", "填写出生日期后必须说明时间来源");
}
if (time || value.birth_time || value.birth_time_period || value.birth_time_clue
if (time || value.birth_time || value.birth_time_period || value.declared_window_start
|| value.declared_window_end || value.birth_time_clue
|| before != null || after != null) {
addIssue("birth_time_source", "未选择时间来源时不得提交时间或误差范围");
}
@@ -109,6 +114,11 @@ export const accountProfilePatchSchema = z.object({
const ensureNoPeriod = () => {
if (value.birth_time_period) addIssue("birth_time_period", "具体时间来源不得同时提交时段");
};
const ensureNoDeclaredWindow = () => {
if (value.declared_window_start || value.declared_window_end) {
addIssue("declared_window_start", "具体时间来源不得同时提交开始结束范围");
}
};
const ensureNoUncertainty = () => {
if (before != null || after != null) {
addIssue("uncertainty_before_minutes", "该时间来源不得提交误差范围");
@@ -119,29 +129,44 @@ export const accountProfilePatchSchema = z.object({
if (!time) addIssue("reported_birth_time", "医院记录需要具体时间");
if (before !== 2 || after !== 2) addIssue("uncertainty_before_minutes", "医院记录固定检查前后 2 分钟");
ensureNoPeriod();
ensureNoDeclaredWindow();
} else if (source === "family_exact") {
if (!time) addIssue("reported_birth_time", "家人记忆需要具体时间");
if (![0, 5, 10, 15].includes(before ?? -1) || before !== after) {
addIssue("uncertainty_before_minutes", "准确时间填报必须前后一致");
}
ensureNoPeriod();
ensureNoDeclaredWindow();
} else if (source === "approximate") {
if (!time) addIssue("reported_birth_time", "大概时间需要具体 HH:mm");
if (![15, 30, 60].includes(before ?? -1) || before !== after) {
addIssue("uncertainty_before_minutes", "大概时间误差必须为前后 15、30 或 60 分钟");
}
ensureNoPeriod();
ensureNoDeclaredWindow();
} else if (source === "legacy_import") {
if (!time && !value.birth_time) addIssue("reported_birth_time", "既有资料需要具体时间");
ensureNoPeriod();
ensureNoDeclaredWindow();
ensureNoUncertainty();
} else if (source === "period_only") {
if (!value.birth_time_period) addIssue("birth_time_period", "只知道时段时必须选择时段");
const start = value.declared_window_start;
const end = value.declared_window_end;
const hasWindow = Boolean(start && end && start !== end);
if (!hasWindow && !value.birth_time_period) {
addIssue("declared_window_start", "不确定准确时间时必须选择开始和结束时间");
}
if ((start && !end) || (!start && end) || (start && end && start === end)) {
addIssue("declared_window_end", "开始和结束必须是不同的钟点");
}
if (time) addIssue("reported_birth_time", "只知道时段时不得同时提交具体分钟");
ensureNoUncertainty();
} else if (source === "unknown") {
if (time) addIssue("reported_birth_time", "时间未知时不得提交具体分钟");
if (value.birth_time_period) addIssue("birth_time_period", "时间未知时不得提交确定时段");
if (value.declared_window_start || value.declared_window_end) {
addIssue("declared_window_start", "时间未知时不得提交开始结束范围");
}
ensureNoUncertainty();
}
@@ -154,6 +179,8 @@ type AccountBirthTimeState = Readonly<{
reported_birth_time: string | null;
birth_time_source: string | null;
birth_time_period: string | null;
declared_window_start?: string | null;
declared_window_end?: string | null;
birth_time_clue: string | null;
uncertainty_before_minutes: number | null;
uncertainty_after_minutes: number | null;
@@ -181,6 +208,8 @@ const declarationFields = [
"reported_birth_time",
"birth_time_source",
"birth_time_period",
"declared_window_start",
"declared_window_end",
"birth_time_clue",
"uncertainty_before_minutes",
"uncertainty_after_minutes",
@@ -46,6 +46,8 @@ export function birthProfileReferenceTime(value: unknown, preferredTime?: string
const direct = text(profile ? valueFor(profile, "birth_time", "time") : null)?.slice(0, 5);
if (direct && /^([01]\d|2[0-3]):[0-5]\d$/.test(direct)) return direct;
const period = text(profile ? valueFor(profile, "birth_time_period", "birthTimePeriod") : null);
const windowStart = text(profile ? valueFor(profile, "declared_window_start", "declaredWindowStart") : null)?.slice(0, 5);
if (windowStart && /^([01]\d|2[0-3]):[0-5]\d$/.test(windowStart)) return windowStart;
return {
early_morning: "06:00",
morning: "10:00",
+76 -18
View File
@@ -1,5 +1,9 @@
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$/;
@@ -38,6 +42,8 @@ export type BirthTimeDraft = {
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;
@@ -84,30 +90,39 @@ export function formatBirthDate(value: Date): string {
export const birthTimeSourceOptions = [
{ value: "family_exact", label: "我知道准确出生时间", hint: "按医院记录或家人记得的时间填写,精确到分钟" },
{ value: "period_only", 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,
@@ -115,6 +130,8 @@ export const birthTimeSourceDefaults = {
unknown: {
reportedTime: "",
birthTimePeriod: "",
declaredWindowStart: "",
declaredWindowEnd: "",
uncertaintyBeforeMinutes: null,
uncertaintyAfterMinutes: null,
},
@@ -136,8 +153,8 @@ export type BirthTimeDisplayState = {
function reportedBirthTimeLabel(draft: BirthTimeDraft): string {
if (draft.birthTimeSource === "period_only") {
return birthTimePeriodOptions.find((option) => option.value === draft.birthTimePeriod)?.label
?? "未选择时段";
const range = declaredWindowRangeForDraft(draft);
return range ? range.label : "未选择时间范围";
}
if (draft.birthTimeSource === "unknown") return "具体时间未知";
return draft.reportedTime || draft.time || "尚未填报";
@@ -155,15 +172,6 @@ export function birthTimeDisplayState(draft: BirthTimeDraft): BirthTimeDisplaySt
};
}
const periodLabels = {
"": "未选择时段",
early_morning: "凌晨或清晨",
morning: "上午",
afternoon: "下午",
evening: "晚上",
late_night: "深夜",
} as const satisfies Record<BirthTimePeriod, string>;
const intentCopy = {
confirm_stable_record: "医院记录前后两分钟内结构稳定,可以直接作为当前排盘时间。",
explain_sensitive_boundary: "这个时间靠近敏感边界,需要先做轻量校正,暂不直接排盘。",
@@ -185,6 +193,38 @@ 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) {
@@ -205,7 +245,7 @@ export function isBirthTimeDraftReady(draft: BirthTimeDraft) {
&& (draft.uncertaintyAfterMinutes === null
|| draft.uncertaintyBeforeMinutes === draft.uncertaintyAfterMinutes);
case "period_only":
return birthTimePeriodOptions.some((option) => option.value === draft.birthTimePeriod)
return Boolean(declaredWindowRangeForDraft(draft))
&& !draft.reportedTime
&& draft.uncertaintyBeforeMinutes === null
&& draft.uncertaintyAfterMinutes === null;
@@ -227,7 +267,13 @@ export function birthTimeDraftReadyHint(draft: BirthTimeDraft) {
if (!parseBirthDate(draft.date)) return "请先选择出生日期";
if (!draft.birthTimeSource) return "请选择你对出生时间了解多少";
if (isBirthTimeDraftReady(draft)) return "";
if (draft.birthTimeSource === "period_only") return "请选择一个大致时段";
if (draft.birthTimeSource === "period_only") {
if (draft.declaredWindowStart && draft.declaredWindowEnd
&& draft.declaredWindowStart === draft.declaredWindowEnd) {
return "开始和结束不能是同一分钟";
}
return "请选择开始和结束时间";
}
if (draft.birthTimeSource === "unknown") return "";
return "请填写时和分";
}
@@ -266,6 +312,8 @@ const declaredBirthInputKeys = [
"reportedTime",
"birthTimeSource",
"birthTimePeriod",
"declaredWindowStart",
"declaredWindowEnd",
"birthTimeClue",
"uncertaintyBeforeMinutes",
"uncertaintyAfterMinutes",
@@ -316,11 +364,19 @@ export function birthTimePersistenceValues(draft: BirthTimeDraft) {
: 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: draft.birthTimePeriod || null,
birth_time_clue: draft.birthTimeClue.trim() || 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,
};
@@ -361,8 +417,10 @@ export function describeBirthTimeDraft(draft: BirthTimeDraft) {
return `${date} ${draft.reportedTime}(填报准确时间)`;
case "approximate":
return `${date},约 ${draft.reportedTime}`;
case "period_only":
return `${date},${periodLabels[draft.birthTimePeriod]}${draft.birthTimeClue.trim() ? `(${draft.birthTimeClue.trim()})` : ""}`;
case "period_only": {
const range = declaredWindowRangeForDraft(draft);
return `${date},${range ? range.label : "未选择时间范围"}`;
}
case "unknown":
return `${date},具体时间未知`;
case "legacy_import":
@@ -2,7 +2,7 @@ import { chinaLocations } from "../data/china-locations.ts";
import { isBirthClockTime, parseBirthDate } from "./birth-time-intake-model.ts";
import { resolveMissingBirthTimezoneOffset } from "./birth-profile-timezone.ts";
import {
declaredClockRange,
declaredClockRangeFromProfile,
declaredRangeWrapsMidnight,
} from "./declared-birth-window.ts";
import {
@@ -367,7 +367,7 @@ function declaredWindowFromProfile(value: unknown): DeclaredBirthWindowConsultat
throw new ConsultationProfileTruthError("mode_changed");
}
const birthTimePeriod = optionalText(profile, "birth_time_period");
const range = declaredClockRange({ source: birthTimeSource, period: birthTimePeriod });
const range = declaredClockRangeFromProfile(profile);
if (!range) throw new ConsultationProfileTruthError("profile_incomplete");
const countryCode = optionalText(profile, "country_code");
@@ -481,10 +481,7 @@ export async function prepareConsultationRoute<Reservation, GuardResult>(
}
serverChart = serverChartFromProfile(profile, consultationMode);
} else if (consultationMode === "declared_birth_window") {
const range = declaredClockRange({
source: optionalText(record(profile) ?? {}, "birth_time_source") ?? "",
period: optionalText(record(profile) ?? {}, "birth_time_period"),
});
const range = declaredClockRangeFromProfile(record(profile) ?? {});
try {
profile = await (input.resolveTimezoneOffset ?? ((value, time) => (
resolveMissingBirthTimezoneOffset(value, { preferredTime: time })
+46 -3
View File
@@ -111,18 +111,61 @@ export function declaredWindowProbeRole(
return "interior";
}
function customDeclaredRange(startTime: string | null | undefined, endTime: string | null | undefined): DeclaredClockRange | null {
if (!isBirthClockText(startTime) || !isBirthClockText(endTime) || startTime === endTime) return null;
return {
label: `${startTime}—${endTime}`,
startTime,
endTime,
};
}
export function matchingDeclaredPeriod(startTime: string, endTime: string): DeclaredBirthPeriod | null {
for (const period of DECLARED_PERIOD_IDS) {
const range = DECLARED_PERIOD_RANGES[period];
if (range.startTime === startTime && range.endTime === endTime) return period;
}
return null;
}
export function declaredClockRange(input: Readonly<{
source: string;
period?: string | null;
startTime?: string | null;
endTime?: string | null;
}>): DeclaredClockRange | null {
const custom = customDeclaredRange(input.startTime, input.endTime);
if (input.source === "period_only") {
return isDeclaredBirthPeriod(input.period) ? DECLARED_PERIOD_RANGES[input.period] : null;
return custom ?? (isDeclaredBirthPeriod(input.period) ? DECLARED_PERIOD_RANGES[input.period] : null);
}
if (input.source === "legacy_import" && isDeclaredBirthPeriod(input.period)) {
return DECLARED_PERIOD_RANGES[input.period];
if (input.source === "legacy_import" && (custom || isDeclaredBirthPeriod(input.period))) {
return custom ?? DECLARED_PERIOD_RANGES[input.period as DeclaredBirthPeriod];
}
if (input.source === "unknown" || input.source === "legacy_import") {
return UNKNOWN_DECLARED_CLOCK_RANGE;
}
return null;
}
function textClock(value: unknown): string | null {
return typeof value === "string" && isBirthClockText(value.slice(0, 5)) ? value.slice(0, 5) : null;
}
export function declaredClockRangeFromProfile(profile: Readonly<Record<string, unknown>>): DeclaredClockRange | null {
const source = typeof profile.birth_time_source === "string"
? profile.birth_time_source
: typeof profile.birthTimeSource === "string"
? profile.birthTimeSource
: "";
const period = typeof profile.birth_time_period === "string"
? profile.birth_time_period
: typeof profile.birthTimePeriod === "string"
? profile.birthTimePeriod
: null;
return declaredClockRange({
source,
period,
startTime: textClock(profile.declared_window_start ?? profile.declaredWindowStart),
endTime: textClock(profile.declared_window_end ?? profile.declaredWindowEnd),
});
}
@@ -13,6 +13,8 @@ type OnboardingProfileInput = {
readonly activeBirthTime: string | null;
readonly birthTimeSource?: string | null;
readonly birthTimePeriod?: string | null;
readonly declaredWindowStart?: string | null;
readonly declaredWindowEnd?: string | null;
readonly birthTimeClue?: string | null;
readonly uncertaintyBeforeMinutes?: number | null;
readonly uncertaintyAfterMinutes?: number | null;
@@ -56,6 +58,8 @@ export function createOnboardingCacheIdentity(
profile.activeBirthTime,
profile.birthTimeSource,
profile.birthTimePeriod,
profile.declaredWindowStart,
profile.declaredWindowEnd,
profile.birthTimeClue,
profile.uncertaintyBeforeMinutes,
profile.uncertaintyAfterMinutes,
+4
View File
@@ -76,6 +76,8 @@ type OnboardingProfileFingerprintInput = {
readonly reportedTime: string;
readonly birthTimeSource: string;
readonly birthTimePeriod: string;
readonly declaredWindowStart: string;
readonly declaredWindowEnd: string;
readonly birthTimeClue: string;
readonly uncertaintyBeforeMinutes: number | null;
readonly uncertaintyAfterMinutes: number | null;
@@ -142,6 +144,8 @@ export function onboardingProfileFingerprint(profile: OnboardingProfileFingerpri
profile.reportedTime,
profile.birthTimeSource,
profile.birthTimePeriod,
profile.declaredWindowStart,
profile.declaredWindowEnd,
profile.birthTimeClue,
profile.uncertaintyBeforeMinutes,
profile.uncertaintyAfterMinutes,
+13 -9
View File
@@ -10,6 +10,7 @@ import {
} from "./onboarding-payload.ts";
import {
formatBirthDate,
hydrateDeclaredWindowDraft,
isDeclaredBirthProfileComplete,
type BirthTimeSource,
type BirthTimeStatus,
@@ -24,6 +25,8 @@ export type OnboardingProfileRow = {
readonly active_birth_time: string | null;
readonly birth_time_source: string | null;
readonly birth_time_period: string | null;
readonly declared_window_start?: string | null;
readonly declared_window_end?: string | null;
readonly birth_time_clue: string | null;
readonly uncertainty_before_minutes: number | null;
readonly uncertainty_after_minutes: number | null;
@@ -103,21 +106,20 @@ function hasCompleteBirthProfile(profile: OnboardingProfileRow): boolean {
];
const status = knownStatuses.find((item) => item === profile.birth_time_status)
?? (persistedTime ? "confirmed" : "");
const clock = (value: string | null) => value ? value.slice(0, 5) : "";
const clock = (value: string | null | undefined) => value ? value.slice(0, 5) : "";
const declaredWindow = hydrateDeclaredWindowDraft({
period: profile.birth_time_period,
start: profile.declared_window_start,
end: profile.declared_window_end,
});
const birthDraft = {
date: normalizeBirthDate(profile.birth_date),
time: clock(persistedTime),
reportedTime: clock(profile.reported_birth_time)
|| (source === "legacy_import" ? clock(persistedTime) : ""),
birthTimeSource: source,
birthTimePeriod: profile.birth_time_period === "early_morning"
|| profile.birth_time_period === "morning"
|| profile.birth_time_period === "afternoon"
|| profile.birth_time_period === "evening"
|| profile.birth_time_period === "late_night"
? profile.birth_time_period
: "",
birthTimeClue: profile.birth_time_clue ?? "",
...declaredWindow,
birthTimeClue: "",
uncertaintyBeforeMinutes: profile.uncertainty_before_minutes,
uncertaintyAfterMinutes: profile.uncertainty_after_minutes,
birthTimeStatus: status,
@@ -181,6 +183,8 @@ export function createOnboardingPost(dependencies: OnboardingPostDependencies):
activeBirthTime: profile.active_birth_time,
birthTimeSource: profile.birth_time_source,
birthTimePeriod: profile.birth_time_period,
declaredWindowStart: profile.declared_window_start,
declaredWindowEnd: profile.declared_window_end,
birthTimeClue: profile.birth_time_clue,
uncertaintyBeforeMinutes: profile.uncertainty_before_minutes,
uncertaintyAfterMinutes: profile.uncertainty_after_minutes,
@@ -5,6 +5,7 @@ import type {
AgenticRectificationContext,
} from "@/mastra/rectification-tools";
import { normalizePersistedBirthDate } from "../birth-time-intake-model.ts";
import { declaredClockRange } from "../declared-birth-window.ts";
type AccountingClient = SupabaseClient;
@@ -52,14 +53,6 @@ const timeValue = (value: unknown): string | null => {
return /^([01]\d|2[0-3]):[0-5]\d$/.test(time) ? time : null;
};
const periodRanges = {
early_morning: { start_time: "04:00", end_time: "07:59" },
morning: { start_time: "08:00", end_time: "11:59" },
afternoon: { start_time: "12:00", end_time: "17:59" },
evening: { start_time: "18:00", end_time: "22:59" },
late_night: { start_time: "23:00", end_time: "03:59" },
} as const;
function shiftedTime(time: string, offsetMinutes: number): string {
const [hour = 0, minute = 0] = time.split(":").map(Number);
const normalized = ((hour * 60 + minute + offsetMinutes) % 1_440 + 1_440) % 1_440;
@@ -71,6 +64,8 @@ function candidateRangeFrom(input: {
reportedTime: string | null;
source: string;
period: unknown;
windowStart: string | null;
windowEnd: string | null;
uncertaintyBefore: number | null;
uncertaintyAfter: number | null;
}): AgenticRectificationContext["candidateRange"] {
@@ -88,16 +83,14 @@ function candidateRangeFrom(input: {
end_time: shiftedTime(referenceTime, input.uncertaintyAfter ?? fallback),
};
}
if (input.source === "period_only" || input.source === "legacy_import") {
const period = typeof input.period === "string"
? periodRanges[input.period as keyof typeof periodRanges]
: null;
if (period) return period;
if (input.source === "period_only") throw new AgenticRectificationProfileError("missing_birth_time_period");
}
if (input.source === "unknown" || input.source === "legacy_import") {
return { start_time: "00:00", end_time: "23:59" };
}
const range = declaredClockRange({
source: input.source,
period: typeof input.period === "string" ? input.period : null,
startTime: input.windowStart,
endTime: input.windowEnd,
});
if (range) return { start_time: range.startTime, end_time: range.endTime };
if (input.source === "period_only") throw new AgenticRectificationProfileError("missing_birth_time_period");
throw new AgenticRectificationProfileError("missing_birth_time");
}
@@ -173,7 +166,7 @@ export async function loadAgenticRectificationProfile(
): Promise<AgenticRectificationProfile> {
const { data, error } = await accounting
.from("profiles")
.select("birth_date,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_offset")
.select("birth_date,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_offset")
.eq("id", userId)
.single();
if (error || !data) throw new AgenticRectificationProfileError("profile_unavailable");
@@ -198,6 +191,8 @@ export async function loadAgenticRectificationProfile(
reportedTime,
source: rawSource,
period: data.birth_time_period,
windowStart: timeValue(data.declared_window_start),
windowEnd: timeValue(data.declared_window_end),
uncertaintyBefore,
uncertaintyAfter,
}),
@@ -39,6 +39,8 @@ export type V9BaselineSnapshot = Readonly<{
timezone_offset: number;
birth_time_source: string;
birth_time_period: string | null;
declared_window_start: string | null;
declared_window_end: string | null;
reported_birth_time: string | null;
active_birth_time: string | null;
uncertainty_before_minutes: number | null;
@@ -119,6 +121,8 @@ function deriveCandidateRange(input: {
reportedTime: string | null;
source: string;
period: string | null;
windowStart: string | null;
windowEnd: string | null;
}): { start_time: string; end_time: string } {
if (input.reportedTime) {
return {
@@ -127,7 +131,12 @@ function deriveCandidateRange(input: {
};
}
const range = declaredClockRange({ source: input.source, period: input.period });
const range = declaredClockRange({
source: input.source,
period: input.period,
startTime: input.windowStart,
endTime: input.windowEnd,
});
if (!range) throw new RectificationCaseServiceError("profile_incomplete");
return { start_time: range.startTime, end_time: range.endTime };
}
@@ -142,6 +151,8 @@ function baselineFingerprint(baseline: V9BaselineSnapshot): string {
String(baseline.timezone_offset),
baseline.birth_time_source,
baseline.birth_time_period ?? "",
baseline.declared_window_start ?? "",
baseline.declared_window_end ?? "",
baseline.reported_birth_time ?? "",
baseline.active_birth_time ?? "",
String(baseline.uncertainty_before_minutes ?? ""),
@@ -157,7 +168,7 @@ export async function loadV9RectificationProfile(
const { data, error } = await accounting
.from("profiles")
.select(
"birth_date,birth_place_label,reported_birth_time,birth_time_source,birth_time_period,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset",
"birth_date,birth_place_label,reported_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset",
)
.eq("id", userId)
.single();
@@ -179,6 +190,8 @@ export async function loadV9RectificationProfile(
const source = typeof row.birth_time_source === "string" ? row.birth_time_source.trim() : "";
const reportedTime = timeValue(row.reported_birth_time);
const period = typeof row.birth_time_period === "string" ? row.birth_time_period : null;
const windowStart = timeValue(row.declared_window_start);
const windowEnd = timeValue(row.declared_window_end);
const uncertaintyBefore = numberOrNull(row.uncertainty_before_minutes);
const uncertaintyAfter = numberOrNull(row.uncertainty_after_minutes);
if (!birthDate || !birthPlaceLabel || latitude === null || longitude === null || !timezoneId || timezoneOffset === null || !source) {
@@ -194,6 +207,8 @@ export async function loadV9RectificationProfile(
timezone_offset: timezoneOffset,
birth_time_source: source,
birth_time_period: period,
declared_window_start: windowStart,
declared_window_end: windowEnd,
reported_birth_time: reportedTime,
// A fresh Case must never inherit an accepted/active minute as its new baseline.
active_birth_time: null,
@@ -205,7 +220,13 @@ export async function loadV9RectificationProfile(
userId,
baseline,
baselineFingerprint: baselineFingerprint(baseline),
candidateRange: deriveCandidateRange({ reportedTime, source, period }),
candidateRange: deriveCandidateRange({
reportedTime,
source,
period,
windowStart,
windowEnd,
}),
};
}
@@ -0,0 +1,62 @@
begin;
-- BUG-353: period_only declarations are a start/end clock window, not a
-- coarse bucket plus free-text clue. Rectification and window consultation
-- read these columns; birth_time_period remains a legacy fallback.
alter table public.profiles
add column if not exists declared_window_start text,
add column if not exists declared_window_end text;
alter table public.profiles
drop constraint if exists profiles_declared_window_clocks;
alter table public.profiles
add constraint profiles_declared_window_clocks check (
(declared_window_start is null and declared_window_end is null)
or (
declared_window_start ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'
and declared_window_end ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'
and declared_window_start is distinct from declared_window_end
)
);
update public.profiles
set
declared_window_start = case birth_time_period
when 'early_morning' then '04:00'
when 'morning' then '08:00'
when 'afternoon' then '12:00'
when 'evening' then '18:00'
when 'late_night' then '23:00'
else declared_window_start
end,
declared_window_end = case birth_time_period
when 'early_morning' then '07:59'
when 'morning' then '11:59'
when 'afternoon' then '17:59'
when 'evening' then '22:59'
when 'late_night' then '03:59'
else declared_window_end
end
where birth_time_source = 'period_only'
and declared_window_start is null
and declared_window_end is null
and birth_time_period in ('early_morning', 'morning', 'afternoon', 'evening', 'late_night');
grant select (
declared_window_start,
declared_window_end
) on table public.profiles to service_role;
grant insert (
declared_window_start,
declared_window_end
) on table public.profiles to service_role;
grant update (
declared_window_start,
declared_window_end
) on table public.profiles to service_role;
commit;
+28
View File
@@ -100,6 +100,34 @@ test("profile patch schema validates calendar, clock, source requirements, and l
uncertainty_before_minutes: null,
uncertainty_after_minutes: null,
}).success, false);
assert.equal(accountProfilePatchSchema.safeParse({
...valid,
reported_birth_time: null,
birth_time_source: "period_only",
birth_time_period: "evening",
uncertainty_before_minutes: null,
uncertainty_after_minutes: null,
}).success, true);
assert.equal(accountProfilePatchSchema.safeParse({
...valid,
reported_birth_time: null,
birth_time_source: "period_only",
birth_time_period: null,
declared_window_start: "14:30",
declared_window_end: "15:00",
uncertainty_before_minutes: null,
uncertainty_after_minutes: null,
}).success, true);
assert.equal(accountProfilePatchSchema.safeParse({
...valid,
reported_birth_time: null,
birth_time_source: "period_only",
birth_time_period: null,
declared_window_start: "14:30",
declared_window_end: "14:30",
uncertainty_before_minutes: null,
uncertainty_after_minutes: null,
}).success, false);
assert.equal(accountProfilePatchSchema.safeParse({
...valid,
reported_birth_time: null,
@@ -23,6 +23,8 @@ const reportedExactTime = {
reportedTime: "05:30",
birthTimeSource: "family_exact",
birthTimePeriod: "",
declaredWindowStart: "",
declaredWindowEnd: "",
birthTimeClue: "",
uncertaintyBeforeMinutes: 10,
uncertaintyAfterMinutes: 10,
@@ -214,11 +216,13 @@ test("birth time intake starts with exact or uncertain choices and keeps rectifi
assert.match(model, /我不确定准确时间/);
assert.match(intake, /你对出生时间了解多少?/);
assert.match(intake, /不用猜具体分钟/);
assert.match(intake, /aria-label="选择出生时间"/);
assert.match(intake, /aria-label=\{ariaLabel\}/);
assert.match(intake, /ariaLabel = "选择出生时间"/);
assert.doesNotMatch(intake, /source === "family_exact" \|\| source === "approximate"/);
assert.match(intake, /请选择最接近的时间范围/);
assert.match(intake, /选择你记得的开始和结束时间/);
assert.match(intake, /完全不清楚,跳过出生时间/);
assert.match(intake, /生时校正以后需要时再做/);
assert.doesNotMatch(intake, /请选择最接近的时间范围|补充描述/);
});
test("homepage and profile result copy use the source-aware consultation options", () => {
+72 -7
View File
@@ -26,6 +26,8 @@ const emptyDraft: BirthTimeDraft = {
reportedTime: "",
birthTimeSource: "",
birthTimePeriod: "",
declaredWindowStart: "",
declaredWindowEnd: "",
birthTimeClue: "",
uncertaintyBeforeMinutes: null,
uncertaintyAfterMinutes: null,
@@ -45,6 +47,12 @@ test("birth time intake requires only the fields selected by the source", () =>
birthTimeSource: "period_only",
birthTimePeriod: "evening",
} satisfies BirthTimeDraft;
const customWindow = {
...emptyDraft,
birthTimeSource: "period_only",
declaredWindowStart: "14:30",
declaredWindowEnd: "15:00",
} satisfies BirthTimeDraft;
const incompleteApproximate = {
...emptyDraft,
birthTimeSource: "approximate",
@@ -53,6 +61,11 @@ test("birth time intake requires only the fields selected by the source", () =>
assert.equal(isBirthTimeDraftReady(hospital), true);
assert.equal(isBirthTimeDraftReady(period), true);
assert.equal(isBirthTimeDraftReady(customWindow), true);
assert.equal(isBirthTimeDraftReady({
...customWindow,
declaredWindowEnd: "14:30",
}), false);
assert.equal(isBirthTimeDraftReady(incompleteApproximate), true);
assert.equal(isBirthTimeDraftReady({ ...emptyDraft, birthTimeSource: "unknown" }), true);
});
@@ -60,6 +73,8 @@ test("birth time intake requires only the fields selected by the source", () =>
test("family exact intake defaults and persists as reported 0/0 without engine confirmation", () => {
assert.deepEqual(birthTimeSourceDefaults.family_exact, {
birthTimePeriod: "",
declaredWindowStart: "",
declaredWindowEnd: "",
birthTimeClue: "",
uncertaintyBeforeMinutes: 0,
uncertaintyAfterMinutes: 0,
@@ -80,6 +95,8 @@ test("family exact intake defaults and persists as reported 0/0 without engine c
reported_birth_time: "05:00",
birth_time_source: "family_exact",
birth_time_period: null,
declared_window_start: null,
declared_window_end: null,
birth_time_clue: null,
uncertainty_before_minutes: 0,
uncertainty_after_minutes: 0,
@@ -240,11 +257,11 @@ test("a persisted candidate working time takes precedence over the reported rang
// When: a profile surface asks what birth-time state to display.
const display = birthTimeDisplayState(candidate);
// Then: the candidate minute is primary and the original period remains secondary.
// Then: the candidate minute is primary and the original clock window remains secondary.
assert.deepEqual(display, {
kind: "candidate",
activeTime: "04:53",
reportedLabel: "凌晨 / 清晨(04:00—07:59)",
reportedLabel: "04:00—07:59",
});
});
@@ -263,6 +280,8 @@ test("birth time declaration payload cannot write deterministic application fiel
reported_birth_time: "14:30",
birth_time_source: "approximate",
birth_time_period: null,
declared_window_start: null,
declared_window_end: null,
birth_time_clue: null,
uncertainty_before_minutes: 30,
uncertainty_after_minutes: 30,
@@ -279,6 +298,23 @@ test("birth time intake describes uncertainty without claiming false precision",
} satisfies BirthTimeDraft;
assert.equal(describeBirthTimeDraft(approximate), "1993年4月17日,约 14:30");
assert.equal(
describeBirthTimeDraft({
...emptyDraft,
birthTimeSource: "period_only",
declaredWindowStart: "14:30",
declaredWindowEnd: "15:00",
}),
"1993年4月17日,14:30—15:00",
);
assert.equal(
describeBirthTimeDraft({
...emptyDraft,
birthTimeSource: "period_only",
birthTimePeriod: "evening",
}),
"1993年4月17日,18:00—22:59",
);
assert.equal(
assistantIntentCopy("present_saved_candidate_range"),
"目前只能保存候选范围,还没有足够证据应用到具体分钟。",
@@ -317,7 +353,7 @@ test("fresh intake copy stays user-facing and explains the next missing field",
);
assert.equal(
birthTimeSourceOptions.find((option) => option.value === "period_only")?.hint,
"只知道大概几点、上午下午,或完全不清楚",
"选择一段开始到结束的时间,或完全不清楚",
);
assert.equal(birthTimeSourceOptions.every((option) => !/引擎确认|初始化填报/.test(option.hint)), true);
assert.equal(birthTimeDraftReadyHint({ ...emptyDraft, date: "" }), "请先选择出生日期");
@@ -331,7 +367,13 @@ test("fresh intake copy stays user-facing and explains the next missing field",
assert.equal(birthTimeDraftReadyHint({
...emptyDraft,
birthTimeSource: "period_only",
}), "请选择一个大致时段");
}), "请选择开始和结束时间");
assert.equal(birthTimeDraftReadyHint({
...emptyDraft,
birthTimeSource: "period_only",
declaredWindowStart: "14:30",
declaredWindowEnd: "14:30",
}), "开始和结束不能是同一分钟");
});
test("fresh intake preserves exact, approximate-period, and unknown-time paths without auto-confirming", () => {
@@ -343,15 +385,38 @@ test("fresh intake preserves exact, approximate-period, and unknown-time paths w
["family_exact", "period_only"],
);
assert.doesNotMatch(source, /已用于当前排盘/);
assert.match(source, /birthTimePeriodOptions/);
assert.doesNotMatch(source, /birthTimePeriodOptions/);
assert.match(source, /birthTimeSource: "unknown"/);
assert.match(source, /请选择最接近的时间范围/);
assert.match(source, /选择你记得的开始和结束时间/);
assert.match(source, /完全不清楚,跳过出生时间/);
assert.match(source, /我可以描述一个时间范围/);
assert.match(source, /我可以选一段时间范围/);
assert.doesNotMatch(source, /补充描述|请选择最接近的时间范围|我可以描述一个时间范围/);
assert.match(source, /birthTimeStatus: "reported"/);
assert.match(source, /birthTimeConsultationOptionsCopy\(value\)/);
});
test("a custom clock window persists as the scan bounds without a leftover period bucket", () => {
const draft = {
...emptyDraft,
birthTimeSource: "period_only",
birthTimePeriod: "afternoon",
declaredWindowStart: "14:30",
declaredWindowEnd: "15:00",
birthTimeStatus: "reported",
} satisfies BirthTimeDraft;
assert.deepEqual(birthTimePersistenceValues(draft), {
reported_birth_time: null,
birth_time_source: "period_only",
birth_time_period: null,
declared_window_start: "14:30",
declared_window_end: "15:00",
birth_time_clue: null,
uncertainty_before_minutes: null,
uncertainty_after_minutes: null,
});
});
test("a saved declaration adopts the birth-time truth the account write returned", () => {
const exactDeclaration = {
...emptyDraft,
@@ -323,9 +323,31 @@ test("stale general mode upgrades period-only profiles to a declared birth windo
assert.equal(prepared.reservation, "reserved");
});
test("a custom declared window consults those clocks instead of the leftover period bucket", async () => {
const prepared = await prepareConsultationRoute({
userId: "user-1",
mode: "general_no_birth_time",
loadProfile: async () => ({
...profile,
reported_birth_time: null,
active_birth_time: null,
birth_time_source: "period_only",
birth_time_period: "afternoon",
declared_window_start: "14:30",
declared_window_end: "15:00",
birth_time_status: "reported",
}),
reserve: async () => "reserved",
});
assert.equal(prepared.consultationMode, "declared_birth_window");
assert.equal(prepared.declaredWindow?.toolInput.rangeStart, "14:30");
assert.equal(prepared.declaredWindow?.toolInput.rangeEnd, "15:00");
});
test("consult route constructs workflow input from the route service rather than client chart fields", () => {
const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
const select = "name,birth_date,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,birth_time_status,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset";
const select = "name,birth_date,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,birth_time_status,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset";
assert.match(route, new RegExp(select));
assert.match(route, /prepareConsultationRoute/);
@@ -79,6 +79,7 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
assert.match(migration.stdout, /applied 20260816010000_accept_exact_family_birth_times\.sql/);
assert.match(migration.stdout, /applied 20260818010000_admin_product_catalog_mutations\.sql/);
assert.match(migration.stdout, /applied 20260819010000_rectification_ingest_precision_plateau\.sql/);
assert.match(migration.stdout, /applied 20260822010000_declared_birth_window_clocks\.sql/);
assert.equal(
fixture.psql(`
@@ -4,7 +4,9 @@ import test from "node:test";
import {
DECLARED_PERIOD_RANGES,
declaredClockRange,
declaredClockRangeFromProfile,
declaredWindowProbeClocks,
matchingDeclaredPeriod,
} from "../src/lib/declared-birth-window.ts";
test("evening probes stay inside the declared range and are not the midpoint", () => {
@@ -34,3 +36,52 @@ test("period_only without a period cannot invent a clock range", () => {
DECLARED_PERIOD_RANGES.evening,
);
});
test("a custom start and end clock beats a leftover afternoon bucket", () => {
assert.deepEqual(
declaredClockRange({
source: "period_only",
period: "afternoon",
startTime: "14:30",
endTime: "15:00",
}),
{ label: "14:30—15:00", startTime: "14:30", endTime: "15:00" },
);
assert.equal(matchingDeclaredPeriod("14:30", "15:00"), null);
assert.equal(matchingDeclaredPeriod("12:00", "17:59"), "afternoon");
assert.equal(
declaredClockRange({
source: "period_only",
startTime: "14:30",
endTime: "14:30",
}),
null,
);
assert.deepEqual(
declaredClockRange({
source: "period_only",
startTime: "23:00",
endTime: "03:59",
}),
{ label: "23:00—03:59", startTime: "23:00", endTime: "03:59" },
);
});
test("profile readers prefer declared window clocks over the period bucket", () => {
assert.deepEqual(
declaredClockRangeFromProfile({
birth_time_source: "period_only",
birth_time_period: "afternoon",
declared_window_start: "14:30",
declared_window_end: "15:00",
}),
{ label: "14:30—15:00", startTime: "14:30", endTime: "15:00" },
);
assert.deepEqual(
declaredClockRangeFromProfile({
birthTimeSource: "period_only",
birthTimePeriod: "evening",
}),
DECLARED_PERIOD_RANGES.evening,
);
});
@@ -77,11 +77,16 @@ test("period-only declaration fields participate in the onboarding cache identit
uncertaintyAfterMinutes: null,
};
const morning = { ...earlyMorning, birthTimePeriod: "morning" };
const customWindow = { ...earlyMorning, declaredWindowStart: "14:30", declaredWindowEnd: "15:00" };
assert.notEqual(
createOnboardingCacheIdentity(earlyMorning).readyVersion,
createOnboardingCacheIdentity(morning).readyVersion,
);
assert.notEqual(
createOnboardingCacheIdentity(earlyMorning).readyVersion,
createOnboardingCacheIdentity(customWindow).readyVersion,
);
});
test("current profile accepts only valid ready content and an active current pending claim", () => {
@@ -12,7 +12,7 @@ import {
const completeProfile = {
name: "林遥", date: "1990-06-15", time: "12:30", reportedTime: "12:30",
birthTimeSource: "hospital_record", birthTimePeriod: "", birthTimeClue: "出生证明", birthTimeStatus: "confirmed",
birthTimeSource: "hospital_record", birthTimePeriod: "", declaredWindowStart: "", declaredWindowEnd: "", birthTimeClue: "出生证明", birthTimeStatus: "confirmed",
uncertaintyBeforeMinutes: 0, uncertaintyAfterMinutes: 0, rectificationCaseId: "", countryCode: "CN", provinceCode: "110000", cityCode: "110000-city", districtCode: "110101",
} as const;
+2
View File
@@ -78,6 +78,8 @@ export function completeProfileRow(
active_birth_time: "12:30",
birth_time_source: "legacy_import",
birth_time_period: null,
declared_window_start: null,
declared_window_end: null,
birth_time_clue: null,
uncertainty_before_minutes: null,
uncertainty_after_minutes: null,
@@ -224,6 +224,8 @@ test("accepted/confirmed remain the only statuses ready for verified natal consu
reportedTime: "12:00",
birthTimeSource: "family_exact" as const,
birthTimePeriod: "morning" as const,
declaredWindowStart: "",
declaredWindowEnd: "",
birthTimeClue: "",
uncertaintyBeforeMinutes: 10,
uncertaintyAfterMinutes: 10,
+27 -1
View File
@@ -1,5 +1,5 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import test from "node:test";
test("upserts a missing profile when saving account details", () => {
@@ -46,6 +46,8 @@ test("account route persists the birth-time declaration before assessment", () =
"reported_birth_time",
"birth_time_source",
"birth_time_period",
"declared_window_start",
"declared_window_end",
"birth_time_clue",
"uncertainty_before_minutes",
"uncertainty_after_minutes",
@@ -113,3 +115,27 @@ test("reported birth declarations remain editable and candidate times never over
assert.match(migration, /update public\.profiles\s+set reported_birth_time\s*=\s*null[\s\S]*?birth_time_source\s+in\s*\(\s*'period_only'\s*,\s*'unknown'\s*\)/i);
assert.match(migration, /profiles_reported_birth_time_source_consistency/i);
});
test("declared birth windows persist as start and end clocks with service-role grants", () => {
const migration = readFileSync(
new URL(
"../supabase/migrations/20260822010000_declared_birth_window_clocks.sql",
import.meta.url,
),
"utf8",
);
assert.match(migration, /add column if not exists declared_window_start text/);
assert.match(migration, /add column if not exists declared_window_end text/);
assert.match(migration, /profiles_declared_window_clocks/);
assert.match(migration, /birth_time_source = 'period_only'/);
assert.match(migration, /when 'afternoon' then '12:00'/);
assert.match(migration, /when 'afternoon' then '17:59'/);
assert.match(migration, /grant select \(\s*declared_window_start,\s*declared_window_end\s*\)/i);
assert.match(migration, /grant insert \(\s*declared_window_start,\s*declared_window_end\s*\)/i);
assert.match(migration, /grant update \(\s*declared_window_start,\s*declared_window_end\s*\)/i);
assert.equal(
existsSync(new URL("../db/migrations/20260822010000_declared_birth_window_clocks.sql", import.meta.url)),
false,
);
});
@@ -110,6 +110,19 @@ test("loadAgenticRectificationProfile accepts a period-only declaration without
assert.equal(profile.baselineBirthTimePeriod, "late_night");
});
test("loadAgenticRectificationProfile uses a custom declared window instead of the leftover period", async () => {
const { client } = fakeAccounting(fakeProfileRow({
reported_birth_time: null,
active_birth_time: null,
birth_time_source: "period_only",
birth_time_period: "afternoon",
declared_window_start: "14:30",
declared_window_end: "15:00",
}));
const profile = await loadAgenticRectificationProfile(client as never, userId);
assert.deepEqual(profile.candidateRange, { start_time: "14:30", end_time: "15:00" });
});
test("loadAgenticRectificationProfile accepts an unknown time as the full day", async () => {
const { client } = fakeAccounting(fakeProfileRow({
reported_birth_time: null,
@@ -168,6 +168,77 @@ test("period-only profiles open with their selected server-owned range", async (
}
});
test("a custom declared window opens the Case with those clocks, not the leftover afternoon bucket", async () => {
const capturedArgs: { value: Record<string, unknown> | null } = { value: null };
const accounting = fakeAccounting({
profile: {
...completeProfile,
reported_birth_time: null,
birth_time_source: "period_only",
birth_time_period: "afternoon",
declared_window_start: "14:30",
declared_window_end: "15:00",
},
rpc: async (_fn, args) => {
capturedArgs.value = args;
return {
data: {
disposition: "created",
case_id: caseId,
session_id: sessionId,
status: "draft",
should_start_opening: true,
skill_version: "9.0.0",
},
error: null,
};
},
});
const response = await openRectificationCase(accounting, "user-1", {
intent: "homepage",
requestId,
});
assert.equal(response.disposition, "created");
assert.deepEqual(capturedArgs.value?.p_candidate_range, { start_time: "14:30", end_time: "15:00" });
assert.equal(
(capturedArgs.value?.p_baseline_birth_snapshot as Record<string, unknown>).declared_window_start,
"14:30",
);
assert.equal(
(capturedArgs.value?.p_baseline_birth_snapshot as Record<string, unknown>).declared_window_end,
"15:00",
);
});
test("tightening a declared window changes the Case fingerprint", async () => {
const afternoon = await loadV9RectificationProfile(fakeAccounting({
profile: {
...completeProfile,
reported_birth_time: null,
birth_time_source: "period_only",
birth_time_period: "afternoon",
declared_window_start: "12:00",
declared_window_end: "17:59",
},
}), "user-1");
const tight = await loadV9RectificationProfile(fakeAccounting({
profile: {
...completeProfile,
reported_birth_time: null,
birth_time_source: "period_only",
birth_time_period: "afternoon",
declared_window_start: "14:30",
declared_window_end: "15:00",
},
}), "user-1");
assert.notEqual(afternoon.baselineFingerprint, tight.baselineFingerprint);
assert.deepEqual(afternoon.candidateRange, { start_time: "12:00", end_time: "17:59" });
assert.deepEqual(tight.candidateRange, { start_time: "14:30", end_time: "15:00" });
});
test("period-only profiles with an IANA timezone can open when the cached offset is missing", async () => {
const originalFetch = globalThis.fetch;
const capturedArgs: { value: Record<string, unknown> | null } = { value: null };
@@ -333,6 +404,8 @@ test("homepage and new opens ignore active time and legacy uncertainty for their
timezone_offset: 8,
birth_time_source: "family_exact",
birth_time_period: null,
declared_window_start: null,
declared_window_end: null,
reported_birth_time: "05:00",
active_birth_time: null,
uncertainty_before_minutes: 10,