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

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

172 lines
5.7 KiB
TypeScript

/**
* Shared declared birth-window table and probe clocks.
*
* Period ranges are the product truth for both rectification search bounds and
* ordinary-session window consultation. Probe clocks are samples, never a
* substitute birth minute.
*/
export const DECLARED_PERIOD_IDS = [
"early_morning",
"morning",
"afternoon",
"evening",
"late_night",
] as const;
export type DeclaredBirthPeriod = (typeof DECLARED_PERIOD_IDS)[number];
export const DECLARED_PERIOD_RANGES = {
early_morning: { label: "04:00—07:59", startTime: "04:00", endTime: "07:59" },
morning: { label: "08:00—11:59", startTime: "08:00", endTime: "11:59" },
afternoon: { label: "12:00—17:59", startTime: "12:00", endTime: "17:59" },
evening: { label: "18:00—22:59", startTime: "18:00", endTime: "22:59" },
late_night: { label: "23:00—03:59", startTime: "23:00", endTime: "03:59" },
} as const satisfies Record<DeclaredBirthPeriod, {
readonly label: string;
readonly startTime: string;
readonly endTime: string;
}>;
export const UNKNOWN_DECLARED_CLOCK_RANGE = {
label: "00:00—23:59",
startTime: "00:00",
endTime: "23:59",
} as const;
export type DeclaredClockRange = Readonly<{
label: string;
startTime: string;
endTime: string;
}>;
const CLOCK = /^([01]\d|2[0-3]):[0-5]\d$/;
const MINUTES_PER_DAY = 24 * 60;
export function isDeclaredBirthPeriod(value: string | null | undefined): value is DeclaredBirthPeriod {
return value === "early_morning"
|| value === "morning"
|| value === "afternoon"
|| value === "evening"
|| value === "late_night";
}
export function isBirthClockText(value: string | null | undefined): boolean {
return typeof value === "string" && CLOCK.test(value);
}
export function clockToMinutes(clock: string): number {
if (!CLOCK.test(clock)) {
throw new Error("clock must be HH:MM");
}
const hour = Number(clock.slice(0, 2));
const minute = Number(clock.slice(3, 5));
return hour * 60 + minute;
}
export function minutesToClock(total: number): string {
const normalized = ((total % MINUTES_PER_DAY) + MINUTES_PER_DAY) % MINUTES_PER_DAY;
const hour = Math.floor(normalized / 60);
const minute = normalized % 60;
return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
}
export function declaredRangeWrapsMidnight(range: Pick<DeclaredClockRange, "startTime" | "endTime">): boolean {
return clockToMinutes(range.endTime) < clockToMinutes(range.startTime);
}
/**
* Four inclusive samples: range start, two interiors, range end.
* Half-up rounding matches the Python probe helper for positive spans.
*/
export function declaredWindowProbeClocks(rangeStart: string, rangeEnd: string): readonly string[] {
const start = clockToMinutes(rangeStart);
const end = clockToMinutes(rangeEnd);
const span = end < start ? end + MINUTES_PER_DAY - start : end - start;
if (span < 1) {
throw new Error("declared window must span at least one minute");
}
const clocks: string[] = [];
const seen = new Set<string>();
for (const numerator of [0, 1, 2, 3]) {
const offset = Math.round((span * numerator) / 3);
const clock = minutesToClock(start + offset);
if (!seen.has(clock)) {
seen.add(clock);
clocks.push(clock);
}
}
if (clocks.length < 2) {
throw new Error("declared window must yield at least two distinct probes");
}
return clocks;
}
export function declaredWindowProbeRole(
index: number,
count: number,
): "range_start" | "interior" | "range_end" {
if (index === 0) return "range_start";
if (index === count - 1) return "range_end";
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 custom ?? (isDeclaredBirthPeriod(input.period) ? DECLARED_PERIOD_RANGES[input.period] : null);
}
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),
});
}