89c0fbe1f6
The deploy pipeline runs from the working tree, so a wave of shipped features
had never been committed. This snapshots git to what's actually running.
SEO impression recovery (live + verified):
- Duplicate /a/{id} now 301-redirect to their canonical twin instead of 404
(a hard 404 silently dropped already-indexed URLs and tanked impressions).
- Dedup representative selection reworked: accepted/serveable -> established
rep (URL stability) -> quality score, so an accepted page never retires to a
rejected rep and an indexed canonical doesn't churn when a newer twin arrives.
- HEAD /a/{id} returns the same status as GET (api_route GET+HEAD) instead of
falling through to the static mount and 404ing.
- `dedup --force-recluster`: cycle-locked, model-free re-cluster to re-apply the
policy to the existing corpus (shared cycle_lock context manager).
- CLI honors GOODNEWS_DB for its default --db (was silently ignored).
Publishing Desk (admin tool to post highlights to X via Web Intents):
- publishing.py queue/rank/handle-resolution; admin UI; full searchable emoji
picker (bundled data, no CDN) for the blurb editor.
Play games + site:
- Bloom (word-wheel), Memory Match, daily ritual set, Zen Den (dev-gated).
- English-only language gate; source prospecting; paywall + dedup hardening.
Tests: full suite green (349). Ignores tightened (node_modules, data/*.db).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
106 lines
4.6 KiB
JavaScript
106 lines
4.6 KiB
JavaScript
// 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 };
|
|
}
|