// Focus timer store. Single active timer at a time — the "just-start" // 2/5/10/15-minute countdown paired with micro-steps. Exposes: // - focusTimer.active: whether a timer is currently running // - focusTimer.progress: 0..1 fraction of elapsed time // - focusTimer.remainingMs: milliseconds until completion // - focusTimer.label / focusTimer.taskId: what this timer is for // - start(seconds, opts) / cancel() / extend(seconds) // // Survives window close + reopen via localStorage, because a timer // that loses its clock when the user alt-tabs is a timer that nobody // trusts. On rehydrate after expiry, fires completion then clears — // so closing the window mid-timer still gets you the "done" signal // on next launch. const STORAGE_KEY = "magnotia.focusTimer.v1"; const TICK_INTERVAL_MS = 250; export type FocusTimerPersisted = { startedAt: number; durationMs: number; taskId: string | null; label: string | null; }; function readPersisted(): FocusTimerPersisted | null { if (typeof window === "undefined") return null; try { const raw = window.localStorage.getItem(STORAGE_KEY); if (!raw) return null; const parsed = JSON.parse(raw); if ( typeof parsed?.startedAt !== "number" || typeof parsed?.durationMs !== "number" ) return null; return { startedAt: parsed.startedAt, durationMs: parsed.durationMs, taskId: parsed.taskId ?? null, label: parsed.label ?? null, }; } catch { return null; } } function writePersisted(state: FocusTimerPersisted | null): void { if (typeof window === "undefined") return; try { if (state === null) window.localStorage.removeItem(STORAGE_KEY); else window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch { /* storage may be disabled; non-fatal */ } } function createFocusTimerStore() { let startedAt = $state(null); let durationMs = $state(0); let taskId = $state(null); let label = $state(null); let now = $state(Date.now()); let completionFlashUntil = $state(0); let interval: ReturnType | null = null; const active = $derived(startedAt !== null); const elapsedMs = $derived(startedAt === null ? 0 : Math.max(0, now - startedAt)); const remainingMs = $derived(Math.max(0, durationMs - elapsedMs)); const progress = $derived(durationMs === 0 ? 0 : Math.min(1, elapsedMs / durationMs)); const finished = $derived(active && remainingMs === 0); const showingCompletionFlash = $derived(now < completionFlashUntil); // Internal completion + flash bookkeeping. We track whether we have // already fired the completion chime for the current timer so a // second tick does not re-fire it. Reset whenever a new timer starts. let completionFired = false; function tick() { now = Date.now(); // Fire completion once, the first tick after we cross remaining=0. if (startedAt !== null && !completionFired && now - startedAt >= durationMs) { completionFired = true; completionFlashUntil = now + 3000; fireCompletion(); } // After the 3 s flash window, clear everything and stop ticking. if (completionFlashUntil > 0 && now >= completionFlashUntil) { clear(); } } function startTick() { if (interval !== null) return; interval = setInterval(tick, TICK_INTERVAL_MS); } function stopTick() { if (interval !== null) { clearInterval(interval); interval = null; } } function clear() { // Broadcast cancellation so downstream listeners (Phase 6 nudge bus) // can reset their inactivity-while-timer state. Only fires when a // timer was actually active — dismissing the completion flash // after natural completion doesn't re-fire the event. const wasActive = startedAt !== null && !completionFired; startedAt = null; durationMs = 0; taskId = null; label = null; completionFlashUntil = 0; completionFired = false; writePersisted(null); stopTick(); if (wasActive && typeof window !== "undefined") { window.dispatchEvent(new CustomEvent("magnotia:focus-timer-cancelled")); } } function start(seconds: number, opts?: { taskId?: string | null; label?: string | null }): void { if (!Number.isFinite(seconds) || seconds <= 0) return; now = Date.now(); startedAt = now; durationMs = Math.floor(seconds * 1000); taskId = opts?.taskId ?? null; label = opts?.label ?? null; completionFlashUntil = 0; completionFired = false; writePersisted({ startedAt, durationMs, taskId, label }); startTick(); } function cancel(): void { clear(); } function extend(seconds: number): void { if (startedAt === null) return; if (!Number.isFinite(seconds) || seconds <= 0) return; durationMs += Math.floor(seconds * 1000); writePersisted({ startedAt, durationMs, taskId, label }); } function dismissCompletionFlash(): void { completionFlashUntil = 0; clear(); } function fireCompletion(): void { if (typeof window === "undefined") return; // Gentle chime — WebAudio so we do not ship a bundled asset. // A 440 Hz fall into 330 Hz over 220 ms, low volume, no sustain. try { type AudioCtx = typeof AudioContext; const win = window as unknown as { AudioContext?: AudioCtx; webkitAudioContext?: AudioCtx }; const Ctx = win.AudioContext ?? win.webkitAudioContext; if (!Ctx) return; const ctx = new Ctx(); const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = "sine"; osc.frequency.setValueAtTime(440, ctx.currentTime); osc.frequency.exponentialRampToValueAtTime(330, ctx.currentTime + 0.22); gain.gain.setValueAtTime(0.0001, ctx.currentTime); gain.gain.exponentialRampToValueAtTime(0.15, ctx.currentTime + 0.02); gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.28); osc.connect(gain); gain.connect(ctx.destination); osc.start(); osc.stop(ctx.currentTime + 0.3); osc.onended = () => ctx.close().catch(() => {}); } catch { /* audio is a nicety; never fatal */ } window.dispatchEvent(new CustomEvent("magnotia:focus-timer-complete", { detail: { taskId, label }, })); } // Rehydrate on first touch. If the persisted timer has already // expired, fire completion then clear so the user still gets the // "done" signal they missed while the window was closed. function rehydrate(): void { const persisted = readPersisted(); if (!persisted) return; const age = Date.now() - persisted.startedAt; if (age >= persisted.durationMs) { // Already expired while the window was closed. Fire a completion // event so downstream listeners (nudges, UI flourishes) can react. taskId = persisted.taskId; label = persisted.label; durationMs = persisted.durationMs; startedAt = persisted.startedAt; now = persisted.startedAt + persisted.durationMs; // Flash briefly so the user knows it happened. completionFlashUntil = Date.now() + 3000; completionFired = true; fireCompletion(); startTick(); return; } startedAt = persisted.startedAt; durationMs = persisted.durationMs; taskId = persisted.taskId; label = persisted.label; now = Date.now(); startTick(); } // Exposed as frozen object. Getters so derivations stay reactive. return { get active() { return active; }, get progress() { return progress; }, get elapsedMs() { return elapsedMs; }, get remainingMs() { return remainingMs; }, get durationMs() { return durationMs; }, get taskId() { return taskId; }, get label() { return label; }, get showingCompletionFlash() { return showingCompletionFlash; }, start, cancel, extend, rehydrate, dismissCompletionFlash, }; } export const focusTimer = createFocusTimerStore(); // Preset durations surfaced in the UI. 2 / 5 / 10 / 15 minutes match // the brief's guidance for the "just-start" timer and cover common // Pomodoro / shorter-focus preferences. export const FOCUS_TIMER_PRESETS_SECONDS: ReadonlyArray<{ label: string; seconds: number }> = [ { label: "2 min", seconds: 120 }, { label: "5 min", seconds: 300 }, { label: "10 min", seconds: 600 }, { label: "15 min", seconds: 900 }, ];