8ed6118b9a
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>
172 lines
5.7 KiB
TypeScript
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),
|
|
});
|
|
}
|