/** * Human time helpers for the alarm surfaces. * * The takeover shows a count-up ("2s ago") rather than a server timestamp: on a * lock screen at arm's length an RFC3339 string is unreadable, and the number * that matters is how long the phone has been screaming. The absolute time is * still available as the accessibility label. */ /** Parse a server RFC3339 string. Returns null when absent or unparseable. */ export function parseServerTime(value: string | null | undefined): Date | null { if (!value) return null; const ms = Date.parse(value); return Number.isNaN(ms) ? null : new Date(ms); } const SECOND = 1000; const MINUTE = 60 * SECOND; const HOUR = 60 * MINUTE; const DAY = 24 * HOUR; /** * Compact elapsed time. Deliberately coarse above a minute: the takeover updates * every second only while the alarm is fresh, and "13m ago" is more useful than * "13m 42s ago" on a wall of red. */ export function relativeTime(from: Date, now: number): string { const elapsed = now - from.getTime(); // A clock skew between phone and server must not render "in 3 seconds". if (elapsed < SECOND) return 'just now'; if (elapsed < MINUTE) return `${Math.floor(elapsed / SECOND)}s ago`; if (elapsed < HOUR) return `${Math.floor(elapsed / MINUTE)}m ago`; if (elapsed < DAY) return `${Math.floor(elapsed / HOUR)}h ago`; return `${Math.floor(elapsed / DAY)}d ago`; } /** Convenience: relative time for a server string, or null when unparseable. */ export function relativeSince(value: string | null | undefined, now: number): string | null { const date = parseServerTime(value); return date ? relativeTime(date, now) : null; } /** * Time left before an alert self-destructs, e.g. `Ends in 6d 4h` / `Ends in * 12m`. Null when there is no deadline or it cannot be parsed; a deadline in * the past renders `Ended` (the service will 404 from then on). */ export function remainingLifetime(expiresAt: string | null | undefined, now: number): string | null { const date = parseServerTime(expiresAt); if (!date) return null; const left = date.getTime() - now; if (left <= 0) return 'Ended'; if (left < MINUTE) return 'Ends in under a minute'; if (left < HOUR) return `Ends in ${Math.floor(left / MINUTE)}m`; if (left < DAY) { const hours = Math.floor(left / HOUR); const minutes = Math.floor((left % HOUR) / MINUTE); return minutes ? `Ends in ${hours}h ${minutes}m` : `Ends in ${hours}h`; } const days = Math.floor(left / DAY); const hours = Math.floor((left % DAY) / HOUR); return hours ? `Ends in ${days}d ${hours}h` : `Ends in ${days}d`; } /** True when the deadline is present and already past. */ export function isPastDeadline(expiresAt: string | null | undefined, now: number): boolean { const date = parseServerTime(expiresAt); return !!date && date.getTime() <= now; } /** Local wall-clock time for the accessibility label of a relative stamp. */ export function absoluteLocal(date: Date): string { const hh = String(date.getHours()).padStart(2, '0'); const mm = String(date.getMinutes()).padStart(2, '0'); const ss = String(date.getSeconds()).padStart(2, '0'); return `${hh}:${mm}:${ss}`; }