/** * Snooze preset resolution for the sidebar snooze menu. Pure functions so * the preset math (evening/tomorrow/next-week boundaries) is unit-testable * without a DOM. * * Presets deliberately skew short: agent-thread rhythms are hours (a CI * run, a teammate review, the next work session), not days. */ import { parseTimestampDate } from "../timestampFormat"; type SnoozePresetId = "hour" | "evening" | "tomorrow" | "next-week"; export interface SnoozePreset { readonly id: SnoozePresetId; readonly label: string; /** Menu-row time column. Complements the label instead of repeating it: "Tomorrow" pairs with "9:00 AM", not "tomorrow 9:00 AM". */ readonly whenLabel: string; /** ISO wake time. */ readonly snoozedUntil: string; } function timeOfDayLabel(date: Date): string { return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); } const EVENING_HOUR = 18; const MORNING_HOUR = 9; const HOUR_MS = 60 * 60 * 1_000; const DAY_MS = 24 * HOUR_MS; function atHour(base: Date, hour: number): Date { const next = new Date(base); next.setHours(hour, 0, 0, 0); return next; } // Calendar-day advance instead of adding DAY_MS: fixed millisecond offsets // land on the wrong local day across DST transitions (a spring-forward day // is 23 hours, so 23:30 + 24h skips the whole next day). function addDays(base: Date, days: number): Date { const next = new Date(base); next.setDate(next.getDate() + days); return next; } /** * Presets for "snooze until", computed against local time. "This evening" * only appears while it is still meaningfully before evening; after that * the list starts at "Tomorrow". */ export function resolveSnoozePresets(now: Date): ReadonlyArray { const inAnHour = new Date(now.getTime() + HOUR_MS); const presets: SnoozePreset[] = [ { id: "hour", label: "In 1 hour", whenLabel: timeOfDayLabel(inAnHour), snoozedUntil: inAnHour.toISOString(), }, ]; const evening = atHour(now, EVENING_HOUR); // Suppress the evening preset once it is within an hour (or past): it // would duplicate "In 1 hour" or point at the past. if (evening.getTime() - now.getTime() > HOUR_MS) { presets.push({ id: "evening", label: "This evening", whenLabel: timeOfDayLabel(evening), snoozedUntil: evening.toISOString(), }); } const tomorrow = atHour(addDays(now, 1), MORNING_HOUR); presets.push({ id: "tomorrow", label: "Tomorrow", whenLabel: timeOfDayLabel(tomorrow), snoozedUntil: tomorrow.toISOString(), }); // Next Monday 9:00 (a week out when today is Monday). const daysUntilMonday = (1 - now.getDay() + 7) % 7 || 7; const nextWeek = atHour(addDays(now, daysUntilMonday), MORNING_HOUR); presets.push({ id: "next-week", label: "Next week", whenLabel: `${nextWeek.toLocaleDateString(undefined, { weekday: "short" })} ${timeOfDayLabel(nextWeek)}`, snoozedUntil: nextWeek.toISOString(), }); return presets; } /** * Compact "wakes in" label for snoozed rows: "2h", "18h", "3d". Minutes * round up so a snooze never reads "0m" while still hidden. */ export function snoozeWakeLabel(snoozedUntil: string, now: Date): string { const wake = parseTimestampDate(snoozedUntil); if (wake === null) return "now"; const remainingMs = wake.getTime() - now.getTime(); if (remainingMs <= 0) return "now"; if (remainingMs < HOUR_MS) return `${Math.max(1, Math.ceil(remainingMs / 60_000))}m`; if (remainingMs < DAY_MS) return `${Math.ceil(remainingMs / HOUR_MS)}h`; return `${Math.ceil(remainingMs / DAY_MS)}d`; } /** * Human wake time for menus and toasts: "tomorrow 9:00", "Mon 9:00", * "17:30" (today). */ export function snoozeWakeDescription(snoozedUntil: string, now: Date): string { const wake = parseTimestampDate(snoozedUntil); if (wake === null) return ""; const time = wake.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); const startOfToday = new Date(now); startOfToday.setHours(0, 0, 0, 0); const dayDelta = Math.floor((wake.getTime() - startOfToday.getTime()) / DAY_MS); if (dayDelta === 0) return time; if (dayDelta === 1) return `tomorrow ${time}`; const weekday = wake.toLocaleDateString(undefined, { weekday: "short" }); if (dayDelta < 7) return `${weekday} ${time}`; const date = wake.toLocaleDateString(undefined, { month: "short", day: "numeric" }); return `${date}, ${time}`; }