// Daily Ritual — the day's "calm set", a finite daily loop. Derived ENTIRELY // from signals we already store (game state in localStorage + a brief-seen flag) // — no backend, no new sync. // // "Today" is ALWAYS the server's puzzle/brief date passed in by the caller, // never the browser's local date, so the daily reset matches the site's actual // daily content (a reader in another timezone never sees "fresh set tomorrow" // while today's puzzle is still up). // // Spirit (Claude + Codex): gentle and non-instrumental. "Enjoyed," not // "completed"; "N of M · fresh set tomorrow," never "finish the rest." Brief // counts only when the end-cap is reached (the finite read), not on page open. // // The set is CURATABLE: only daily-cadence "one-a-day, finish-and-done" things // are eligible (never Free Play / ambient toys), and the reader chooses which of // those fill THEIR set (settings → Calm set). Default = all eligible items. const BRIEF_SEEN_KEY = (date) => `goodnews:briefSeen:${date}`; function read(key) { try { return JSON.parse(localStorage.getItem(key) || 'null'); } catch { return null; } } // Mark the brief as enjoyed for `date` — call only when the reader actually // reaches the end-cap, not merely on open. export function markBriefSeen(date) { if (!date) return; try { localStorage.setItem(BRIEF_SEEN_KEY(date), '1'); } catch { /* ignore */ } } export function briefSeen(date) { if (!date) return false; try { return localStorage.getItem(BRIEF_SEEN_KEY(date)) === '1'; } catch { return false; } } // A word is enjoyed once you've seen it through (won or lost) on either length. function wordEnjoyed(date) { for (const v of ['5', '6']) { const s = read(`goodnews:word:${v}:${date}`); if (s && (s.status === 'won' || s.status === 'lost')) return true; } return false; } // A word search is enjoyed once any size is cleared. function wordsearchEnjoyed(date) { for (const sz of ['small', 'med', 'large']) { const s = read(`goodnews:wordsearch:${sz}:${date}`); if (s && s.status === 'done') return true; } return false; } // Bloom is enjoyed once the daily reaches the top tier (Flourishing) — the day's // goal, the "saw it through" point (BloomGame persists `top`). Free Play doesn't count. function bloomEnjoyed(date) { const s = read(`goodnews:bloom:${date}`); return !!(s && s.top); } // Memory Match is enjoyed once ANY daily board (any tier/format) is cleared — the // reader's chosen mood-level counts; Free Play never does. const MATCH_VARIANTS = ['gentle', 'standard', 'expert'].flatMap( (t) => ['icons', 'colors'].map((f) => `${t}-${f}`)); function matchEnjoyed(date) { for (const v of MATCH_VARIANTS) { const s = read(`goodnews:match:${v}:${date}`); if (s && s.done) return true; } return false; } // Eligible daily-cadence items, in display order. New daily games join here. export const RITUAL_ITEMS = [ { key: 'brief', label: 'Brief', href: '/', done: briefSeen }, { key: 'word', label: 'Daily Word', href: '/play?game=word', done: wordEnjoyed }, { key: 'wordsearch', label: 'Word Search', href: '/play?game=wordsearch', done: wordsearchEnjoyed }, { key: 'bloom', label: 'Bloom', href: '/play?game=bloom&v=daily', done: bloomEnjoyed }, { key: 'match', label: 'Memory Match', href: '/play?game=match&v=daily-icons-standard', done: matchEnjoyed }, ]; const ALL_KEYS = RITUAL_ITEMS.map((i) => i.key); // The default (out-of-box) set is a CURATED subset of the eligible items, kept // separate from RITUAL_ITEMS on purpose: adding a new eligible daily game must // NOT auto-bloat every uncustomized reader's set. A new game becomes selectable // in the chooser immediately; whether it joins the default is a deliberate // case-by-case decision (add its key here). `null` pref = "follow this default." export const DEFAULT_KEYS = ['brief', 'word', 'wordsearch', 'bloom']; // Normalize the reader's selection: null/undefined → the curated default; // an array → those keys in canonical order; unknown keys ignored. export function ritualKeys(enabled) { if (!Array.isArray(enabled)) return DEFAULT_KEYS.filter((k) => ALL_KEYS.includes(k)); return ALL_KEYS.filter((k) => enabled.includes(k)); } // The day's ritual snapshot for the reader's chosen set. Reads localStorage on // each call (cheap) — callers recompute on mount / navigation / focus. export function ritualState(date, enabled) { const keys = ritualKeys(enabled); const items = RITUAL_ITEMS .filter((i) => keys.includes(i.key)) .map((i) => ({ key: i.key, label: i.label, href: i.href, done: i.done(date) })); return { items, count: items.filter((i) => i.done).length, total: items.length }; }