learning_ai_clock/web/src/lib/cascade.ts

109 lines
3.0 KiB
TypeScript

// ── Pre-Warning Cascade System ─────────────────────────────────
// Cascade presets aligned with PRD: Aggressive, Standard, Light, Minimal, None, Custom
export type CascadePreset = 'aggressive' | 'standard' | 'light' | 'minimal' | 'none' | 'custom';
export interface CascadeWarning {
id: string;
minutesBefore: number;
fired: boolean;
firedAt: number | null;
scheduledTime: number; // epoch ms
}
export interface CascadeConfig {
preset: CascadePreset;
intervals: number[]; // minutes before target time
}
// Preset definitions (minutes before target)
export const CASCADE_PRESETS: Record<CascadePreset, number[]> = {
aggressive: [240, 180, 120, 90, 60, 30, 15, 5, 1],
standard: [120, 60, 30, 15, 5],
light: [60, 15, 5],
minimal: [15],
none: [],
custom: [],
};
export const CASCADE_PRESET_LABELS: Record<CascadePreset, string> = {
aggressive: 'Aggressive',
standard: 'Standard',
light: 'Light',
minimal: 'Minimal',
none: 'None (fire only)',
custom: 'Custom',
};
/**
* Calculate all warning timestamps from target time and cascade intervals.
* Filters out warnings that would be in the past relative to `now`.
*/
export function calculateCascadeWarnings(
targetTime: number,
intervals: number[],
now: number = Date.now()
): CascadeWarning[] {
return intervals
.sort((a, b) => b - a) // largest first (earliest warning)
.map((minutesBefore, idx) => {
const scheduledTime = targetTime - minutesBefore * 60 * 1000;
return {
id: `w-${idx}-${minutesBefore}m`,
minutesBefore,
fired: scheduledTime <= now,
firedAt: scheduledTime <= now ? scheduledTime : null,
scheduledTime,
};
});
}
/**
* Get the next unfired warning from a cascade.
*/
export function getNextWarning(warnings: CascadeWarning[]): CascadeWarning | null {
return warnings.find((w) => !w.fired) ?? null;
}
/**
* Check which warnings should fire given the current time.
* Returns newly-fired warning IDs.
*/
export function checkWarnings(
warnings: CascadeWarning[],
now: number = Date.now()
): string[] {
const newlyFired: string[] = [];
for (const warning of warnings) {
if (!warning.fired && warning.scheduledTime <= now) {
warning.fired = true;
warning.firedAt = now;
newlyFired.push(warning.id);
}
}
return newlyFired;
}
/**
* Get intervals for a preset, or custom intervals if preset is 'custom'.
*/
export function getCascadeIntervals(config: CascadeConfig): number[] {
if (config.preset === 'custom') {
return [...config.intervals].sort((a, b) => b - a);
}
return CASCADE_PRESETS[config.preset];
}
/**
* Format minutes into human-readable string.
*/
export function formatMinutesBefore(minutes: number): string {
if (minutes >= 60) {
const hours = Math.floor(minutes / 60);
const remaining = minutes % 60;
if (remaining === 0) return `${hours}h`;
return `${hours}h ${remaining}m`;
}
return `${minutes}m`;
}