diff --git a/internal/ui/live_menu.go b/internal/ui/live_menu.go index afa9417..a59f1a2 100644 --- a/internal/ui/live_menu.go +++ b/internal/ui/live_menu.go @@ -106,6 +106,7 @@ func sessionMenuHTML(id, class, bodyClass, itemClass, toggleID, themeIconClass, {Label: "Appearance", Suffix: template.HTML(""), Attrs: `data-action="theme"`}, {Label: "Notifications", Suffix: template.HTML("OFF"), ExtraClass: itemClass + "-toggle", Attrs: `data-action="notifications"`}, {Label: "Spinner", Suffix: template.HTML("RUNCAT"), ExtraClass: itemClass + "-toggle", Attrs: `data-action="spinner"`}, + {Label: "Cat Gatekeeper", Suffix: "", Attrs: `data-action="cat-gatekeeper"`}, }}, {Title: "Development", Items: []liveMenuItem{ {Label: "Resume via Terminal", Attrs: `data-action="terminal"`}, diff --git a/internal/ui/live_templates/assets/cat.webm b/internal/ui/live_templates/assets/cat.webm new file mode 100644 index 0000000..68281ad Binary files /dev/null and b/internal/ui/live_templates/assets/cat.webm differ diff --git a/internal/ui/live_templates/styles/session.css b/internal/ui/live_templates/styles/session.css index f4d1a65..fa45ed1 100644 --- a/internal/ui/live_templates/styles/session.css +++ b/internal/ui/live_templates/styles/session.css @@ -3836,5 +3836,199 @@ color: var(--error, #cc6666); } + /* =================================================================== + Cat Gatekeeper — focus/break + bedtime overlay (live app only). + The overlay DOM is created by web/src/session/cat-gatekeeper/. + =================================================================== */ + .cat-overlay { + position: fixed; + inset: 0; + z-index: 2147483000; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + background: radial-gradient(circle at 50% 40%, rgba(20, 22, 30, 0.92), rgba(8, 9, 13, 0.98)); + opacity: 0; + visibility: hidden; + transition: opacity 0.45s ease, visibility 0s linear 0.45s; + -webkit-user-select: none; + user-select: none; + } + + .cat-overlay.visible { + opacity: 1; + visibility: visible; + transition: opacity 0.45s ease; + } + + .cat-overlay-hidden { + pointer-events: none; + } + + .cat-overlay--sleep, + .cat-overlay--locked { + background: radial-gradient(circle at 50% 35%, rgba(14, 16, 34, 0.95), rgba(4, 4, 12, 0.99)); + } + + /* Full-screen stage; the cat fills it and the countdown floats in its own + box up top so text never lands on the cat (à la Cat Gatekeeper). */ + .cat-overlay-inner { + position: absolute; + inset: 0; + } + + .cat-art { + position: absolute; + inset: 0; + width: 100vw; + height: 100vh; + line-height: 0; + } + + /* The cat is a background-removed (alpha) clip, so it floats full-screen + with no card/frame — just a soft shadow — and slides in from the side. */ + .cat-video { + display: block; + width: 100%; + height: 100%; + object-fit: contain; + filter: drop-shadow(0 18px 38px rgba(0, 0, 0, 0.55)); + } + + /* Our cat is lounging, not walking, so a horizontal slide looks off — it + just gently fades and settles into view instead. */ + .cat-overlay.visible .cat-art { + animation: cat-settle-in 0.9s cubic-bezier(0.22, 1, 0.36, 1) both; + } + + @keyframes cat-settle-in { + from { opacity: 0; transform: translateY(28px) scale(0.94); } + to { opacity: 1; transform: translateY(0) scale(1); } + } + + /* Bedtime cat reads calmer/dimmed. */ + .cat-overlay--sleep .cat-video, + .cat-overlay--locked .cat-video { + filter: grayscale(0.55) brightness(0.62) saturate(0.8) drop-shadow(0 18px 38px rgba(0, 0, 0, 0.55)); + } + + .cat-timer, + .cat-message { + position: absolute; + top: 7vh; + left: 50%; + transform: translateX(-50%); + z-index: 2; + text-align: center; + font-family: var(--font-sans, sans-serif); + background: rgba(0, 0, 0, 0.58); + border-radius: 22px; + backdrop-filter: blur(2px); + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.45); + } + + .cat-timer { + font-size: clamp(64px, 13vw, 150px); + font-weight: 700; + color: #ffffff; + letter-spacing: 2px; + line-height: 1; + font-variant-numeric: tabular-nums; + padding: 22px 52px; + } + + .cat-message { + font-size: clamp(22px, 4vw, 40px); + font-weight: 600; + color: rgba(255, 255, 255, 0.95); + line-height: 1.25; + max-width: 80vw; + padding: 22px 40px; + } + + .cat-overlay--locked .cat-message { + color: rgba(207, 211, 255, 0.95); + } + + /* --- settings sheet form --- */ + .cat-settings { display: flex; flex-direction: column; gap: 4px; } + + .cat-settings-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 4px; + border-bottom: 1px solid var(--border); + cursor: pointer; + } + .cat-settings-row:last-of-type { border-bottom: none; } + + .cat-settings-label { + font-size: 14px; + color: var(--text); + font-weight: 500; + } + + .cat-settings-hint { + font-size: 12px; + color: var(--muted); + font-weight: 400; + margin-top: 3px; + } + + .cat-settings-number, + .cat-settings-time { + flex: 0 0 auto; + width: 90px; + padding: 6px 8px; + font: inherit; + font-size: 14px; + text-align: right; + color: var(--text); + background: var(--container-bg); + border: 1px solid var(--border); + border-radius: 6px; + } + .cat-settings-time { width: 110px; } + + .cat-settings-toggle { + width: 18px; + height: 18px; + accent-color: var(--accent); + cursor: pointer; + } + + .cat-settings-status { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 14px; + padding: 14px; + background: var(--container-bg); + border: 1px solid var(--border); + border-radius: 8px; + } + + .cat-settings-status-text { + font-size: 13px; + color: var(--text-soft, var(--muted)); + } + + .cat-settings-skip { + flex: 0 0 auto; + padding: 8px 14px; + font: inherit; + font-size: 13px; + font-weight: 600; + color: var(--bg, #111); + background: var(--accent); + border: none; + border-radius: 6px; + cursor: pointer; + } + .cat-settings-skip:hover { filter: brightness(1.08); } diff --git a/internal/ui/pwa.go b/internal/ui/pwa.go index fe832c3..3301053 100644 --- a/internal/ui/pwa.go +++ b/internal/ui/pwa.go @@ -1,8 +1,10 @@ package ui import ( + "bytes" _ "embed" "net/http" + "time" ) //go:embed live_templates/assets/manifest.webmanifest @@ -26,6 +28,9 @@ var CatMP3 []byte //go:embed live_templates/assets/done.mp3 var DoneMP3 []byte +//go:embed live_templates/assets/cat.webm +var catWebm []byte + //go:embed live_templates/styles/theme.css var themeCSS string @@ -70,6 +75,11 @@ func RegisterPWAHandlers(mux *http.ServeMux) { w.Header().Set("Cache-Control", "public, max-age=86400") _, _ = w.Write([]byte(piLogoSVG)) }) + mux.HandleFunc("/cat.webm", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "video/webm") + w.Header().Set("Cache-Control", "public, max-age=86400") + http.ServeContent(w, r, "cat.webm", time.Time{}, bytes.NewReader(catWebm)) + }) mux.HandleFunc("/theme.css", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/css; charset=utf-8") w.Header().Set("Cache-Control", "no-cache") diff --git a/web/src/session/cat-gatekeeper/cat-gatekeeper.js b/web/src/session/cat-gatekeeper/cat-gatekeeper.js new file mode 100644 index 0000000..4d05cd2 --- /dev/null +++ b/web/src/session/cat-gatekeeper/cat-gatekeeper.js @@ -0,0 +1,328 @@ +/** + * Cat Gatekeeper — a focus/break companion for pi-web. + * + * A background pomodoro timer counts down "focus" time only while the user is + * actively in the pi-web tab (the timer pauses on blur / hidden / idle). When + * focus runs out a full-screen cat overlay enforces a break with a countdown. + * A separate bedtime triggers a sleepy cat that, after a short reminder, locks + * pi-web for the rest of the session. + * + * State is intentionally per-session for the sleep lock (a reload clears it), + * while the remaining focus time survives short reloads via localStorage. + */ + +import { loadCatSettings, showCatSettings } from './cat-settings.js'; + +const TICK_MS = 1000; +// Cap how much a single focus tick can subtract so a throttled/backgrounded +// tab that fires a delayed tick can't burn a big chunk of focus time at once. +const MAX_FOCUS_STEP_MS = 2000; +// How long after bedtime the goodnight nudge still fires (handles opening +// pi-web well after bedtime, including past midnight). +const SLEEP_WINDOW_MIN = 8 * 60; +// Remaining-focus persistence is only trusted across short reloads. +const FOCUS_RESTORE_MAX_AGE_MS = 30 * 60 * 1000; + +const FOCUS_REMAINING_KEY = 'pi-web:v1:cat:focus-remaining-ms'; +const FOCUS_SAVED_AT_KEY = 'pi-web:v1:cat:focus-saved-at'; + +// The cat ships as a looping, muted WebM (served at /cat.webm). Both the break +// and the sleepy bedtime overlay reuse it; the sleepy look is a CSS filter. +const CAT_VIDEO_SRC = '/cat.webm'; +function catVideoHTML() { + return ``; +} + +function formatMMSS(ms) { + const total = Math.max(0, Math.ceil(ms / 1000)); + const m = Math.floor(total / 60); + const s = total % 60; + return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; +} + +function bedtimeToMinutes(bedtime) { + const match = /^(\d{1,2}):(\d{2})$/.exec(String(bedtime)); + if (!match) return 23 * 60; + return Number(match[1]) * 60 + Number(match[2]); +} + +export function setupCatGatekeeper({ + documentImpl = document, + windowImpl = window, + storage = windowImpl.localStorage, + nowFn = () => Date.now(), + setIntervalImpl = (typeof windowImpl.setInterval === 'function' ? windowImpl.setInterval.bind(windowImpl) : () => 0), + clearIntervalImpl = (typeof windowImpl.clearInterval === 'function' ? windowImpl.clearInterval.bind(windowImpl) : () => {}), + requestAnimationFrameImpl = (cb) => (typeof windowImpl.requestAnimationFrame === 'function' ? windowImpl.requestAnimationFrame(cb) : cb()), +} = {}) { + let overlay = null; + let inputBlockers = null; + let intervalId = null; + + const state = { + phase: 'focus', // focus | break | sleep | sleep-locked + focusRemainingMs: 0, + breakRemainingMs: 0, + sleepElapsedMs: 0, + sleepTriggered: false, + lastTickAt: nowFn(), + }; + + function settings() { + return loadCatSettings({ storage }); + } + + function isActive() { + const hidden = documentImpl.hidden === true || documentImpl.visibilityState === 'hidden'; + let focused = true; + try { + if (typeof documentImpl.hasFocus === 'function') focused = documentImpl.hasFocus(); + } catch { /* assume focused */ } + return !hidden && focused; + } + + function persistFocus() { + try { + storage?.setItem(FOCUS_REMAINING_KEY, String(Math.max(0, Math.round(state.focusRemainingMs)))); + storage?.setItem(FOCUS_SAVED_AT_KEY, String(nowFn())); + } catch { /* ignore */ } + } + + function restoreFocus(focusTotalMs) { + try { + const remaining = Number(storage?.getItem(FOCUS_REMAINING_KEY)); + const savedAt = Number(storage?.getItem(FOCUS_SAVED_AT_KEY)); + if (Number.isFinite(remaining) && Number.isFinite(savedAt) + && remaining > 0 && remaining <= focusTotalMs + && nowFn() - savedAt <= FOCUS_RESTORE_MAX_AGE_MS) { + return remaining; + } + } catch { /* ignore */ } + return focusTotalMs; + } + + function inSleepWindow(bedtime) { + const d = new Date(nowFn()); + const cur = d.getHours() * 60 + d.getMinutes(); + let diff = (cur - bedtimeToMinutes(bedtime)) % 1440; + if (diff < 0) diff += 1440; + return diff < SLEEP_WINDOW_MIN; + } + + // --- overlay ------------------------------------------------------------- + + function ensureOverlay() { + if (overlay && documentImpl.body.contains(overlay)) return overlay; + overlay = documentImpl.createElement('div'); + overlay.id = 'cat-gatekeeper-overlay'; + overlay.className = 'cat-overlay'; + overlay.setAttribute('aria-hidden', 'true'); + overlay.innerHTML = ` +
+
+
+
+
`; + documentImpl.body.appendChild(overlay); + return overlay; + } + + function blockInput() { + if (inputBlockers) return; + const swallow = (e) => { e.preventDefault(); e.stopPropagation(); }; + const swallowWheel = (e) => { e.preventDefault(); }; + documentImpl.addEventListener('keydown', swallow, true); + documentImpl.addEventListener('wheel', swallowWheel, { capture: true, passive: false }); + documentImpl.addEventListener('touchmove', swallowWheel, { capture: true, passive: false }); + inputBlockers = { swallow, swallowWheel }; + try { documentImpl.activeElement?.blur?.(); } catch { /* ignore */ } + } + + function unblockInput() { + if (!inputBlockers) return; + documentImpl.removeEventListener('keydown', inputBlockers.swallow, true); + documentImpl.removeEventListener('wheel', inputBlockers.swallowWheel, { capture: true }); + documentImpl.removeEventListener('touchmove', inputBlockers.swallowWheel, { capture: true }); + inputBlockers = null; + } + + function showOverlay(variant) { + const el = ensureOverlay(); + el.classList.remove('cat-overlay--break', 'cat-overlay--sleep', 'cat-overlay--locked', 'cat-overlay-hidden'); + el.classList.add(`cat-overlay--${variant}`); + el.setAttribute('aria-hidden', 'false'); + const art = el.querySelector('[data-cat-art]'); + if (art && !art.querySelector('video')) art.innerHTML = catVideoHTML(); + const video = art?.querySelector('video'); + if (video) { + try { + video.currentTime = 0; + video.playbackRate = 0.6; // calmer, slower cat + const p = video.play(); + if (p && p.catch) p.catch(() => {}); + } catch { /* ignore */ } + } + blockInput(); + requestAnimationFrameImpl(() => el.classList.add('visible')); + } + + function hideOverlay() { + unblockInput(); + if (!overlay) return; + overlay.classList.remove('visible'); + overlay.setAttribute('aria-hidden', 'true'); + overlay.classList.add('cat-overlay-hidden'); + } + + function renderBreak() { + const el = ensureOverlay(); + const timer = el.querySelector('[data-cat-timer]'); + const msg = el.querySelector('[data-cat-message]'); + if (timer) { timer.style.display = ''; timer.textContent = formatMMSS(state.breakRemainingMs); } + // Break shows only the countdown box (no message overlapping the cat). + if (msg) { msg.textContent = ''; msg.style.display = 'none'; } + } + + function renderSleep(locked) { + const el = ensureOverlay(); + const timer = el.querySelector('[data-cat-timer]'); + const msg = el.querySelector('[data-cat-message]'); + if (timer) timer.style.display = 'none'; + if (msg) { msg.style.display = ''; msg.textContent = locked ? 'Locked for the night — get some rest.' : 'Time to sleep!'; } + if (locked) el.classList.add('cat-overlay--locked'); + } + + // --- phase transitions --------------------------------------------------- + + function enterBreak() { + state.phase = 'break'; + state.breakRemainingMs = settings().breakMin * 60000; + showOverlay('break'); + renderBreak(); + } + + function endBreak() { + state.phase = 'focus'; + state.focusRemainingMs = settings().focusMin * 60000; + persistFocus(); + hideOverlay(); + } + + function enterSleep() { + state.sleepTriggered = true; + state.phase = 'sleep'; + state.sleepElapsedMs = 0; + showOverlay('sleep'); + renderSleep(false); + } + + function tick() { + const now = nowFn(); + const realDelta = Math.max(0, now - state.lastTickAt); + state.lastTickAt = now; + + const cfg = settings(); + if (!cfg.enabled) { + if (state.phase !== 'focus') { state.phase = 'focus'; hideOverlay(); } + return; + } + + // Bedtime overrides everything and, once triggered, is sticky for the session. + if (state.phase !== 'sleep' && state.phase !== 'sleep-locked' + && !state.sleepTriggered && isActive() && inSleepWindow(cfg.bedtime)) { + enterSleep(); + return; + } + + switch (state.phase) { + case 'focus': { + if (isActive()) { + state.focusRemainingMs -= Math.min(realDelta, MAX_FOCUS_STEP_MS); + persistFocus(); + } + if (state.focusRemainingMs <= 0) enterBreak(); + break; + } + case 'break': { + state.breakRemainingMs -= realDelta; + if (state.breakRemainingMs <= 0) endBreak(); + else renderBreak(); + break; + } + case 'sleep': { + state.sleepElapsedMs += realDelta; + if (state.sleepElapsedMs >= cfg.sleepMin * 60000) { + state.phase = 'sleep-locked'; + renderSleep(true); + } + break; + } + default: + break; + } + } + + // --- public API ---------------------------------------------------------- + + function getStatusText() { + const cfg = settings(); + if (!cfg.enabled) return 'Cat Gatekeeper is off.'; + switch (state.phase) { + case 'break': return `On a break — ${formatMMSS(state.breakRemainingMs)} left.`; + case 'sleep': + case 'sleep-locked': return 'Bedtime — time to sleep.'; + default: return `Next break in ${formatMMSS(state.focusRemainingMs)}.`; + } + } + + function skipToBreak() { + if (!settings().enabled) return; + if (state.phase === 'focus') enterBreak(); + } + + function openSettings() { + return showCatSettings({ + documentImpl, + windowImpl, + storage, + controller: { getStatusText, skipToBreak }, + onChange: (next) => { + // Apply focus duration changes immediately when idle in focus phase so + // a longer/shorter focus setting takes effect without a reload. + if (state.phase === 'focus') { + const focusTotal = next.focusMin * 60000; + if (state.focusRemainingMs > focusTotal) state.focusRemainingMs = focusTotal; + persistFocus(); + } + if (!next.enabled && (state.phase === 'break')) { + // Disabling mid-break releases the user. + state.phase = 'focus'; + state.focusRemainingMs = next.focusMin * 60000; + hideOverlay(); + } + }, + }); + } + + function start() { + const focusTotal = settings().focusMin * 60000; + state.focusRemainingMs = restoreFocus(focusTotal); + state.lastTickAt = nowFn(); + // Greet immediately if pi-web is opened after bedtime. Guarded so a hostile + // or mocked environment can't break the rest of session init. + try { tick(); } catch { /* ignore */ } + intervalId = setIntervalImpl(tick, TICK_MS); + return controller; + } + + function destroy() { + if (intervalId != null) clearIntervalImpl(intervalId); + intervalId = null; + unblockInput(); + if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay); + overlay = null; + } + + const controller = { start, destroy, tick, getStatusText, skipToBreak, openSettings, getState: () => ({ ...state }) }; + return controller; +} diff --git a/web/src/session/cat-gatekeeper/cat-gatekeeper.test.js b/web/src/session/cat-gatekeeper/cat-gatekeeper.test.js new file mode 100644 index 0000000..fa62170 --- /dev/null +++ b/web/src/session/cat-gatekeeper/cat-gatekeeper.test.js @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest'; +import { JSDOM } from 'jsdom'; +import { setupCatGatekeeper } from './cat-gatekeeper.js'; +import { saveCatSettings } from './cat-settings.js'; + +function makeStorage(initial = {}) { + const map = new Map(Object.entries(initial)); + return { + getItem: (k) => (map.has(k) ? map.get(k) : null), + setItem: (k, v) => map.set(k, String(v)), + removeItem: (k) => map.delete(k), + }; +} + +// Build a harness with a controllable clock and active/visibility state. +function harness({ hour = 10, minute = 0, settings } = {}) { + const dom = new JSDOM(''); + const doc = dom.window.document; + const storage = makeStorage(); + if (settings) saveCatSettings(settings, { storage }); + + const base = new Date(2026, 4, 31, hour, minute, 0).getTime(); + const clock = { ms: 0 }; + const nowFn = () => base + clock.ms; + + const focusState = { active: true }; + Object.defineProperty(doc, 'hidden', { value: false, configurable: true }); + doc.hasFocus = () => focusState.active; + + const controller = setupCatGatekeeper({ + documentImpl: doc, + windowImpl: dom.window, + storage, + nowFn, + setIntervalImpl: () => 0, + clearIntervalImpl: () => {}, + requestAnimationFrameImpl: (cb) => cb(), + }); + + const advance = (ms) => { clock.ms += ms; }; + const tick = (ms = 1000) => { advance(ms); controller.tick(); }; + const overlay = () => doc.getElementById('cat-gatekeeper-overlay'); + + return { dom, doc, storage, controller, focusState, advance, tick, overlay }; +} + +describe('cat gatekeeper timer', () => { + it('decrements focus only while active', () => { + const h = harness({ settings: { focusMin: 25, breakMin: 5 } }); + h.controller.start(); + + h.focusState.active = false; + const before = h.controller.getState().focusRemainingMs; + h.tick(2000); + expect(h.controller.getState().focusRemainingMs).toBe(before); + + h.focusState.active = true; + h.tick(2000); + expect(h.controller.getState().focusRemainingMs).toBe(before - 2000); + }); + + it('enters break when focus runs out and shows the countdown overlay', () => { + const h = harness({ settings: { focusMin: 1, breakMin: 5 } }); + h.controller.start(); + + // Drain 60s of focus in 2s clamped steps. + for (let i = 0; i < 35; i++) h.tick(2000); + + expect(h.controller.getState().phase).toBe('break'); + const el = h.overlay(); + expect(el.classList.contains('cat-overlay--break')).toBe(true); + expect(el.querySelector('[data-cat-timer]').textContent).toMatch(/^0[45]:/); + }); + + it('ends the break and resets focus after the break elapses', () => { + const h = harness({ settings: { focusMin: 1, breakMin: 1 } }); + h.controller.start(); + h.controller.skipToBreak(); + expect(h.controller.getState().phase).toBe('break'); + + h.tick(61_000); // longer than the 1-minute break + expect(h.controller.getState().phase).toBe('focus'); + expect(h.controller.getState().focusRemainingMs).toBe(60_000); + expect(h.overlay().classList.contains('visible')).toBe(false); + }); + + it('skipToBreak jumps straight to a break', () => { + const h = harness({ settings: { focusMin: 25, breakMin: 5 } }); + h.controller.start(); + h.controller.skipToBreak(); + expect(h.controller.getState().phase).toBe('break'); + }); + + it('does nothing when disabled', () => { + const h = harness({ settings: { enabled: false, focusMin: 1 } }); + h.controller.start(); + for (let i = 0; i < 35; i++) h.tick(2000); + expect(h.controller.getState().phase).toBe('focus'); + expect(h.overlay()).toBeNull(); + }); + + it('persists remaining focus to storage', () => { + const h = harness({ settings: { focusMin: 25 } }); + h.controller.start(); + h.tick(2000); + const saved = Number(h.storage.getItem('pi-web:v1:cat:focus-remaining-ms')); + expect(saved).toBeGreaterThan(0); + expect(saved).toBeLessThanOrEqual(25 * 60000); + }); +}); + +describe('cat gatekeeper bedtime', () => { + it('shows the sleepy cat at bedtime and locks after the reminder', () => { + const h = harness({ hour: 23, minute: 0, settings: { bedtime: '23:00', sleepMin: 2 } }); + h.controller.start(); // immediate tick greets at/after bedtime + + expect(h.controller.getState().phase).toBe('sleep'); + const el = h.overlay(); + expect(el.classList.contains('cat-overlay--sleep')).toBe(true); + expect(el.querySelector('[data-cat-message]').textContent).toBe('Time to sleep!'); + + h.tick(2 * 60000 + 1000); + expect(h.controller.getState().phase).toBe('sleep-locked'); + expect(el.querySelector('[data-cat-message]').textContent).toMatch(/Locked for the night/); + }); + + it('does not trigger bedtime outside the sleep window', () => { + const h = harness({ hour: 10, minute: 0, settings: { bedtime: '23:00' } }); + h.controller.start(); + expect(h.controller.getState().phase).toBe('focus'); + }); +}); diff --git a/web/src/session/cat-gatekeeper/cat-settings.js b/web/src/session/cat-gatekeeper/cat-settings.js new file mode 100644 index 0000000..a42a72b --- /dev/null +++ b/web/src/session/cat-gatekeeper/cat-settings.js @@ -0,0 +1,192 @@ +/** + * Cat Gatekeeper settings — persistent focus/break/bedtime configuration plus + * the settings sheet UI. Pure storage helpers are exported separately so the + * controller can read settings without pulling in DOM code. + */ + +import { showSheet } from '../live/full-screen-sheet.js'; + +export const CAT_KEYS = { + enabled: 'pi-web:v1:cat:enabled', + focusMin: 'pi-web:v1:cat:focus-min', + breakMin: 'pi-web:v1:cat:break-min', + bedtime: 'pi-web:v1:cat:bedtime', + sleepMin: 'pi-web:v1:cat:sleep-min', +}; + +export const CAT_DEFAULTS = { + enabled: true, + focusMin: 25, + breakMin: 5, + bedtime: '23:00', + sleepMin: 2, +}; + +const LIMITS = { + focusMin: { min: 1, max: 240 }, + breakMin: { min: 1, max: 120 }, + sleepMin: { min: 1, max: 60 }, +}; + +function clampInt(value, { min, max }, fallback) { + if (value === null || value === undefined || value === '') return fallback; + const n = Math.round(Number(value)); + if (!Number.isFinite(n)) return fallback; + return Math.min(max, Math.max(min, n)); +} + +// Accepts "HH:MM" 24-hour time; returns a normalized string or the fallback. +export function normalizeBedtime(value, fallback = CAT_DEFAULTS.bedtime) { + if (typeof value !== 'string') return fallback; + const match = /^(\d{1,2}):(\d{2})$/.exec(value.trim()); + if (!match) return fallback; + const h = Number(match[1]); + const m = Number(match[2]); + if (h < 0 || h > 23 || m < 0 || m > 59) return fallback; + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; +} + +export function loadCatSettings({ storage = globalThis.localStorage } = {}) { + const read = (key) => { + try { return storage?.getItem(key); } catch { return null; } + }; + const enabledRaw = read(CAT_KEYS.enabled); + return { + enabled: enabledRaw === null ? CAT_DEFAULTS.enabled : enabledRaw === 'true', + focusMin: clampInt(read(CAT_KEYS.focusMin), LIMITS.focusMin, CAT_DEFAULTS.focusMin), + breakMin: clampInt(read(CAT_KEYS.breakMin), LIMITS.breakMin, CAT_DEFAULTS.breakMin), + bedtime: normalizeBedtime(read(CAT_KEYS.bedtime)), + sleepMin: clampInt(read(CAT_KEYS.sleepMin), LIMITS.sleepMin, CAT_DEFAULTS.sleepMin), + }; +} + +export function saveCatSettings(partial = {}, { storage = globalThis.localStorage } = {}) { + const write = (key, value) => { + try { storage?.setItem(key, String(value)); } catch { /* ignore */ } + }; + if ('enabled' in partial) write(CAT_KEYS.enabled, !!partial.enabled); + if ('focusMin' in partial) write(CAT_KEYS.focusMin, clampInt(partial.focusMin, LIMITS.focusMin, CAT_DEFAULTS.focusMin)); + if ('breakMin' in partial) write(CAT_KEYS.breakMin, clampInt(partial.breakMin, LIMITS.breakMin, CAT_DEFAULTS.breakMin)); + if ('bedtime' in partial) write(CAT_KEYS.bedtime, normalizeBedtime(partial.bedtime)); + if ('sleepMin' in partial) write(CAT_KEYS.sleepMin, clampInt(partial.sleepMin, LIMITS.sleepMin, CAT_DEFAULTS.sleepMin)); + return loadCatSettings({ storage }); +} + +/** + * Open the Cat Gatekeeper settings sheet. `controller` (optional) provides live + * status: getStatusText() and skipToBreak() power the "next break" row. + */ +export function showCatSettings({ + documentImpl = document, + windowImpl = window, + storage = windowImpl.localStorage, + onChange = () => {}, + controller = null, +} = {}) { + const settings = loadCatSettings({ storage }); + + return showSheet({ + title: 'Cat Gatekeeper', + showBack: true, + showClose: false, + documentImpl, + windowImpl, + renderBody: ({ bodyEl }) => { + const root = documentImpl.createElement('div'); + root.className = 'cat-settings'; + + const field = (labelText, control, hint) => { + const row = documentImpl.createElement('label'); + row.className = 'cat-settings-row'; + const text = documentImpl.createElement('div'); + text.className = 'cat-settings-label'; + text.textContent = labelText; + if (hint) { + const h = documentImpl.createElement('div'); + h.className = 'cat-settings-hint'; + h.textContent = hint; + text.appendChild(h); + } + row.appendChild(text); + row.appendChild(control); + return row; + }; + + const numberInput = (key, value) => { + const input = documentImpl.createElement('input'); + input.type = 'number'; + input.className = 'cat-settings-number'; + input.min = String(LIMITS[key].min); + input.max = String(LIMITS[key].max); + input.value = String(value); + input.addEventListener('change', () => { + const next = saveCatSettings({ [key]: input.value }, { storage }); + input.value = String(next[key]); + onChange(next); + }); + return input; + }; + + // Master toggle + const toggle = documentImpl.createElement('input'); + toggle.type = 'checkbox'; + toggle.className = 'cat-settings-toggle'; + toggle.checked = settings.enabled; + toggle.addEventListener('change', () => { + const next = saveCatSettings({ enabled: toggle.checked }, { storage }); + onChange(next); + }); + root.appendChild(field('Enable Cat Gatekeeper', toggle, 'A cat appears when it is time to rest.')); + + root.appendChild(field('Focus time (minutes)', numberInput('focusMin', settings.focusMin), 'Uninterrupted work before the cat appears.')); + root.appendChild(field('Break time (minutes)', numberInput('breakMin', settings.breakMin), 'How long the cat keeps you away.')); + + const bedtime = documentImpl.createElement('input'); + bedtime.type = 'time'; + bedtime.className = 'cat-settings-time'; + bedtime.value = settings.bedtime; + bedtime.addEventListener('change', () => { + const next = saveCatSettings({ bedtime: bedtime.value }, { storage }); + bedtime.value = next.bedtime; + onChange(next); + }); + root.appendChild(field('Bedtime', bedtime, 'When the cat says goodnight.')); + + root.appendChild(field('Sleep reminder (minutes)', numberInput('sleepMin', settings.sleepMin), 'How long the sleepy cat stays before locking.')); + + // Live status: next break + skip-to-break. + if (controller) { + const statusRow = documentImpl.createElement('div'); + statusRow.className = 'cat-settings-status'; + + const statusText = documentImpl.createElement('div'); + statusText.className = 'cat-settings-status-text'; + const updateStatus = () => { statusText.textContent = controller.getStatusText?.() || ''; }; + updateStatus(); + + const skipBtn = documentImpl.createElement('button'); + skipBtn.type = 'button'; + skipBtn.className = 'cat-settings-skip'; + skipBtn.textContent = 'Take a break now'; + skipBtn.addEventListener('click', () => { + controller.skipToBreak?.(); + updateStatus(); + }); + + statusRow.appendChild(statusText); + statusRow.appendChild(skipBtn); + root.appendChild(statusRow); + + const timer = windowImpl.setInterval(updateStatus, 1000); + root.addEventListener('cat-sheet-closed', () => windowImpl.clearInterval(timer), { once: true }); + } + + return root; + }, + onClose: () => { + // Stop the status interval when the sheet closes. + const body = documentImpl.querySelector('.cat-settings'); + body?.dispatchEvent?.(new windowImpl.Event('cat-sheet-closed')); + }, + }); +} diff --git a/web/src/session/cat-gatekeeper/cat-settings.test.js b/web/src/session/cat-gatekeeper/cat-settings.test.js new file mode 100644 index 0000000..ecf9e28 --- /dev/null +++ b/web/src/session/cat-gatekeeper/cat-settings.test.js @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { JSDOM } from 'jsdom'; +import { + CAT_DEFAULTS, + loadCatSettings, + saveCatSettings, + normalizeBedtime, + showCatSettings, +} from './cat-settings.js'; + +function makeStorage(initial = {}) { + const map = new Map(Object.entries(initial)); + return { + getItem: (k) => (map.has(k) ? map.get(k) : null), + setItem: (k, v) => map.set(k, String(v)), + removeItem: (k) => map.delete(k), + }; +} + +describe('cat settings storage', () => { + it('returns defaults when storage is empty', () => { + expect(loadCatSettings({ storage: makeStorage() })).toEqual(CAT_DEFAULTS); + }); + + it('round-trips saved values', () => { + const storage = makeStorage(); + saveCatSettings({ enabled: false, focusMin: 50, breakMin: 10, bedtime: '22:30', sleepMin: 3 }, { storage }); + expect(loadCatSettings({ storage })).toEqual({ + enabled: false, focusMin: 50, breakMin: 10, bedtime: '22:30', sleepMin: 3, + }); + }); + + it('clamps out-of-range numbers to limits', () => { + const storage = makeStorage(); + saveCatSettings({ focusMin: 9999, breakMin: 0, sleepMin: -4 }, { storage }); + const s = loadCatSettings({ storage }); + expect(s.focusMin).toBe(240); + expect(s.breakMin).toBe(1); + expect(s.sleepMin).toBe(1); + }); + + it('falls back to default for invalid stored values', () => { + const storage = makeStorage({ + 'pi-web:v1:cat:focus-min': 'abc', + 'pi-web:v1:cat:bedtime': 'not-a-time', + }); + const s = loadCatSettings({ storage }); + expect(s.focusMin).toBe(CAT_DEFAULTS.focusMin); + expect(s.bedtime).toBe(CAT_DEFAULTS.bedtime); + }); + + it('normalizes bedtime strings', () => { + expect(normalizeBedtime('9:5')).toBe(CAT_DEFAULTS.bedtime); // bad minutes format + expect(normalizeBedtime('9:05')).toBe('09:05'); + expect(normalizeBedtime('23:59')).toBe('23:59'); + expect(normalizeBedtime('24:00')).toBe(CAT_DEFAULTS.bedtime); + expect(normalizeBedtime('foo')).toBe(CAT_DEFAULTS.bedtime); + }); +}); + +describe('cat settings sheet', () => { + it('renders the form and persists the enable toggle', () => { + const dom = new JSDOM(''); + const storage = makeStorage(); + const win = dom.window; + + showCatSettings({ documentImpl: win.document, windowImpl: win, storage }); + + const root = win.document.querySelector('.cat-settings'); + expect(root).not.toBeNull(); + + const toggle = win.document.querySelector('.cat-settings-toggle'); + expect(toggle.checked).toBe(true); + toggle.checked = false; + toggle.dispatchEvent(new win.Event('change')); + expect(loadCatSettings({ storage }).enabled).toBe(false); + }); +}); diff --git a/web/src/session/live/command-menu.js b/web/src/session/live/command-menu.js index 1ebaf10..05bec9a 100644 --- a/web/src/session/live/command-menu.js +++ b/web/src/session/live/command-menu.js @@ -334,6 +334,10 @@ export function setupCommandMenu({ .catch(() => showToast('Clone failed', documentImpl, windowImpl)); break; } + case 'cat-gatekeeper': + closeMenu(); + windowImpl.__piCatGatekeeper?.openSettings?.(); + break; case 'version': closeMenu(); openVersionModal(); diff --git a/web/src/session/session.js b/web/src/session/session.js index acfbfe5..ddbfe83 100644 --- a/web/src/session/session.js +++ b/web/src/session/session.js @@ -36,6 +36,7 @@ import { setupKeyboardNav } from '../shared/keyboard-nav.js'; import { toggleTheme, syncThemeIcons } from '../shared/theme.js'; import { setupSessionListPalette } from '../shared/session-list-palette.js'; import { showShortcutsModal } from './live/shortcuts-modal.js'; +import { setupCatGatekeeper } from './cat-gatekeeper/cat-gatekeeper.js'; export { buildSessionLookups, createSessionDataModel, decodeBase64JSON, getSessionSearchParams, loadSessionData, readSessionPayload } from './data/session-data.js'; export { buildActivePathIds, buildTree, buildTreeNodeMap, buildTreePrefix, findNewestLeaf, flattenTree, getPath } from './tree/session-tree.js'; export { createTreeRenderer } from './tree/tree-renderer.js'; @@ -328,6 +329,10 @@ export function runSessionApp({ target = window } = {}) { createVersionController({ documentImpl, windowImpl: target }); + // Cat Gatekeeper — focus/break + bedtime companion. Self-paced background + // timer; settings open from the command menu (data-action="cat-gatekeeper"). + target.__piCatGatekeeper = setupCatGatekeeper({ documentImpl, windowImpl: target }).start(); + setupCommandMenu({ documentImpl, windowImpl: target,