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:
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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%; }
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user