/** * 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; 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): value is string { 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): 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(); 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>): 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), }); }