Sync repo to deployed state: SEO recovery, Publishing Desk, Play games, emoji picker

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>
This commit is contained in:
jay
2026-06-18 11:32:27 -04:00
parent 2dbe73430c
commit 89c0fbe1f6
66 changed files with 6138 additions and 109 deletions
+105
View File
@@ -0,0 +1,105 @@
// 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 };
}