Compare commits

...

10 Commits

Author SHA1 Message Date
thejayman77 59ff48ae90 Game share-loop: instrument funnel, deep-link shares, /play metadata
Sharpen the existing daily-game share loop into something measurable (per Codex's
"instrument what you have, then feed people into it" plan), ahead of a Show HN launch.

Analytics:
- Per-game funnel events <game>_{arrival,started,completed,shared} (article_id=0).
  arrival = landed via a shared link (utm_source=game_share); started = first move
  (guess/find/flip); completed = solved/cleared/Full Bloom; shared = on share success.
- trackVisit() moved into the global layout so direct /play landings count; the
  server-rendered /a/ share page now creates a visitor token + sends a daily visit
  beacon (first-time /a/-only visitors were previously dropped).
- Admin "Games funnel" panel: arrivals / engaged / completed / shared, per game.

Sharing:
- Memory Match gains a Share button (it was the only game without one).
- All shares deep-link to the exact game+variant with a full https:// URL +
  utm_source=game_share (gameShareUrl helper), instead of a bare /play.
- "shared" is counted only after navigator.share()/clipboard.writeText() succeeds.

/play social metadata:
- /play served homepage canonical/OG (static SPA, ssr=false). postbuild script
  patches build/play.html's head to /play canonical/title/description/OG; fails the
  build if the homepage tags drift. Caddy try_files now serves {path}.html so /play
  is served from the patched file (snapshot in deploy/caddy/).

Tests: backend 352, frontend 27.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 16:22:06 -04:00
thejayman77 89c0fbe1f6 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>
2026-06-18 11:32:27 -04:00
thejayman77 2dbe73430c Sources: per-source paywall override (3-state) — fix domain-rule mis-flags
The Articles inspector revealed paywall is domain-coarse: nytimes.com is flagged,
so NY Times Learning's free Word-of-the-Day inherits 🔒 — and that flag isn't
cosmetic, it deprioritizes the content in feed sort + lead selection. Add a
per-source override so admins can correct it after inspecting.

- sources.paywall_override: NULL (domain rule) | 'free' | 'paywalled'.
- paywall.py: keep low-level is_paywalled(url) (domain); add is_paywalled_for_source
  (url, override) for the EFFECTIVE decision — never patched the domain helper
  globally (per Codex), so "domain says X" stays distinguishable from "overridden".
- Threaded everywhere ranking/UI touches paywall, via src.paywall_override on the
  shared _ARTICLE_COLUMNS + the source-aware helper: feed sort, /api/since, replace,
  lead selection, Article badge, brief composition (briefs.py), digest, source_health
  (table 🔒), the Articles inspector, and the review/attention check — so ranking and
  UI always agree.
- Endpoint POST /api/admin/sources/{id}/paywall {override}; admin UI: a select in the
  inspector header (Use domain rule / Treat as free / Treat as paywalled) + the basis
  ("ON (domain)" / "OFF (override)"), optimistic so the panel stays open.

Test: domain rule → paywalled in table+inspector+feed badge; 'free' → off in all
three; validation 422 + 404. 242 pytest + 11 vitest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 22:10:44 -04:00
thejayman77 7279b18fdc Articles inspector hardening (Codex audit): http(s)-only links + clamp reason
- Article titles render as external links ONLY for http(s) URLs (matches
  ArticleCard's safeHref rule); anything else is plain text — no unsafe schemes.
- reason_text clamped to 2 lines with a full-text title tooltip, so a long
  classifier reason can't make the panel visually noisy.
(Admin gate + source-scoped query confirmed already in place.)
2026-06-12 21:58:13 -04:00
thejayman77 ddcfab3a11 Admin: source Articles inspector (verify metrics against real evidence)
New per-row "Articles" button on the Sources table expands a read-only inline
panel of the source's ACTUAL ingested articles — so the automated metrics
(paywall/image/acceptance/duplicate) can be verified against evidence instead of
trusted blind. Distinct from "Check" (which re-samples the LIVE feed for
would-pass quality); this shows what's already in the DB, which is what the table
metrics are computed from.

- Backend: GET /api/admin/sources/{id}/articles?filter=&limit=&offset= (admin,
  read-only). queries.source_articles + source_articles_summary — per article:
  title, url, date, accepted, reason (the "why"), topic/flavor, paywalled
  (domain rule), has_image, duplicate. Summary = counts + source-level paywall
  rule.
- Frontend: expandable panel with a summary header ("27 ingested · 18 accepted
  · … · paywall rule: ON (domain)"), filter chips (All/Accepted/Rejected/No
  image/Duplicates), compact rows with title→link + badges + reason, Load more.

So "100% paywall" or "0% images" becomes clickable evidence: open two articles
to tell a real paywall from a mis-flagged domain, or a true image gap from an
enrichment failure. Test: test_source_articles_inspector. 241 pytest + 11 vitest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 21:37:51 -04:00
thejayman77 64339aafb0 Games: in-progress hub status + distribution-aware word-search placement (Codex)
- Play hub: word cards now surface IN-PROGRESS games too (not just won/lost) so
  "continue on another device" shows at a glance — card reads "5:3…" and the
  selection option says "Continue · 3/6".
- Word Search generator: replace "prefer any crossing" with a SCORED placement —
  score = overlap*4 - local crowding (filled neighbours that aren't crossings) —
  then pick among the best ~20%. Keeps the organic interlocking but spreads words
  across the board instead of clumping around the first-placed (longest) words.
  Every word still placed (tests green). NOTE: changes today's grid layouts, so
  an in-progress word search resets once.

237 pytest + 11 vitest green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 15:18:04 -04:00
thejayman77 de59cf49d8 Play hub: sync game status at the hub, not only on game-open
Reported sync gaps: the hub showed "Play" until you opened each game, and a
game synced only if you'd reopened it on that device (so a desktop win that was
never reopened never reached the server). Root cause: the /play hub read card
status from localStorage only and never talked to the server — sync happened
exclusively inside the game components on mount.

Now the hub itself reconciles every game (word 5/6 + wordsearch small/med/large)
with the server on load (signed-in): pushes this device's local state and writes
the merged result back to localStorage, then refreshes the cards. So statuses
appear cross-device WITHOUT opening each game, and local progress uploads even
for games not reopened. Word Search card status derived from the (completion-
gated) ms. 237 pytest + 11 vitest green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 14:59:01 -04:00
thejayman77 065ab98598 Games sync hardening (Codex audit): server-side state normalization
Don't trust client JSON at the storage layer:
- sanitize_game_state() runs before merge AND on the merged result (heals legacy
  rows). Word Search: keep only finds whose cells actually spell a real word in
  that day's grid (validated when the puzzle exists, shape-only 4-12 alpha +
  cell-length otherwise), dedupe, renumber ci. Word: validate status enum, guess
  count/length/alpha, colour-row shape, terminal answer/why.
- Completion is now derived from the real puzzle word count (foundWords ==
  expected), not a client-sent `ms` — so stats can't be inflated by junk.
- Date validated as YYYY-MM-DD at the API (400 otherwise) — no junk/future rows.

Tests: sanitizer-rejects-junk + bad-date 400; existing tests updated to use
real-shaped data (the sanitizer is a good forcing function). 237 pytest + 11
vitest green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 13:51:24 -04:00
thejayman77 dd0df64d76 Games: cross-device sync + overlap colour-blend
Two game polish items:

- Word Search: overlapping cells now multiply-blend the crossing words' colours
  (deepening to a darker shade with readable text) instead of the newest colour
  stomping the rest — matches the new interlocking grids.

- Cross-device game-state sync (signed-in): per-puzzle progress + stats now
  follow you between devices. New game_state table; server-side merge on every
  save so two devices converge regardless of push order, tailored per game:
  * Word Search → UNION of finds (monotonic; can't un-find), earliest start,
    best completion time.
  * Word → furthest-progress wins (terminal beats in-progress; more guesses
    beats fewer) — picks one device's game whole, never splices guesses.
  Stats (streak/distribution/best) derived server-side from the synced states,
  so they're consistent instead of per-device counters. Endpoints GET/PUT
  /api/games/state + GET /api/games/stats (signed-in; size-capped). Frontend is
  local-first: games paint instantly from localStorage, then reconcile in the
  background; both game components push debounced on each move and adopt the
  merge. Conflict handling unit-tested + an API two-device convergence test.

235→ tests + 11 vitest green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 13:35:20 -04:00
thejayman77 2ef0efd909 Perf: skip needless dedup re-cluster + interlock word-search grids
Two things found while chasing the recurring ~15min slowness:

- dedup.py: cluster_duplicates re-ran an O(n²) cosine pass over ALL ~3.7k
  articles and rewrote duplicate_of for every one of them EVERY cycle — even
  when nothing new arrived (embedded=0) — ~53s CPU + a large WAL commit that
  starved live API reads (/api/brief 2-7s). Now skip the re-cluster entirely
  when nothing new was embedded (clusters can't have changed). Verified: cycle
  drops from ~53s to ~1s and /api/brief stays at 20ms through a cycle, vs 2-7s
  before. (A real new article still triggers a full re-cluster.)

- games.py _build_grid: word placement took the first random valid spot, so
  words rarely crossed. Now gather valid placements and PREFER ones that cross
  an already-placed word (shared matching letter), falling back to any valid
  spot — so the grid interlocks like a real word search. Every word still
  placed (tests green). NOTE: changes today's grid layouts, so an in-progress
  word search resets once.

Also added a systemd drop-in (Nice=19/CPUWeight=20/IOWeight=10/ionice-idle) to
deprioritize the batch cycle — minor, the dedup skip is the real fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 12:35:01 -04:00
79 changed files with 7579 additions and 168 deletions
+2
View File
@@ -1,8 +1,10 @@
__pycache__/
*.py[cod]
.venv/
node_modules/
*.egg-info/
data/*.sqlite3
data/*.sqlite3-*
data/*.db
logs/
+148
View File
@@ -0,0 +1,148 @@
# SNAPSHOT (read-only) of the live Caddy config.
# Live source of truth: /home/jay/srv/caddy/caddy-config/Caddyfile (mounted into the 'caddy' container).
# Captured so the upbeatbytes try_files {path} {path}.html change is tracked. Do not edit here expecting it to deploy.
{
email thejayman77@gmail.com
}
tjm77.com, www.tjm77.com {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
root * /srv/sites/tjm77
file_server
encode gzip zstd
log {
output file /data/access-tjm77.log
}
}
jsj-designs.com, www.jsj-designs.com {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
root * /srv/sites/jsj
file_server
encode gzip zstd
log {
output file /data/access-jsj.log
}
}
# Canonical host = apex. www redirects to it BEFORE the app, so OAuth always
# starts from the same host its callback uses (the ub_oauth cookie is host-only;
# starting from www then bouncing to the apex callback loses it → error=google).
www.upbeatbytes.com {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
redir https://upbeatbytes.com{uri} permanent
}
upbeatbytes.com {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
encode gzip zstd
# Dynamic API + server-rendered pages (share, digest, sitemap) → FastAPI.
@api path /api/* /healthz /docs /docs/* /openapi.json /a/* /today /sitemap.xml
handle @api {
reverse_proxy upbeatbytes-api:8000
}
# Everything else → the static SvelteKit SPA. try_files falls back to
# index.html so deep client routes (e.g. /auth/verify) boot the app
# instead of 404ing.
handle {
root * /srv/sites/upbeatbytes
# Content-hashed assets never change for a given URL — cache them forever.
@immutable path /_app/immutable/*
header @immutable Cache-Control "public, max-age=31536000, immutable"
# The SPA shell: "/" and extensionless client routes (try_files → index.html).
# Briefly cacheable at the CDN edge (s-maxage) so a first paint never depends
# on this origin's uplink; browsers still revalidate every visit (max-age=0).
# A deploy propagates within ≤2min and old immutable chunks are kept for a
# 14-day grace window, so a briefly-stale shell still boots cleanly.
# (Requires a Cloudflare Cache Rule marking these paths eligible — CF does
# not cache HTML by default.)
@shell {
not path /_app/immutable/*
not path *.*
}
header @shell Cache-Control "public, max-age=0, s-maxage=120, stale-while-revalidate=600"
# Mutable FILES (service worker, version manifest, webmanifest, word lists,
# icons) must revalidate every time — a pinned stale service worker is the
# classic blank-screen cause behind a CDN.
@revalidate {
not path /_app/immutable/*
path *.*
}
header @revalidate Cache-Control "no-cache"
# Serve a route's own prerendered HTML when it exists (e.g. /play -> play.html,
# which carries its own canonical/OG metadata), else fall back to the SPA shell.
# Cache-Control matchers above run on the ORIGINAL extensionless path, so /play
# still gets the @shell header before this rewrite.
try_files {path} {path}.html /index.html
file_server
}
log {
output file /data/access-upbeatbytes.log
}
}
git.tjm77.com {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
reverse_proxy gitea:3000
encode gzip zstd
log {
output file /data/access-gitea.log
}
}
api.tjm77.com {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
encode gzip zstd
# Recipe finder (What can I make? web tier) — must precede the arbiter catch-all.
@finder path /v1/find-recipes /v1/find-recipes/*
handle @finder {
reverse_proxy finder:8090
}
# Per-device token registration → auth service.
@register path /v1/register
handle @register {
reverse_proxy auth:8070
}
# In-app feedback relay → auth service (validates the device token + SMTP-sends).
# Keeps the arbiter a pure LLM gateway. Must precede the arbiter catch-all.
@feedback path /v1/feedback
handle @feedback {
reverse_proxy auth:8070
}
# App update policy — public static JSON (no secrets). Edit ~/srv/sites/api/app-version.json
# to change what testers are prompted to update to; no redeploy needed. Precedes the catch-all.
@appversion path /v1/app-version
handle @appversion {
root * /srv/sites
rewrite * /api/app-version.json
header Content-Type application/json
file_server
}
# LLM Arbiter (everything else); Bearer auth enforced by the arbiter.
handle {
reverse_proxy arbiter:8080
}
log {
output file /data/access-arbiter.log
}
}
}
+8
View File
@@ -7,6 +7,9 @@
"": {
"name": "goodnews-web",
"version": "0.1.0",
"dependencies": {
"three": "^0.184.0"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.8.0",
@@ -1540,6 +1543,11 @@
"node": ">=18"
}
},
"node_modules/three": {
"version": "0.184.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz",
"integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg=="
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+4
View File
@@ -6,6 +6,7 @@
"scripts": {
"dev": "vite dev --host",
"build": "vite build",
"postbuild": "node scripts/patch-play-head.mjs",
"preview": "vite preview",
"test": "vitest run"
},
@@ -17,5 +18,8 @@
"svelte": "^5.1.0",
"vite": "^6.0.0",
"vitest": "^4.1.8"
},
"dependencies": {
"three": "^0.184.0"
}
}
+41
View File
@@ -0,0 +1,41 @@
// Post-build: give build/play.html its own social/canonical metadata.
//
// The app is a static SPA (ssr=false), so every prerendered shell ships app.html's
// HOMEPAGE <head> — meaning a shared /play link previews as the news homepage. Client
// svelte:head can't fix that for non-JS social scrapers (Twitter/Slack/iMessage/etc.).
// So we rewrite the static head of play.html here, at build time. Deep-linked variants
// (/play?game=…) are served the same file, so they inherit this games-hub preview.
import { readFile, writeFile } from 'node:fs/promises';
const FILE = new URL('../build/play.html', import.meta.url);
const URL_PLAY = 'https://upbeatbytes.com/play';
const TITLE = 'Play · Upbeat Bytes — calm daily games';
const DESC =
'A calm set of daily games — Daily Word, Word Search, Bloom, and Memory Match. ' +
'A friendly little break from the doomscroll.';
const subs = [
[/<title>[\s\S]*?<\/title>/, `<title>${TITLE}</title>`],
[/<meta name="description" content="[^"]*"\s*\/>/, `<meta name="description" content="${DESC}" />`],
[/<link rel="canonical" href="https:\/\/upbeatbytes\.com\/"\s*\/>/, `<link rel="canonical" href="${URL_PLAY}" />`],
[/<meta property="og:title" content="[^"]*"\s*\/>/, `<meta property="og:title" content="${TITLE}" />`],
[/<meta property="og:description" content="[^"]*"\s*\/>/, `<meta property="og:description" content="${DESC}" />`],
[/<meta property="og:url" content="https:\/\/upbeatbytes\.com\/"\s*\/>/, `<meta property="og:url" content="${URL_PLAY}" />`],
[/<meta name="twitter:title" content="[^"]*"\s*\/>/, `<meta name="twitter:title" content="${TITLE}" />`],
[/<meta name="twitter:description" content="[^"]*"\s*\/>/, `<meta name="twitter:description" content="${DESC}" />`],
];
let html = await readFile(FILE, 'utf8');
const missed = [];
for (const [re, repl] of subs) {
if (!re.test(html)) { missed.push(re.source.slice(0, 40)); continue; }
html = html.replace(re, repl);
}
// Fail loudly if the homepage head drifted — better a broken build than silently
// shipping the wrong /play preview again.
if (missed.length) {
console.error('patch-play-head: these head tags were not found (app.html changed?):\n ' + missed.join('\n '));
process.exit(1);
}
await writeFile(FILE, html);
console.log('patch-play-head: rewrote build/play.html head → /play metadata');
+98
View File
@@ -0,0 +1,98 @@
<script>
// Full searchable emoji picker for the Publishing Desk. Data is bundled locally
// (emoji-data.js) — no runtime/CDN fetch. Calls onpick(char) for each selection;
// stays open so several can be inserted in a row. Recents persist in localStorage.
import { EMOJI_GROUPS } from './emoji-data.js';
let { onpick } = $props();
let q = $state('');
let recent = $state([]);
const RECENT_KEY = 'ub.pub.emojiRecent';
$effect(() => {
try { recent = JSON.parse(localStorage.getItem(RECENT_KEY) || '[]'); } catch { recent = []; }
});
function pick(c) {
onpick(c);
const next = [c, ...recent.filter((x) => x !== c)].slice(0, 24);
recent = next;
try { localStorage.setItem(RECENT_KEY, JSON.stringify(next)); } catch { /* private mode */ }
}
// search across name + keyword slug; null when the box is empty (→ show grouped view)
const matches = $derived.by(() => {
const term = q.trim().toLowerCase();
if (!term) return null;
const res = [];
for (const g of EMOJI_GROUPS)
for (const e of g.emojis)
if (e.n.includes(term) || e.k.includes(term)) res.push(e);
return res;
});
let sections = {}; // group name -> section element, for the jump tabs
function jump(name) { sections[name]?.scrollIntoView({ block: 'start', behavior: 'smooth' }); }
</script>
<div class="ep">
<input class="ep-search" placeholder="Search emoji…" bind:value={q} autocomplete="off" />
{#if !q.trim()}
<div class="ep-tabs">
{#if recent.length}<button type="button" title="Recent" onclick={() => jump('__recent')}>🕘</button>{/if}
{#each EMOJI_GROUPS as g (g.name)}
<button type="button" title={g.name} onclick={() => jump(g.name)}>{g.emojis[0].c}</button>
{/each}
</div>
{/if}
<div class="ep-scroll">
{#if matches}
{#if matches.length}
<div class="ep-grid">
{#each matches as e (e.c)}<button type="button" class="ep-em" title={e.n} onclick={() => pick(e.c)}>{e.c}</button>{/each}
</div>
{:else}
<p class="ep-none">No emoji match “{q}”.</p>
{/if}
{:else}
{#if recent.length}
<div class="ep-sec" bind:this={sections['__recent']}>
<div class="ep-h">Recent</div>
<div class="ep-grid">
{#each recent as c (c)}<button type="button" class="ep-em" onclick={() => pick(c)}>{c}</button>{/each}
</div>
</div>
{/if}
{#each EMOJI_GROUPS as g (g.name)}
<div class="ep-sec" bind:this={sections[g.name]}>
<div class="ep-h">{g.name}</div>
<div class="ep-grid">
{#each g.emojis as e (e.c)}<button type="button" class="ep-em" title={e.n} onclick={() => pick(e.c)}>{e.c}</button>{/each}
</div>
</div>
{/each}
{/if}
</div>
</div>
<style>
.ep { width: 300px; }
.ep-search { width: 100%; box-sizing: border-box; font: inherit; font-size: 0.85rem; padding: 6px 9px;
border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--ink); }
.ep-tabs { display: flex; flex-wrap: wrap; gap: 1px; margin: 6px 0; }
.ep-tabs button { font-size: 1.05rem; line-height: 1; padding: 3px; border: 0; border-radius: 6px;
background: transparent; cursor: pointer; }
.ep-tabs button:hover { background: var(--accent-soft); }
.ep-scroll { max-height: 230px; overflow-y: auto; margin-top: 4px; }
.ep-sec { scroll-margin-top: 2px; }
.ep-h { position: sticky; top: 0; background: var(--card, var(--bg)); font-size: 0.72rem; font-weight: 700;
color: var(--muted); text-transform: uppercase; letter-spacing: .04em; padding: 4px 2px; }
.ep-grid { display: grid; grid-template-columns: repeat(8, 1fr); }
.ep-em { font-size: 1.25rem; line-height: 1; padding: 4px 0; border: 0; border-radius: 7px;
background: transparent; cursor: pointer; }
.ep-em:hover { background: var(--accent-soft); }
.ep-none { font-size: 0.82rem; color: var(--muted); padding: 8px 2px; }
</style>
+16
View File
@@ -35,6 +35,22 @@ export function track(kind, articleId = 0) {
}
}
// Deep link for a shared game result: full https:// URL straight to the exact game +
// variant (not generic /play), tagged so we can attribute arrivals to the share loop.
const SITE = 'https://upbeatbytes.com';
export function gameShareUrl(game, variant) {
const v = variant ? `&v=${encodeURIComponent(variant)}` : '';
return `${SITE}/play?game=${game}${v}&utm_source=game_share`;
}
// Per-game funnel: trackGame('word', 'started'|'completed'|'shared'). Kinds must mirror
// the backend allowlist (_GAME_EVENT_KINDS in api.py). Deduped per game/kind/day server-side.
const GAME_NAMES = ['word', 'wordsearch', 'bloom', 'match'];
const GAME_EVENTS = ['started', 'completed', 'shared', 'arrival'];
export function trackGame(game, event) {
if (GAME_NAMES.includes(game) && GAME_EVENTS.includes(event)) track(`${game}_${event}`);
}
// Count a visit at most once per day per device.
export function trackVisit() {
try {
@@ -0,0 +1,484 @@
<script>
import { getJSON, postJSON } from '$lib/api.js';
import { pushGameState, fetchGameStats } from '$lib/gamesync.js';
import { trackGame, gameShareUrl } from '$lib/analytics.js';
// mode: 'daily' (shared, synced, ritual) | 'free' (local-only, infinite wheels)
// format: 'center' (center letter required) | 'wild' (any word from the 7)
let { mode = 'daily', format = 'center', onstatus } = $props();
// $derived (not const) so a prop change can't leave the mode "stuck".
let isFree = $derived(mode === 'free');
let isWild = $derived(format === 'wild');
let center = $state('');
let outer = $state([]); // Center Circle: 6 petals (order shuffles)
let wildRing = $state([]); // Wild Bloom: all 7 letters, equal (order shuffles)
let accepted = $state(new Set()); // sha256(salt:word) hex — no plaintext answers
let maxScore = $state(0);
let tiers = $state([]);
let date = $state(''); // daily mode
let seed = $state(''); // free mode (lets us resume the same wheel)
let found = $state([]); // plaintext words this device has found
let current = $state(''); // the word being built
let loading = $state(true);
let ready = $state(false);
let message = $state('');
let shake = $state(false);
let pulse = $state(false); // bloom flourish on a pangram
let fullShown = $state(false); // Full Bloom celebration latch
let reportWord = $state(''); // a rejected real-looking word offered to flag
let reported = $state(false); // "thanks" after flagging
let salt = $derived(isFree ? seed : date); // the hash salt + storage discriminator
const stateKey = $derived(isFree ? `goodnews:bloom:free:${format}` : `goodnews:bloom:${date}`);
// The active letter set matches what's actually on screen (Wild ring vs center+petals).
let activeLetters = $derived(isWild ? wildRing : [center, ...outer]);
let wheel = $derived(new Set(activeLetters));
// The flower's center: azure (Center Circle = "required") vs slate (Wild =
// "just a letter"); both bloom to jewel hues as you find words.
let centerColor = $derived(found.length ? bloomColor(found.length) : (isWild ? '#5b6b78' : 'var(--accent)'));
function scoreWord(w) {
let s = w.length === 4 ? 1 : w.length;
if (isPangram(w)) s += 7;
return s;
}
function isPangram(w) {
return w.length >= 7 && [...wheel].every((l) => w.includes(l));
}
// Curated calm jewel hues (all readable under the white letter). The flower's
// center blooms a fresh hue with each found word; each found chip keeps its own.
const PALETTE = ['#0083ad', '#117a8b', '#2e8b57', '#b8732e', '#c0563f', '#bb4a63',
'#c2569b', '#8e5bb0', '#6a5fc4', '#4f6fc6', '#b14a8a', '#2f8f8f'];
function bloomColor(n) { return n ? PALETTE[(n * 5) % PALETTE.length] : 'var(--accent)'; }
function hueFor(w) {
let h = 0;
for (const c of w) h = (h * 31 + c.charCodeAt(0)) >>> 0;
return PALETTE[h % PALETTE.length];
}
let score = $derived(found.reduce((s, w) => s + scoreWord(w), 0));
let tierIdx = $derived.by(() => { let idx = 0; tiers.forEach((t, i) => { if (score >= t.score) idx = i; }); return idx; });
let tier = $derived(tiers[tierIdx] || { name: '', score: 0 });
let nextTier = $derived(tiers[tierIdx + 1] || null);
// The ring fills toward the NEXT goal — the next tier, or (at the top tier)
// Full Bloom — so a full ring never falsely implies "every word found."
let progress = $derived.by(() => {
const start = tier.score;
const target = nextTier ? nextTier.score : maxScore;
return target > start ? Math.min(1, (score - start) / (target - start)) : 1;
});
let fullBloom = $derived(maxScore > 0 && score >= maxScore);
// Reached the top tier (Flourishing) — the daily's "saw it through" point;
// persisted so the calm-set ritual can read it without the puzzle payload.
let reachedTop = $derived(score >= (tiers.find((t) => t.name === 'Flourishing')?.score ?? Infinity));
async function sha256hex(str) {
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str));
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('');
}
function readStored() {
try { return JSON.parse(localStorage.getItem(stateKey) || 'null'); } catch { return null; }
}
async function load(forceNew = false) {
loading = true; ready = false;
found = []; current = ''; message = ''; fullShown = false;
try {
let p, savedFound = [];
if (isFree) {
const stored = forceNew ? null : readStored(); // resume the same wheel by its seed
const q = stored?.seed ? `&seed=${encodeURIComponent(stored.seed)}` : '';
p = await getJSON(`/api/puzzle/bloom/free?format=${format}${q}`);
seed = p.seed;
if (stored && stored.seed === p.seed && Array.isArray(stored.found)) savedFound = stored.found;
} else {
p = await getJSON('/api/puzzle/bloom'); // holds NO plaintext words
date = p.date;
const stored = readStored();
if (stored && Array.isArray(stored.found)) savedFound = stored.found;
}
center = p.center; outer = p.outer;
if (isWild) wildRing = [center, ...outer]; // 7 equal petals — no required center
accepted = new Set(p.accepted); maxScore = p.max_score; tiers = p.tiers;
await restore(savedFound);
} catch {
message = isFree ? 'Could not load a wheel.' : 'Could not load todays Bloom.';
}
loading = false;
requestAnimationFrame(() => (ready = true));
if (!isFree) syncNow(); // free play is local-only — never syncs
}
function newWheel() { if (!loading) load(true); } // free: deal a fresh wheel
async function restore(savedFound) {
// Keep only finds valid for THIS wheel (drops stale/junk / wrong-seed words).
const valid = [];
for (const w of (savedFound || [])) {
if (typeof w === 'string' && accepted.has(await sha256hex(`${salt}:${w}`))) valid.push(w);
}
found = valid;
persist();
if (!isFree) onstatus?.(summary());
}
function persist() {
// `full` lets the hub card show "Full Bloom" after reload; `seed` lets free
// play resume the same wheel.
const data = isFree ? { seed, found, score, full: fullBloom, top: reachedTop }
: { found, score, full: fullBloom, top: reachedTop };
try { localStorage.setItem(stateKey, JSON.stringify(data)); } catch { /* ignore */ }
if (!isFree) onstatus?.(summary()); // only the daily feeds the hub status
}
function summary() {
return { date, count: found.length, tier: tier.name, full: fullBloom };
}
// --- cross-device sync (signed-in; union of found words merged server-side) ---
let serverStats = $state(null);
let syncTimer;
async function adopt(merged) {
if (!merged || !Array.isArray(merged.found)) return;
// The server state is authoritative: it has already merged this device's push
// with any other device AND sanitized against today's accept set, so we adopt
// it wholesale — which also removes any local words the server dropped.
const valid = [];
for (const w of merged.found) {
if (typeof w === 'string' && accepted.has(await sha256hex(`${date}:${w}`))) valid.push(w);
}
found = valid;
persist();
}
async function syncNow() {
if (isFree) return; // free play is local-only
const d = date;
const merged = await pushGameState('bloom', '', d, { found, score });
if (d === date) await adopt(merged);
serverStats = await fetchGameStats('bloom', '');
}
function syncSoon() { clearTimeout(syncTimer); syncTimer = setTimeout(syncNow, 1000); }
function flash(m) { message = m; setTimeout(() => (message = ''), 1300); }
function shakeIt() { shake = true; setTimeout(() => (shake = false), 400); }
function bloomPulse() { pulse = true; setTimeout(() => (pulse = false), 700); }
function tap(l) { if (!loading) current += l; }
// Touch/mouse act on pointerdown (instant, and preventDefault stops focus-scroll).
// There is deliberately NO onclick — a click handler alongside pointerdown
// double-enters. Keyboard users type directly (global handler) or can Tab to a
// petal and press Enter/Space (onkeydown) — neither collides with pointer events.
function petalDown(e, l) { e.preventDefault(); tap(l); }
function petalKey(e, l) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); tap(l); } }
function del() { current = current.slice(0, -1); }
// Fisher-Yates, retried until it actually feels shuffled (≥minMoved positions
// change) — sort(()=>Math.random()-0.5) is biased and often leaves letters put.
function shuffled(arr, minMoved) {
let out = arr;
for (let t = 0; t < 12; t++) {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
out = a;
if (a.filter((l, i) => l !== arr[i]).length >= minMoved) break;
}
return out;
}
function shuffle() {
if (isWild) wildRing = shuffled(wildRing, 4);
else outer = shuffled(outer, 4);
}
async function submit() {
const w = current.trim().toLowerCase();
current = '';
reportWord = ''; reported = false;
if (!w) return;
if (w.length < 4) { flash('Too short'); shakeIt(); return; }
if (!isWild && !w.includes(center)) { flash('Needs the center letter'); shakeIt(); return; }
if ([...w].some((l) => !wheel.has(l))) { flash('Letters not in the bloom'); shakeIt(); return; }
if (found.includes(w)) { flash('Already found'); shakeIt(); return; }
const h = await sha256hex(`${salt}:${w}`);
if (!accepted.has(h)) {
flash('Not in the word list'); shakeIt();
reportWord = w; // it's a well-formed wheel word — offer to flag it
setTimeout(() => { if (reportWord === w) reportWord = ''; }, 7000);
return;
}
const pan = isPangram(w);
if (found.length === 0) trackGame('bloom', 'started'); // first word = started
found = [w, ...found];
flash(pan ? 'Pangram! 🌸 +' + scoreWord(w) : '+' + scoreWord(w));
if (pan) bloomPulse();
persist();
if (!isFree) syncSoon();
if (found.reduce((s, x) => s + scoreWord(x), 0) >= maxScore && !fullShown) {
fullShown = true; bloomPulse(); trackGame('bloom', 'completed'); // Full Bloom
}
}
// Spoiler-safe share: achievement + a word-length breakdown, never the words.
let copied = $state(false);
function share() {
const byLen = {};
found.forEach((w) => (byLen[w.length] = (byLen[w.length] || 0) + 1));
const breakdown = Object.keys(byLen).sort((a, b) => b - a).map((l) => `${l}×${byLen[l]}`).join(' ');
const pang = found.some(isPangram) ? ' · pangram ✓' : '';
const bloomV = mode === 'daily' ? 'daily' : (format === 'wild' ? 'free-wild' : 'free-center');
const text = `Upbeat Bytes · Bloom ${date}\n${fullBloom ? 'Full Bloom 🌸' : tier.name} · ${found.length} words${pang}\n${breakdown}\n${gameShareUrl('bloom', bloomV)}`;
if (navigator.share) navigator.share({ text }).then(() => trackGame('bloom', 'shared')).catch(() => {});
else navigator.clipboard?.writeText(text).then(() => { trackGame('bloom', 'shared'); copied = true; setTimeout(() => (copied = false), 1500); });
}
// Quiet "this should count?" — flags a rejected word for the admin queue.
async function reportMissing() {
const w = reportWord;
if (!w) return;
reported = true;
try {
await postJSON('/api/bloom/report', {
word: w, date: isFree ? null : date, mode, format,
letters: [center, ...outer].join(''), reason: 'not in the word list',
});
} catch { /* best-effort */ }
setTimeout(() => { if (reportWord === w) { reportWord = ''; reported = false; } }, 2600);
}
function onKeydown(e) {
if (e.metaKey || e.ctrlKey || e.altKey || loading) return;
const k = e.key.toLowerCase();
if (k === 'enter') { e.preventDefault(); submit(); }
else if (k === 'backspace') { e.preventDefault(); del(); }
else if (/^[a-z]$/.test(k) && wheel.has(k)) { e.preventDefault(); tap(k); }
}
$effect(() => { load(); });
</script>
<svelte:window onkeydown={onKeydown} />
<div class="bloomgame" class:ready>
{#if loading}
<p class="muted">Loading todays Bloom…</p>
{:else}
{#if isFree}<p class="freecap">Free Play · {isWild ? 'Wild Bloom' : 'Center Circle'}</p>{/if}
<!-- progress: tier name + ring toward the next goal (total stays hidden) -->
<div class="meter">
<div class="ring" style="--p:{progress}">
<span class="rscore">{found.length}</span>
</div>
<div class="tierline">
<span class="tname" class:full={fullBloom}>{fullBloom ? 'Full Bloom 🌸' : tier.name}</span>
<span class="tsub">{found.length} {found.length === 1 ? 'word' : 'words'}</span>
</div>
{#if isFree}
<button class="share top" onclick={newWheel}>New wheel</button>
{:else}
<button class="share top" onclick={share}>{copied ? 'Copied!' : 'Share'}</button>
{/if}
</div>
<!-- Fixed-height entry + feedback rows so the bloom never reflows. -->
<div class="entry" class:shake>
{#if current}
{#each current.split('') as ch, i (i)}
<span class="ec" class:cen={!isWild && ch === center}>{ch.toUpperCase()}</span>
{/each}
{:else}
<span class="ph">Type or tap letters</span>
{/if}
</div>
<!-- One fixed-height feedback slot (flash OR the "should count?" flag) so the
bloom never shifts whether or not a message/report is showing. -->
<div class="feedback">
{#if reportWord}
{#if reported}
<span class="thanks">Thanks — flagged for review 🌱</span>
{:else}
<button class="reportbtn" onclick={reportMissing}>{reportWord} should count?</button>
{/if}
{:else}
<span class="flash" class:show={!!message} class:pan={message.includes('Pangram')}>{message || ' '}</span>
{/if}
</div>
<!-- Petals: pointerdown for instant touch + no focus-scroll. Center Circle =
1 azure center letter + 6 petals. Wild = 7 equal petals ringing a small
decorative bloom dot (letterless, non-tappable — never a required letter). -->
<div class="bloom" class:pulse class:wild={isWild}>
{#if isWild}
<span class="bloomcenter" style="background: {bloomColor(found.length)}" aria-hidden="true"></span>
{#each wildRing as letter, i (letter)}
<button class="petal" style="--a:{i * (360 / 7)}deg"
onpointerdown={(e) => petalDown(e, letter)} onkeydown={(e) => petalKey(e, letter)}>{letter.toUpperCase()}</button>
{/each}
{:else}
<button class="petal center" style="background: {centerColor}"
onpointerdown={(e) => petalDown(e, center)} onkeydown={(e) => petalKey(e, center)}>{center.toUpperCase()}</button>
{#each outer as letter, i (letter)}
<button class="petal" style="--a:{i * 60}deg"
onpointerdown={(e) => petalDown(e, letter)} onkeydown={(e) => petalKey(e, letter)}>{letter.toUpperCase()}</button>
{/each}
{/if}
</div>
<!-- Controls use click so they stay keyboard-activatable (Shuffle especially);
touch-action keeps them snappy on phones. -->
<div class="controls">
<button class="ctl" onclick={del} aria-label="Delete">Delete</button>
<button class="ctl round" onclick={shuffle} aria-label="Shuffle letters">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 3h5v5"/><path d="M4 20 21 3"/><path d="M21 16v5h-5"/><path d="M15 15l6 6"/><path d="M4 4l5 5"/></svg>
</button>
<button class="ctl enter" onclick={submit}>Enter</button>
</div>
{#if fullShown}
<p class="fullmsg rise">🌸 Full Bloom — you found today's whole bloom. Lovely.</p>
{/if}
{#if found.length}
<div class="found">
{#each found.slice().sort() as w (w)}
<span class="chip" class:pan={isPangram(w)} style="background: {hueFor(w)}">{w}</span>
{/each}
</div>
{/if}
{/if}
</div>
<style>
.bloomgame { max-width: 480px; margin: 0 auto; opacity: 0; transform: translateY(6px);
display: flex; flex-direction: column; align-items: center; }
.bloomgame.ready { opacity: 1; transform: none; transition: opacity 0.3s ease, transform 0.3s ease; }
.muted { color: var(--muted); text-align: center; }
/* Everything above the found-list holds its height (flex-shrink:0) so the
height-constrained mobile column can't compress/redistribute it — only the
found-list flexes. This is what was nudging the bloom on mobile. */
.meter, .entry, .feedback, .controls, .fullmsg, .freecap { flex-shrink: 0; }
.freecap { margin: 0 0 6px; font-family: var(--label); font-size: 0.72rem; letter-spacing: 0.1em;
text-transform: uppercase; color: var(--muted); font-weight: 600; }
.meter { display: flex; align-items: center; gap: 12px; width: 100%; max-width: 360px; margin: 2px 0 14px; }
.ring {
width: 46px; height: 46px; border-radius: 50%; flex-shrink: 0;
background: conic-gradient(var(--accent) calc(var(--p) * 360deg), var(--line) 0);
display: grid; place-items: center; transition: background 0.4s ease;
}
.ring .rscore { width: 36px; height: 36px; border-radius: 50%; background: var(--surface);
display: grid; place-items: center; font-family: var(--label); font-weight: 700;
font-size: 0.95rem; color: var(--accent-deep); }
.tierline { display: flex; flex-direction: column; line-height: 1.15; margin-right: auto; }
.tname { font-family: var(--serif); font-size: 1.2rem; color: var(--accent-deep); }
.tname.full { color: #c2569b; }
.tsub { color: var(--muted); font-size: 0.8rem; }
.share.top { background: none; border: 1px solid var(--line); color: var(--accent-deep);
border-radius: 999px; padding: 6px 16px; font: inherit; font-size: 0.85rem; cursor: pointer; }
.share.top:hover { border-color: var(--accent); }
/* Fixed heights → the bloom below never shifts as you type or messages appear. */
.entry { height: 46px; display: flex; align-items: center; justify-content: center; gap: 3px; }
.entry.shake { animation: shake 0.4s ease; }
.ec { font-family: var(--label); font-weight: 700; font-size: 1.8rem; letter-spacing: 0.04em; color: var(--ink); }
.ec.cen { color: var(--accent); }
.ph { color: var(--muted); font-style: italic; font-size: 1rem; }
.feedback { height: 26px; margin: 2px 0 4px; display: flex; align-items: center; justify-content: center; }
.flash { font-family: var(--label); font-size: 0.86rem; color: var(--accent-deep);
white-space: nowrap; opacity: 0; transition: opacity 0.15s ease; }
.flash.show { opacity: 1; }
.flash.pan { color: #c2569b; font-weight: 700; }
.reportbtn { background: none; border: none; cursor: pointer; font-family: var(--label);
font-size: 0.84rem; color: var(--accent-deep); text-decoration: underline;
text-underline-offset: 3px; padding: 0; }
.reportbtn:hover { color: var(--accent); }
.thanks { font-family: var(--label); font-size: 0.84rem; color: #2e8b57; }
/* The bloom: a flower — a larger azure center surrounded by 6 evenly-spaced
circular petals. Generous gaps, no overlap (our identity, not a honeycomb). */
.bloom { position: relative; width: 300px; height: 300px; margin: 2px 0 10px; --r: 100px; flex-shrink: 0; }
.bloom.pulse { animation: bloompulse 0.7s ease; }
.petal {
position: absolute; left: 50%; top: 50%; --a: 0deg;
transform: translate(-50%, -50%) rotate(var(--a)) translateY(calc(-1 * var(--r))) rotate(calc(-1 * var(--a)));
width: 84px; height: 84px; border-radius: 50%; border: none; cursor: pointer; z-index: 1;
background: var(--surface); color: var(--accent-deep);
font-family: var(--label); font-weight: 700; font-size: 1.7rem; text-transform: uppercase;
box-shadow: inset 0 0 0 1px var(--line), 0 1px 5px rgba(60, 50, 30, 0.07);
transition: transform 0.45s cubic-bezier(.2, .8, .2, 1), background 0.12s ease, filter 0.1s ease;
touch-action: manipulation; -webkit-tap-highlight-color: transparent; user-select: none;
}
.petal.center {
z-index: 2; width: 96px; height: 96px; transform: translate(-50%, -50%);
background: var(--accent); color: #fff; box-shadow: 0 4px 14px rgba(60, 50, 30, 0.24);
/* gentle hue morph as the flower blooms (background set inline per word) */
transition: background 0.6s ease, transform 0.18s ease;
}
.petal:hover { filter: brightness(0.97); }
.petal:not(.center):active {
transform: translate(-50%, -50%) rotate(var(--a)) translateY(calc(-1 * var(--r))) rotate(calc(-1 * var(--a))) scale(0.92);
}
.petal.center:active { transform: translate(-50%, -50%) scale(0.93); }
/* Wild Bloom: 7 equal petals in an open ring around a small decorative bloom
dot (letterless, non-tappable — never reads as a required letter). */
.bloom.wild { --r: 106px; }
.bloom.wild .petal { width: 76px; height: 76px; font-size: 1.6rem; }
/* The whole center circle IS the bloom — a petal-sized disc that shifts hue
with each found word (letterless + non-tappable, so never a required letter). */
.bloomcenter {
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
width: 76px; height: 76px; border-radius: 50%; pointer-events: none; z-index: 1;
/* Ring sits OUTSIDE the fill (box-shadow, not a border) so the color reaches
the very edge — no white gap. No inset highlight (that was the white sliver). */
box-shadow: 0 0 0 2px rgba(20, 20, 20, 0.65), 0 1px 6px rgba(60, 50, 30, 0.12);
transition: background 0.6s ease;
}
.controls { display: flex; align-items: center; gap: 12px; margin: 0 0 22px; }
.ctl {
border: 1px solid var(--line); background: var(--surface); color: var(--ink);
border-radius: 999px; padding: 11px 22px; font: inherit; font-weight: 600; cursor: pointer;
box-shadow: 0 2px 0 rgba(120, 108, 84, 0.18);
touch-action: manipulation; -webkit-tap-highlight-color: transparent; user-select: none;
}
.ctl:active { transform: translateY(2px); box-shadow: none; }
.ctl.round { padding: 0; width: 46px; height: 46px; display: grid; place-items: center; }
.ctl.round svg { width: 22px; height: 22px; }
.ctl.enter { background: var(--accent); border-color: var(--accent); color: #fff;
box-shadow: 0 2px 0 var(--accent-deep); }
.ctl.enter:hover { background: var(--accent-deep); }
.fullmsg { text-align: center; color: #c2569b; font-family: var(--serif); font-style: italic;
font-size: 1.05rem; margin: 0 0 12px; }
.found { display: flex; flex-wrap: wrap; gap: 7px; justify-content: center; max-width: 440px; }
/* Each found word keeps its own hue — a little garden of finds (bg set inline). */
.chip { border: none; border-radius: 999px; padding: 5px 13px; font-size: 0.9rem; color: #fff;
text-transform: capitalize; box-shadow: 0 1px 3px rgba(40, 38, 28, 0.12);
transition: background 0.5s ease; }
.chip.pan { font-weight: 700; box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.75), 0 1px 4px rgba(40, 38, 28, 0.18); }
@keyframes shake { 0%, 100% { transform: translateX(0); } 25% { transform: translateX(-6px); } 75% { transform: translateX(6px); } }
@keyframes bloompulse { 0% { transform: scale(1); } 40% { transform: scale(1.04); } 100% { transform: scale(1); } }
@media (prefers-reduced-motion: reduce) { .entry, .bloom, .petal { animation: none !important; transition: none !important; } }
@media (max-width: 720px) {
/* /play locks the viewport (overflow:hidden), so the found list gets its own
scroll region — the bloom + controls stay put, words scroll under them. */
.bloomgame { height: 100%; max-width: 100%; }
.bloom { width: 280px; height: 280px; --r: 92px; }
.petal { width: 76px; height: 76px; font-size: 1.6rem; }
.petal.center { width: 88px; height: 88px; }
.bloom.wild { --r: 90px; }
.bloom.wild .petal { width: 64px; height: 64px; font-size: 1.45rem; }
.bloom.wild .bloomcenter { width: 64px; height: 64px; }
.found { flex: 1 1 auto; min-height: 0; width: 100%; overflow-y: auto;
align-content: flex-start; padding-bottom: calc(env(safe-area-inset-bottom) + 8px);
scrollbar-width: none; }
.found::-webkit-scrollbar { display: none; }
}
</style>
@@ -0,0 +1,272 @@
<script>
import { untrack } from 'svelte';
import { pushGameState } from '$lib/gamesync.js';
import { trackGame, gameShareUrl } from '$lib/analytics.js';
import { COLOR_BY_KEY } from '$lib/games/match/palette.js';
import { buildBoard, freeSeed } from '$lib/games/match/board.js';
import MatchIcon from './MatchIcon.svelte';
// mode: 'daily' (shared, synced, feeds the ritual) | 'free' (local-only, replayable)
// format: 'icons' | 'colors' · tier: 'gentle' | 'standard' | 'expert'
let { mode = 'daily', format = 'icons', tier = 'standard', date = '', onstatus } = $props();
let isFree = $derived(mode === 'free');
let variant = $derived(`${tier}-${format}`);
// Storage: daily keyed by the server date (cross-device synced); free keeps one
// resumable scratch board per variant.
let stateKey = $derived(isFree ? `goodnews:match:free:${variant}` : `goodnews:match:${variant}:${date}`);
const ASSIST_KEY = 'goodnews:match:assist';
let board = $state(null);
let seed = $state('');
let flipped = $state([]); // card ids face-up, not yet resolved
let matchedKeys = $state(new Set());
let moves = $state(0);
let locked = $state(false); // brief hold while a mismatch is shown
let ready = $state(false);
let assist = $state(false); // colorblind assist: show a tiny letter on swatches
let celebrated = $state(false);
let matchedIds = $derived(board ? new Set(board.cards.filter((c) => matchedKeys.has(c.key)).map((c) => c.id)) : new Set());
let done = $derived(!!board && matchedKeys.size >= board.faces.length);
function read(key) { try { return JSON.parse(localStorage.getItem(key) || 'null'); } catch { return null; } }
function write(key, v) { try { localStorage.setItem(key, JSON.stringify(v)); } catch { /* ignore */ } }
function emitStatus() {
onstatus?.({ count: matchedKeys.size, total: board?.faces.length ?? 0, moves, done });
}
function persist() {
const snap = { matched: [...matchedKeys], moves, done, seed: isFree ? seed : undefined };
write(stateKey, snap);
emitStatus();
// Daily resumes across devices; free play stays local.
if (!isFree && date) pushGameState('match', variant, date, { matched: snap.matched, moves, done });
}
let loadSeq = 0;
async function load(forceNew = false) {
const myLoad = ++loadSeq;
ready = false;
// A pending mismatch flip-back must not fire onto a freshly switched board.
if (flipTimer) { clearTimeout(flipTimer); flipTimer = null; }
try { assist = localStorage.getItem(ASSIST_KEY) === '1'; } catch { /* ignore */ }
const useFree = isFree;
let saved, theSeed;
if (useFree) {
saved = forceNew ? null : read(stateKey);
// a free board derives a stable seed so a reload resumes the same layout
theSeed = saved?.seed || freeSeed((Date.now() ^ Math.floor(Math.random() * 1e9)) >>> 0);
} else {
theSeed = date;
saved = read(stateKey);
// pull/merge the server's copy first (cross-device resume)
if (date) {
const merged = await pushGameState('match', variant, date, {
matched: saved?.matched || [], moves: saved?.moves || 0, done: !!saved?.done,
});
if (myLoad !== loadSeq) return; // a newer board load started — drop this stale one
if (merged) saved = merged;
}
}
if (myLoad !== loadSeq) return;
// Commit the new board + restored progress atomically AFTER any await, so a rapid
// daily/free, icon/color, or tier switch can't hydrate an old response onto the
// newer board.
const b = buildBoard({ format, tier, seed: theSeed });
const valid = new Set(b.faces);
board = b;
seed = theSeed;
flipped = []; locked = false;
matchedKeys = new Set((saved?.matched || []).filter((k) => valid.has(k)));
moves = Number.isFinite(saved?.moves) ? saved.moves : 0;
celebrated = matchedKeys.size >= b.faces.length; // don't replay the celebration on resume
// Persist immediately so a freshly-generated free board's seed survives a reload
// even before the first move.
write(stateKey, { matched: [...matchedKeys], moves, done: matchedKeys.size >= b.faces.length,
seed: useFree ? theSeed : undefined });
ready = true;
emitStatus();
}
function reset() { if (isFree) load(true); }
function toggleAssist() {
assist = !assist;
try { localStorage.setItem(ASSIST_KEY, assist ? '1' : '0'); } catch { /* ignore */ }
}
let flipTimer = null;
function flip(card) {
if (!ready || locked || done) return;
if (matchedKeys.has(card.key) || flipped.includes(card.id)) return;
if (moves === 0 && flipped.length === 0 && matchedKeys.size === 0) trackGame('match', 'started');
// Match off the LOCAL `next`, not the just-assigned reactive `flipped`, so the
// evaluation never runs a click behind.
const next = [...flipped, card.id];
flipped = next;
if (next.length < board.matchN) return; // Expert needs all 3 before judging
moves += 1;
const keys = next.map((id) => board.cards[id].key);
const allSame = keys.every((k) => k === keys[0]);
if (allSame) {
matchedKeys = new Set([...matchedKeys, keys[0]]);
flipped = [];
persist();
if (matchedKeys.size >= board.faces.length && !celebrated) { celebrated = true; trackGame('match', 'completed'); }
} else {
locked = true; // show the mismatch briefly, then flip back
persist();
flipTimer = setTimeout(() => { flipped = []; locked = false; flipTimer = null; }, 850);
}
}
const TIER_LABEL = { gentle: 'Gentle', standard: 'Standard', expert: 'Expert' };
let copied = $state(false);
function share() {
const label = `${TIER_LABEL[tier] || tier} · ${format === 'colors' ? 'colors' : 'icons'}`;
const when = isFree ? 'Free play' : date;
const text = `Upbeat Bytes · Memory Match (${label}) ${when}\nCleared in ${moves} moves\n${gameShareUrl('match', `${mode}-${format}-${tier}`)}`;
if (navigator.share) navigator.share({ text }).then(() => trackGame('match', 'shared')).catch(() => {});
else navigator.clipboard?.writeText(text).then(() => { trackGame('match', 'shared'); copied = true; setTimeout(() => (copied = false), 1500); });
}
function faceLabel(card) {
if (format === 'colors') return COLOR_BY_KEY[card.key]?.name ?? 'color';
return card.key;
}
// Reload whenever the board identity (mode/format/tier/date, all encoded in
// stateKey) changes. untrack() keeps the effect from tracking load()'s internal
// state reads (board/matchedKeys/moves/done via emitStatus) — which load() also
// writes — so a synchronous free-play load can't self-retrigger into a loop.
$effect(() => {
stateKey;
untrack(() => load());
return () => { if (flipTimer) clearTimeout(flipTimer); };
});
</script>
<div class="match">
<div class="topbar">
<div class="meta">
{#if board}
<span class="tier">{board.label}{format === 'colors' ? ' · Color' : ''}</span>
<span class="prog">{matchedKeys.size}/{board.faces.length}{moves ? ` · ${moves} moves` : ''}</span>
<span class="rule" class:tri={board.matchN === 3}>{#if board.matchN === 3 && flipped.length > 0 && flipped.length < 3}Pick 3 · {flipped.length}/3{:else}{board.matchN === 3 ? 'Match 3 of a kind' : 'Match pairs'}{/if}</span>
{/if}
</div>
<div class="controls">
{#if format === 'colors'}
<button class="ctl" aria-pressed={assist} onclick={toggleAssist}>Assist {assist ? 'on' : 'off'}</button>
{/if}
{#if isFree}
<button class="ctl" onclick={reset}>New board</button>
{/if}
</div>
</div>
{#if board}
<div class="grid" class:locked
style="grid-template-columns: repeat({board.cols}, 1fr); max-width: {board.cols * 76}px;">
{#each board.cards as card (card.id)}
{@const up = matchedIds.has(card.id) || flipped.includes(card.id)}
{@const matched = matchedIds.has(card.id)}
<button class="card" class:up class:matched
aria-label={up ? faceLabel(card) : 'card, face down'}
disabled={matched}
onclick={() => flip(card)}>
<span class="inner">
<span class="back" aria-hidden="true"></span>
<span class="front" class:colored={format === 'colors'} aria-hidden="true">
{#if format === 'colors'}
<span class="colorfill" style="--c: {COLOR_BY_KEY[card.key]?.hex}">
{#if assist}<span class="assist">{COLOR_BY_KEY[card.key]?.assist}</span>{/if}
</span>
{:else}
<MatchIcon name={card.key} />
{/if}
{#if matched}<span class="check" aria-hidden="true"></span>{/if}
</span>
</span>
</button>
{/each}
</div>
{#if done && celebrated}
<p class="done">Lovely — you cleared {isFree ? 'the board' : "today's set"}.{isFree ? '' : ' Fresh one tomorrow.'}</p>
<button class="share" onclick={share}>{copied ? 'Copied!' : 'Share result'}</button>
{/if}
{/if}
</div>
<style>
.match { display: flex; flex-direction: column; align-items: center; gap: 14px; }
.topbar { width: 100%; max-width: 460px; display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.meta { display: flex; flex-direction: column; gap: 1px; }
.tier { font-family: var(--label); font-weight: 600; }
.prog { color: var(--muted); font-size: 0.85rem; font-variant-numeric: tabular-nums; }
.rule { font-size: 0.8rem; color: var(--muted); margin-top: 2px; }
/* Expert's 3-of-a-kind rule is the easy-to-forget one — give it a clear chip. */
.rule.tri { align-self: flex-start; color: var(--accent-deep); background: var(--accent-soft);
padding: 1px 9px; border-radius: 999px; font-weight: 600; }
.controls { display: flex; gap: 8px; }
.ctl { font-size: 0.8rem; padding: 5px 11px; border: 1px solid var(--line); border-radius: 9px;
background: var(--surface); color: var(--accent-deep); cursor: pointer; }
.grid { display: grid; gap: 8px; width: 100%; }
.card { aspect-ratio: 1; min-width: 0; padding: 0; border: none; background: none; cursor: pointer;
perspective: 600px; -webkit-tap-highlight-color: transparent; touch-action: manipulation; }
.card[disabled] { cursor: default; }
.inner { position: relative; display: block; width: 100%; height: 100%;
transition: transform 0.32s ease; transform-style: preserve-3d; }
.card.up .inner { transform: rotateY(180deg); }
.back, .front { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
border-radius: 13px; backface-visibility: hidden; -webkit-backface-visibility: hidden; }
/* Face-down: a branded card back — azure gradient under a faint dot motif. */
.back { background:
radial-gradient(circle at 50% 50%, rgba(255,255,255,0.18) 1.2px, transparent 1.6px) 0 0 / 11px 11px,
linear-gradient(150deg, var(--accent) 0%, var(--accent-deep) 100%);
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.18); }
.front { background: var(--surface); border: 1px solid var(--line); color: var(--accent-deep);
transform: rotateY(180deg); }
.front.colored { background: none; border: none; } /* the colorfill is the whole face */
/* Icon front-and-center: ~70% of the card (≈50% area) so the symbol is memorable. */
.front :global(svg) { width: 70%; height: 70%; }
.colorfill { position: absolute; inset: 0; border-radius: 13px; background: var(--c);
border: 2px solid #3a3a3a; }
.assist { position: absolute; top: 5px; right: 6px; font-family: var(--label); font-weight: 700;
font-size: 0.72rem; color: #fff; background: rgba(0, 0, 0, 0.45); padding: 1px 5px; border-radius: 6px; }
/* State hierarchy ----------------------------------------------------------
Inspecting (flipped, unmatched): vivid + lifted, so the unknown cards pop. */
.card.up:not(.matched) .front:not(.colored) { box-shadow: 0 2px 8px rgba(0,0,0,0.12); }
.card.up:not(.matched) .colorfill { box-shadow: 0 2px 8px rgba(0,0,0,0.20); }
/* Matched: a settled "paint-chip" tile — receded, desaturated, with a quiet check,
so the last few unknown cards stay obvious instead of blending in. */
.card.matched .inner { transform: rotateY(180deg) scale(0.9); }
.card.matched .front { box-shadow: none; }
.card.matched .front.colored { background: var(--surface); border: 1px solid var(--line); }
.card.matched .colorfill { inset: 5px; border-radius: 8px; border: 1px solid rgba(0,0,0,0.22);
filter: saturate(0.6) brightness(1.06); box-shadow: none; }
.card.matched :global(svg) { opacity: 0.42; }
.card.matched .assist { display: none; }
.check { position: absolute; bottom: 4px; right: 6px; font-size: 0.82rem; line-height: 1;
color: var(--accent-deep); font-weight: 700; }
.done { margin: 4px 0 0; color: var(--accent-deep); font-family: var(--label); text-align: center; }
.share { margin-top: 10px; background: var(--accent); color: #fff; border: none; border-radius: 999px;
padding: 9px 22px; font: inherit; font-weight: 600; cursor: pointer; }
.share:hover { background: var(--accent-deep); }
@media (prefers-reduced-motion: reduce) {
.inner { transition: none; }
}
</style>
@@ -0,0 +1,9 @@
<script>
import { ICONS } from '$lib/games/match/icons.js';
let { name, size = 34 } = $props();
</script>
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
{@html ICONS[name] ?? ''}
</svg>
+75 -12
View File
@@ -1,5 +1,7 @@
<script>
import { getJSON, postJSON } from '$lib/api.js';
import { pushGameState, fetchGameStats } from '$lib/gamesync.js';
import { trackGame, gameShareUrl } from '$lib/analytics.js';
let { variant = '5', onstatus } = $props();
@@ -40,6 +42,7 @@
}
loading = false;
requestAnimationFrame(() => (ready = true));
syncNow(); // reconcile with the server in the background (signed-in only)
}
function restore() {
@@ -59,6 +62,30 @@
try { localStorage.setItem(stateKey, JSON.stringify({ guesses, cols, status, answer, why })); } catch { /* ignore */ }
onstatus?.(summary());
}
// --- cross-device sync (signed-in only; "furthest progress" merged server-side) ---
let serverStats = $state(null);
let syncTimer;
function adopt(merged) {
if (!merged || !Array.isArray(merged.guesses)) return;
const lr = [status !== 'playing' ? 1 : 0, guesses.length];
const mr = [merged.status && merged.status !== 'playing' ? 1 : 0, merged.guesses.length];
if (mr[0] > lr[0] || (mr[0] === lr[0] && mr[1] > lr[1])) { // server is further along
guesses = merged.guesses;
cols = Array.isArray(merged.cols) ? merged.cols : cols;
status = merged.status || 'playing';
answer = merged.answer ?? answer;
why = merged.why ?? why;
persist();
}
}
async function syncNow() {
const d = date, v = variant;
const merged = await pushGameState('word', v, d, { guesses, cols, status, answer, why });
if (d === date && v === variant) adopt(merged);
serverStats = await fetchGameStats('word', variant); // streak/dist follow you across devices
}
function syncSoon() { clearTimeout(syncTimer); syncTimer = setTimeout(syncNow, 1000); }
function summary() {
return { variant, date, status, tries: guesses.length, max: maxGuesses };
}
@@ -95,12 +122,14 @@
submitting = true;
try {
const res = await postJSON('/api/puzzle/word/guess', { variant, guess: current, n: guesses.length + 1 });
if (guesses.length === 0) trackGame('word', 'started'); // first real guess = started
guesses = [...guesses, current];
cols = [...cols, res.colors];
current = '';
if (res.solved) { status = 'won'; answer = res.answer; why = res.why; recordStat(true); }
else if (guesses.length >= maxGuesses) { status = 'lost'; answer = res.answer; why = res.why; recordStat(false); }
if (res.solved) { status = 'won'; answer = res.answer; why = res.why; recordStat(true); trackGame('word', 'completed'); }
else if (guesses.length >= maxGuesses) { status = 'lost'; answer = res.answer; why = res.why; recordStat(false); trackGame('word', 'completed'); }
persist();
syncSoon(); // push this guess (and any completion) to the server, debounced
} catch {
flash('Hmm — couldnt check that. Try again.');
} finally {
@@ -119,6 +148,7 @@
}
let stats = $derived.by(() => {
if (status === 'playing') return null;
if (serverStats) return serverStats; // signed-in: consistent across devices
try { return JSON.parse(localStorage.getItem(statsKey) || 'null'); } catch { return null; }
});
@@ -137,9 +167,11 @@
const label = variant === '6' ? 'Long Word' : 'Daily Word';
const score = status === 'won' ? guesses.length : 'X';
const grid = cols.map((cs) => cs.map((c) => EMOJI[c]).join('')).join('\n');
const text = `Upbeat Bytes · ${label} ${date}\n${score}/${maxGuesses}\n${grid}\nupbeatbytes.com/play`;
if (navigator.share) navigator.share({ text }).catch(() => {});
else navigator.clipboard?.writeText(text).then(() => { copied = true; setTimeout(() => (copied = false), 1500); });
const text = `Upbeat Bytes · ${label} ${date}\n${score}/${maxGuesses}\n${grid}\n${gameShareUrl('word', variant)}`;
// Count a share only once it actually happens (sheet completed / clipboard wrote),
// never on a cancelled share sheet or denied clipboard.
if (navigator.share) navigator.share({ text }).then(() => trackGame('word', 'shared')).catch(() => {});
else navigator.clipboard?.writeText(text).then(() => { trackGame('word', 'shared'); copied = true; setTimeout(() => (copied = false), 1500); });
}
// Load on mount and whenever the variant toggles. Tracks ONLY `variant` — it
@@ -165,7 +197,7 @@
{#each Array(length) as _, c (c)}
{@const ch = g ? g[c] : (r === guesses.length ? current[c] : '')}
<div class="tile {cs ? cs[c] : ''}" class:filled={!!ch}
style={cs ? `animation-delay:${c * 0.08}s` : ''}>{(ch || '').toUpperCase()}</div>
style={cs ? `--d:${c * 0.08}s` : ''}>{(ch || '').toUpperCase()}</div>
{/each}
</div>
{/each}
@@ -225,22 +257,53 @@
aspect-ratio: 1; display: flex; align-items: center; justify-content: center;
border: 2px solid var(--line); border-radius: 8px; font-family: var(--label);
font-weight: 700; font-size: 1.5rem; color: var(--ink); text-transform: uppercase;
background: var(--surface);
background: var(--surface); position: relative; overflow: hidden;
}
.tile.filled { border-color: #b7c0cb; }
.tile.correct { background: #4a9d6e; border-color: #4a9d6e; color: #fff; }
.tile.present { background: #d8b24a; border-color: #d8b24a; color: #fff; }
.tile.absent { background: #9aa6b2; border-color: #9aa6b2; color: #fff; }
/* Judged tiles set like glazed enamel: a soft top-light gradient over the
colour, an inner bevel, and a little lift off the board. Pending tiles stay
flat on purpose — depth marks the moment a letter is settled. */
.tile.correct, .tile.present, .tile.absent { color: #fff; text-shadow: 0 1px 2px rgba(20, 30, 25, 0.22); }
.tile.correct {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.2), rgba(255, 255, 255, 0) 45%),
linear-gradient(165deg, #56ac7c, #4a9d6e 55%, #3e8a5e);
border-color: #3e8a5e;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.32), inset 0 -3px 5px rgba(22, 64, 42, 0.2),
0 2px 5px rgba(58, 125, 86, 0.28);
}
.tile.present {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.2), rgba(255, 255, 255, 0) 45%),
linear-gradient(165deg, #e2c163, #d8b24a 55%, #c29c38);
border-color: #c29c38;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.35), inset 0 -3px 5px rgba(122, 92, 22, 0.2),
0 2px 5px rgba(184, 148, 58, 0.28);
}
.tile.absent {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0) 45%),
linear-gradient(165deg, #a7b2bd, #9aa6b2 55%, #87939f);
border-color: #87939f;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.26), inset 0 -3px 5px rgba(50, 60, 70, 0.16),
0 2px 4px rgba(110, 122, 134, 0.24);
}
/* One quiet glint sweeps each tile just after its flip lands — once, not a loop. */
.tile.correct::after, .tile.present::after, .tile.absent::after {
content: ''; position: absolute; inset: 0; pointer-events: none;
background: linear-gradient(115deg, transparent 38%, rgba(255, 255, 255, 0.38) 50%, transparent 62%);
transform: translateX(-130%);
animation: sheen 0.65s ease-out both;
animation-delay: calc(var(--d, 0s) + 0.32s);
}
/* Juice: a tile pops as you type; the row reveals with a staggered bounce when
you submit; the row shakes on an invalid word. */
.tile.filled:not(.correct):not(.present):not(.absent) { animation: pop 0.13s ease; }
.tile.correct, .tile.present, .tile.absent { animation: reveal 0.34s ease both; }
.tile.correct, .tile.present, .tile.absent { animation: reveal 0.34s ease both; animation-delay: var(--d, 0s); }
.row.shake { animation: shake 0.4s ease; }
@keyframes pop { 0% { transform: scale(1); } 45% { transform: scale(1.09); } 100% { transform: scale(1); } }
@keyframes reveal { 0% { transform: scale(0.5); opacity: 0.3; } 55% { transform: scale(1.12); } 100% { transform: scale(1); opacity: 1; } }
@keyframes sheen { to { transform: translateX(130%); } }
@keyframes shake { 0%, 100% { transform: translateX(0); } 20% { transform: translateX(-7px); } 40% { transform: translateX(7px); } 60% { transform: translateX(-5px); } 80% { transform: translateX(5px); } }
@media (prefers-reduced-motion: reduce) { .tile, .row { animation: none !important; } }
@media (prefers-reduced-motion: reduce) { .tile, .tile::after, .row { animation: none !important; } }
.flash {
text-align: center; background: var(--ink); color: #fff; border-radius: 8px;
padding: 7px 14px; width: fit-content; margin: 0 auto 12px; font-size: 0.86rem;
+111 -20
View File
@@ -1,6 +1,8 @@
<script>
import { getJSON } from '$lib/api.js';
import { lineFrom, matchWord, cellFromPoint } from '$lib/wordsearch.js';
import { pushGameState, fetchGameStats } from '$lib/gamesync.js';
import { trackGame, gameShareUrl } from '$lib/analytics.js';
let { size = 'med', onstatus } = $props();
@@ -15,7 +17,8 @@
let foundWords = $state([]); // {word, cells:[[r,c]], ci}
let sel = $state([]); // current selection cells
let selecting = false;
let startTime = 0;
let playedMs = 0; // accumulated ACTIVE play time (closed segments)
let segStart = 0; // wall-clock start of the open segment (0 = paused)
let resultMs = $state(0);
let best = $state(0);
let loading = $state(true);
@@ -32,26 +35,74 @@
const status = $derived(words.length && foundWords.length === words.length ? 'done' : 'playing');
const selSet = $derived(new Set(sel.map(([r, c]) => r + ',' + c)));
const cellColor = $derived.by(() => {
const m = new Map();
for (const w of foundWords) for (const [r, c] of w.cells) m.set(r + ',' + c, w.ci);
const m = new Map(); // "r,c" -> [colour index, ...] (all words covering it)
for (const w of foundWords) for (const [r, c] of w.cells) {
const k = r + ',' + c;
if (!m.has(k)) m.set(k, []);
m.get(k).push(w.ci);
}
return m;
});
// Multiply-blend the palette colours of every word covering a cell, so where
// words cross the cell DEEPENS into a darker shade instead of one colour
// stomping the others. Single-word cells keep their plain pastel.
function cellStyle(indices) {
let r = 255, g = 255, b = 255;
for (const ci of indices) {
const h = PALETTE[ci];
r = (r * parseInt(h.slice(1, 3), 16)) / 255;
g = (g * parseInt(h.slice(3, 5), 16)) / 255;
b = (b * parseInt(h.slice(5, 7), 16)) / 255;
}
r = Math.round(r); g = Math.round(g); b = Math.round(b);
const lum = 0.299 * r + 0.587 * g + 0.114 * b; // keep text readable as cells darken
return `background:rgb(${r},${g},${b});color:${lum < 140 ? '#fff' : '#2a2f36'}`;
}
const wordColor = $derived.by(() => {
const m = new Map();
for (const w of foundWords) m.set(w.word, w.ci);
return m;
});
// --- the clock counts ACTIVE play only -----------------------------------
// Wall-clock timing made "finish in one sitting" feel mandatory — the
// opposite of calm. The clock runs only while the puzzle is on screen
// (tab visible, window focused, game unfinished); stepping away pauses it,
// coming back resumes it, and several sittings simply add up.
function playedNow() { return playedMs + (segStart ? Date.now() - segStart : 0); }
function pauseClock(save = true) {
if (!segStart) return;
playedMs += Date.now() - segStart; segStart = 0;
if (save) persist(); // don't lose the segment if the tab dies
}
function resumeClock() {
if (!segStart && !loading && status === 'playing' && !document.hidden) segStart = Date.now();
}
$effect(() => {
const onVis = () => (document.hidden ? pauseClock() : resumeClock());
const onAway = () => pauseClock();
document.addEventListener('visibilitychange', onVis);
window.addEventListener('pagehide', onAway);
window.addEventListener('blur', onAway);
window.addEventListener('focus', onVis);
return () => {
document.removeEventListener('visibilitychange', onVis);
window.removeEventListener('pagehide', onAway);
window.removeEventListener('blur', onAway);
window.removeEventListener('focus', onVis);
};
});
async function load() {
const seq = ++loadSeq; // stale-load guard for rapid size switches
loading = true; ready = false;
foundWords = []; sel = []; resultMs = 0; startTime = 0;
foundWords = []; sel = []; resultMs = 0; playedMs = 0; segStart = 0;
try {
const p = await getJSON('/api/puzzle/wordsearch?variant=' + size);
if (seq !== loadSeq) return; // a newer size was selected — abandon
theme = p.theme; words = p.words; grid = p.grid; date = p.date;
restore();
if (!startTime) startTime = Date.now();
try { best = JSON.parse(localStorage.getItem(bestKey) || '0'); } catch { best = 0; }
} catch {
if (seq !== loadSeq) return;
@@ -59,30 +110,66 @@
}
if (seq !== loadSeq) return;
loading = false;
resumeClock();
requestAnimationFrame(() => (ready = true));
// Reconcile with the server in the background (signed-in only): pull any
// progress from another device, and pull the cross-device best time.
syncNow();
fetchGameStats('wordsearch', size).then((st) => {
if (st && st.best && (!best || st.best < best)) best = st.best;
});
}
// Keep only finds whose cells still spell their word in the CURRENT grid —
// guards stale highlights if the puzzle/layout changed (and validates finds
// arriving from another device, which is the same date+size grid).
function validFinds(list) {
return (list || []).filter((fw) =>
fw && Array.isArray(fw.cells) && words.includes(fw.word) &&
fw.cells.map(([r, c]) => (grid[r] && grid[r][c]) || '').join('') === fw.word);
}
function restore() {
try {
const s = JSON.parse(localStorage.getItem(stateKey) || 'null');
if (s && Array.isArray(s.foundWords)) {
// Keep only finds whose stored cells still spell their word in the CURRENT
// grid — guards against stale highlights if the day's puzzle changed.
const valid = s.foundWords.filter((fw) =>
fw && Array.isArray(fw.cells) && words.includes(fw.word) &&
fw.cells.map(([r, c]) => (grid[r] && grid[r][c]) || '').join('') === fw.word);
foundWords = valid;
startTime = s.startTime || 0;
resultMs = valid.length === words.length ? (s.ms || 0) : 0;
foundWords = validFinds(s.foundWords);
playedMs = s.played || 0; // pre-"active clock" saves restart at 0:00 — the kind direction
resultMs = foundWords.length === words.length ? (s.ms || 0) : 0;
}
} catch { /* ignore */ }
onstatus?.(summary());
}
function persist() {
try { localStorage.setItem(stateKey, JSON.stringify({ foundWords, startTime, ms: resultMs, status })); }
try { localStorage.setItem(stateKey, JSON.stringify({ foundWords, played: playedNow(), ms: resultMs, status })); }
catch { /* ignore */ }
onstatus?.(summary());
}
// --- cross-device sync (signed-in only; merged server-side) ---
let syncTimer;
function adopt(merged) {
if (!merged) return;
const have = new Set(foundWords.map((w) => w.word));
for (const fw of validFinds(merged.foundWords)) {
if (!have.has(fw.word)) { foundWords = [...foundWords, fw]; have.add(fw.word); }
}
// renumber colours by find order so overlap blends stay consistent
foundWords = foundWords.map((fw, i) => ({ ...fw, ci: i % PALETTE.length }));
// another device may have accumulated more active time — credit the larger
if ((merged.played || 0) > playedNow()) {
playedMs = merged.played;
if (segStart) segStart = Date.now();
}
if (foundWords.length === words.length && merged.ms) resultMs = Math.min(resultMs || merged.ms, merged.ms);
persist();
}
async function syncNow() {
const d = date, sz = size; // pin against a size switch mid-flight
const merged = await pushGameState('wordsearch', sz, d, { foundWords, played: playedNow(), ms: resultMs });
if (d === date && sz === size) adopt(merged); // ignore if the user switched away
}
function syncSoon() { clearTimeout(syncTimer); syncTimer = setTimeout(syncNow, 1200); }
function summary() { return { game: 'wordsearch', size, date, status, found: foundWords.length, total: words.length, ms: resultMs }; }
function cellAt(e) {
@@ -92,7 +179,7 @@
function down(e) {
if (status === 'done') return;
selecting = true;
if (!startTime) startTime = Date.now();
resumeClock(); // safety net if a focus event was missed
sel = [cellAt(e)];
gridEl.setPointerCapture?.(e.pointerId);
e.preventDefault();
@@ -111,14 +198,18 @@
function evaluate(cells) {
const hit = matchWord(cells, grid, words, found);
if (!hit) return;
if (foundWords.length === 0) trackGame('wordsearch', 'started'); // first word found = started
foundWords = [...foundWords, { word: hit, cells: cells.map((c) => [...c]), ci: foundWords.length % PALETTE.length }];
okFlash = true; setTimeout(() => (okFlash = false), 500);
if (foundWords.length === words.length) finish();
persist();
syncSoon(); // push this find (and completion) to the server, debounced
}
function finish() {
resultMs = startTime ? Date.now() - startTime : 0;
pauseClock(false); // close the open segment; persist follows in evaluate()
trackGame('wordsearch', 'completed');
resultMs = playedMs;
if (resultMs && (!best || resultMs < best)) {
best = resultMs;
try { localStorage.setItem(bestKey, JSON.stringify(best)); } catch { /* ignore */ }
@@ -132,9 +223,9 @@
function share() {
const label = { small: 'Small', med: 'Medium', large: 'Large' }[size] || '';
const text = `Upbeat Bytes · Word Search (${label}) ${date}\n${theme} — cleared in ${fmt(resultMs)}\nupbeatbytes.com/play`;
if (navigator.share) navigator.share({ text }).catch(() => {});
else navigator.clipboard?.writeText(text).then(() => { copied = true; setTimeout(() => (copied = false), 1500); });
const text = `Upbeat Bytes · Word Search (${label}) ${date}\n${theme} — cleared in ${fmt(resultMs)}\n${gameShareUrl('wordsearch', size)}`;
if (navigator.share) navigator.share({ text }).then(() => trackGame('wordsearch', 'shared')).catch(() => {});
else navigator.clipboard?.writeText(text).then(() => { trackGame('wordsearch', 'shared'); copied = true; setTimeout(() => (copied = false), 1500); });
}
// Load on mount and whenever the size changes. Tracks ONLY `size`.
@@ -157,7 +248,7 @@
{#each rowStr.split('') as ch, c (c)}
{@const key = r + ',' + c}
<div class="cell" class:sel={selSet.has(key)}
style={cellColor.has(key) && !selSet.has(key) ? `background:${PALETTE[cellColor.get(key)]};color:#2a2f36` : ''}>{ch}</div>
style={cellColor.has(key) && !selSet.has(key) ? cellStyle(cellColor.get(key)) : ''}>{ch}</div>
{/each}
{/each}
</div>
+36
View File
@@ -0,0 +1,36 @@
// Reusable in-development gate. Lets us ship a half-built game LIVE (so we can
// test the real, fully-wired experience) without any public visitor stumbling
// onto it:
// • its card shows on /play only for admins (or a preview link)
// • its route bounces non-admins back to the hub
// • search engines never index it (the route guard + noindex)
// Launch = remove the game key from IN_DEV. Future dev games just join the set.
//
// This is casual concealment, not security — the API is harmless on its own.
// 'bloom' launched 2026-06-15 — public. 'match' (Memory/Color Match) launched
// 2026-06-17 — public. 'zen' (UB/Zen Den) gated while UB is ironed out (model
// tabled). Add a key here to gate any in-dev game/section.
export const IN_DEV = new Set(['zen']);
// A non-obvious bypass so we can test logged-out / incognito: /play?...&preview=KEY
const PREVIEW_KEY = 'sunflower';
export function isDevGated(gameKey) {
return IN_DEV.has(gameKey);
}
// Can THIS viewer see in-dev games? Admins always; anyone with the preview token.
export function canSeeDev(user, url) {
if (user?.is_admin) return true;
try {
return url?.searchParams?.get('preview') === PREVIEW_KEY;
} catch {
return false;
}
}
// True when this game should be hidden/blocked for this viewer.
export function blockedForViewer(gameKey, user, url) {
return isDevGated(gameKey) && !canSeeDev(user, url);
}
File diff suppressed because one or more lines are too long
+83
View File
@@ -0,0 +1,83 @@
// Memory Match board engine — fully deterministic ("code disposes"): a given seed
// always yields the same board, so the daily is shared across devices with no
// server round-trip. Faces are stored as KEYS (icon name or color key), never raw
// indices, so saved progress survives layout tweaks (Codex).
import { ICON_KEYS } from './icons.js';
import { COLORS, deltaE } from './palette.js';
// Tiers — Gentle/Standard match pairs; Expert matches THREE of a kind and uses a
// bigger grid so it reads as a real step up (Codex's sizing).
// gentle 4×3 = 12 cards = 6 pairs
// standard 4×4 = 16 cards = 8 pairs
// expert 6×4 = 24 cards = 8 triples
export const TIERS = {
gentle: { label: 'Gentle', cols: 4, rows: 3, faces: 6, matchN: 2 },
standard: { label: 'Standard', cols: 4, rows: 4, faces: 8, matchN: 2 },
expert: { label: 'Expert', cols: 6, rows: 4, faces: 8, matchN: 3 },
};
export const TIER_KEYS = Object.keys(TIERS);
export const FORMAT_KEYS = ['icons', 'colors'];
// Minimum perceptual gap (ΔE) we try to keep between a board's colors so two
// near-identical shades never appear together; relaxed only if a board can't fill.
const MIN_DELTA_E = 26;
function hashStr(s) {
let h = 2166136261 >>> 0;
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
return h >>> 0;
}
function mulberry32(a) {
return function () {
a |= 0; a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function shuffle(arr, rng) {
const a = arr.slice();
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
// Pick `n` colors, seed-varied but spaced: walk a seeded shuffle keeping only
// colors at least `threshold` ΔE from those already chosen; relax if we can't fill.
function pickColors(n, rng) {
const order = shuffle(COLORS, rng);
for (let threshold = MIN_DELTA_E; threshold >= 0; threshold -= 3) {
const chosen = [];
for (const c of order) {
if (chosen.every((p) => deltaE(p.lab, c.lab) >= threshold)) chosen.push(c);
if (chosen.length === n) break;
}
if (chosen.length === n) return chosen.map((c) => c.key);
}
return order.slice(0, n).map((c) => c.key); // pool too small (shouldn't happen)
}
function pickFaces(format, n, rng) {
if (format === 'colors') return pickColors(n, rng);
return shuffle(ICON_KEYS, rng).slice(0, n);
}
// Build a board. `seed` is the day's date (daily) or a random token (free play).
export function buildBoard({ format = 'icons', tier = 'standard', seed = '' } = {}) {
const t = TIERS[tier] || TIERS.standard;
const fmt = FORMAT_KEYS.includes(format) ? format : 'icons';
const rng = mulberry32(hashStr(`match:v1:${fmt}:${tier}:${seed}`));
const faces = pickFaces(fmt, t.faces, rng);
const multiset = [];
for (const key of faces) for (let c = 0; c < t.matchN; c++) multiset.push(key);
const cards = shuffle(multiset, rng).map((key, id) => ({ id, key }));
return { cards, faces, matchN: t.matchN, cols: t.cols, rows: t.rows, format: fmt, tier, label: t.label };
}
// A short token for a fresh free-play board (deterministic given the inputs so a
// reload resumes the same board); callers persist it.
export function freeSeed(n) {
return 'f' + (n >>> 0).toString(36);
}
@@ -0,0 +1,51 @@
import { describe, it, expect } from 'vitest';
import { buildBoard, TIERS, TIER_KEYS } from './board.js';
import { COLOR_BY_KEY, deltaE } from './palette.js';
import { ICON_KEYS } from './icons.js';
describe('match board', () => {
it('is deterministic for a given seed', () => {
const a = buildBoard({ format: 'icons', tier: 'standard', seed: '2026-06-16' });
const b = buildBoard({ format: 'icons', tier: 'standard', seed: '2026-06-16' });
expect(a.cards).toEqual(b.cards);
});
it('different days give different boards', () => {
const a = buildBoard({ format: 'icons', tier: 'standard', seed: '2026-06-16' });
const b = buildBoard({ format: 'icons', tier: 'standard', seed: '2026-06-17' });
expect(a.cards).not.toEqual(b.cards);
});
for (const tier of TIER_KEYS) {
it(`${tier}: correct card count and exactly matchN of each face`, () => {
const t = TIERS[tier];
const { cards, faces, matchN } = buildBoard({ tier, seed: 's' });
expect(cards.length).toBe(t.cols * t.rows);
expect(faces.length).toBe(t.faces);
expect(matchN).toBe(t.matchN);
for (const f of faces) {
expect(cards.filter((c) => c.key === f).length).toBe(matchN);
}
// every card is one of the chosen faces
expect(cards.every((c) => faces.includes(c.key))).toBe(true);
});
}
it('icon boards draw from the icon set', () => {
const { faces } = buildBoard({ format: 'icons', tier: 'expert', seed: 'x' });
expect(faces.every((k) => ICON_KEYS.includes(k))).toBe(true);
});
it('color boards are perceptually spaced (no near-identical pair)', () => {
// sweep many seeds; every board's colors must be comfortably distinct
for (let i = 0; i < 80; i++) {
const { faces } = buildBoard({ format: 'colors', tier: 'standard', seed: 'd' + i });
const labs = faces.map((k) => COLOR_BY_KEY[k].lab);
let min = Infinity;
for (let a = 0; a < labs.length; a++)
for (let b = a + 1; b < labs.length; b++)
min = Math.min(min, deltaE(labs[a], labs[b]));
expect(min).toBeGreaterThan(15); // never confusably close
}
});
});
+41
View File
@@ -0,0 +1,41 @@
// Memory Match icon set — in-repo, owned, no dependency. Each value is the inner
// SVG markup on a 0 0 24 24 viewBox; MatchIcon.svelte supplies the shared styling
// (stroke=currentColor, fill=none, round caps) so every icon reads as one calm
// line family. Keep additions simple and recognizable; quality over count.
export const ICONS = {
sun: '<circle cx="12" cy="12" r="4.2"/><path d="M12 2.5v2.4M12 19.1v2.4M2.5 12h2.4M19.1 12h2.4M5.2 5.2l1.7 1.7M17.1 17.1l1.7 1.7M18.8 5.2l-1.7 1.7M6.9 17.1l-1.7 1.7"/>',
moon: '<path d="M20 14.5A8 8 0 1 1 11 4a6.5 6.5 0 0 0 9 10.5z"/>',
star: '<path d="M12 3.5l2.6 5.3 5.9.9-4.25 4.15 1 5.85L12 17l-5.25 2.75 1-5.85L3.5 9.7l5.9-.9z"/>',
cloud: '<path d="M7 18h9.5a3.5 3.5 0 0 0 .3-7 5 5 0 0 0-9.6-1.3A3.8 3.8 0 0 0 7 18z"/>',
raindrop: '<path d="M12 3.5c3.5 4.2 5.5 7 5.5 9.8a5.5 5.5 0 0 1-11 0C6.5 10.5 8.5 7.7 12 3.5z"/>',
wave: '<path d="M3 9c2 0 2.5 2 4.5 2S10 9 12 9s2.5 2 4.5 2S19 9 21 9M3 15c2 0 2.5 2 4.5 2S10 15 12 15s2.5 2 4.5 2S19 15 21 15"/>',
leaf: '<path d="M5 19C5 11 11 5 19 5c0 8-6 14-14 14z"/><path d="M5 19C9 15 12 12 16 9"/>',
flower: '<circle cx="12" cy="12" r="2.3"/><circle cx="12" cy="6.6" r="2.3"/><circle cx="12" cy="17.4" r="2.3"/><circle cx="7.3" cy="9.3" r="2.3"/><circle cx="16.7" cy="9.3" r="2.3"/><circle cx="7.3" cy="14.7" r="2.3"/><circle cx="16.7" cy="14.7" r="2.3"/>',
seedling: '<path d="M12 20v-7M12 13c0-3 2.5-5 6-5 0 3.5-2.5 5-6 5zM12 13c0-2.5-2-4.5-5-4.5 0 3 2 4.5 5 4.5z"/>',
tree: '<path d="M12 3 6 11h3l-4 6h14l-4-6h3z"/><path d="M12 17v4"/>',
mountain: '<path d="M3 19l6-10 4 6 2.5-4L21 19z"/>',
shell: '<path d="M4 18C4 10 7.5 5 12 5s8 5 8 13z"/><path d="M12 18V6M8 18 9.5 7M16 18 14.5 7"/>',
feather: '<path d="M5 19C7 9 12 5 19 5c0 7-4 12-12 13z"/><path d="M5 19 13 11"/>',
acorn: '<path d="M6 8c0-2 2.7-3.5 6-3.5S18 6 18 8zM7 8a5 5 0 0 0 10 0M12 18v2"/>',
butterfly: '<path d="M12 6v12M12 9C8 4 4 6 4 10s4 5 8 2M12 9c4-5 8-3 8 1s-4 5-8 2"/>',
rainbow: '<path d="M3 18a9 9 0 0 1 18 0M6 18a6 6 0 0 1 12 0M9 18a3 3 0 0 1 6 0"/>',
heart: '<path d="M12 20S4 14.5 4 9a4 4 0 0 1 8-1 4 4 0 0 1 8 1c0 5.5-8 11-8 11z"/>',
sparkle: '<path d="M12 3c.6 4.5 1.5 5.4 6 6-4.5.6-5.4 1.5-6 6-.6-4.5-1.5-5.4-6-6 4.5-.6 5.4-1.5 6-6z"/>',
home: '<path d="M4 11 12 4l8 7M6 10v9h12v-9M10 19v-5h4v5"/>',
book: '<path d="M12 6C10 4.5 7 4.5 4 5v13c3-.5 6-.5 8 1 2-1.5 5-1.5 8-1V5c-3-.5-6-.5-8 1zM12 6v13"/>',
teacup: '<path d="M5 9h12v3a6 6 0 0 1-12 0zM17 10h2a2 2 0 0 1 0 4h-2M5 19h12"/>',
candle: '<path d="M9 11h6v9H9zM12 11V8M12 8c0-1.6-2-2-2-3.5C10 3.4 11 3 12 3s2 .4 2 1.5C14 6 12 6.4 12 8z"/>',
lantern: '<path d="M10 3h4M8 6h8l-1 12H9zM9 6V4.5h6V6M8 18h8M12 9v6"/>',
compass: '<circle cx="12" cy="12" r="9"/><path d="M15 9l-2.5 5.5L9 15l2.5-5.5z"/>',
kite: '<path d="M12 3 19 10 12 17 5 10zM12 3v14M5 10h14M12 17l-2 4M12 17l2 4"/>',
note: '<path d="M9 18V6l9-2v12"/><circle cx="7" cy="18" r="2"/><circle cx="16" cy="16" r="2"/>',
boat: '<path d="M4 16h16l-2.5 4H6.5zM12 14V3l6 8zM12 14H6l6-7"/>',
fish: '<path d="M3 12c2.5-3.5 6-5 9-5s5.5 1.5 7 5c-1.5 3.5-4 5-7 5s-6.5-1.5-9-5z"/><path d="M19 12l3-2.5v5z"/><circle cx="8" cy="11" r="0.9"/>',
bird: '<path d="M3 9c3-2 6 0 9 0s6-2 9 0c-3 3-5 4-9 4S6 12 3 9z"/>',
mushroom: '<path d="M4 11a8 8 0 0 1 16 0zM9 11v5a3 3 0 0 0 6 0v-5"/>',
bell: '<path d="M6.5 17c1.3-1 1.8-3 1.8-6a3.7 3.7 0 0 1 7.4 0c0 3 .5 5 1.8 6zM10 17.5a2 2 0 0 0 4 0M12 4.2V2.6"/>',
snowflake: '<path d="M12 3v18M4.2 7.5l15.6 9M19.8 7.5l-15.6 9M9 5l3 2 3-2M9 19l3-2 3 2"/>',
clover: '<circle cx="9" cy="10" r="3"/><circle cx="15" cy="10" r="3"/><circle cx="12" cy="14.5" r="3"/><path d="M12 16.5V21"/>',
};
export const ICON_KEYS = Object.keys(ICONS);
+50
View File
@@ -0,0 +1,50 @@
// Color Match palette — a hand-curated set of calm-but-distinct colors. The board
// builder picks a perceptually-spaced subset (see board.js) so two near-identical
// shades never land on the same board. Each color is NAMED (drives the aria-label
// and the optional colorblind "assist" glyph), and varies in LIGHTNESS as well as
// hue so confusable hue pairs (e.g. green/teal) still differ by brightness.
//
// `assist` is a short letter shown in the corner when the player turns assist on —
// kept to 12 chars, unique enough to disambiguate the swatch without reading as
// a full label. Default play is pure color; assist is opt-in.
const RAW = [
{ key: 'color-rose', name: 'Rose', hex: '#d76a86', assist: 'Ro' },
{ key: 'color-coral', name: 'Coral', hex: '#e07a52', assist: 'Co' },
{ key: 'color-amber', name: 'Amber', hex: '#e3a32f', assist: 'Am' },
{ key: 'color-gold', name: 'Gold', hex: '#cdb63c', assist: 'Go' },
{ key: 'color-lime', name: 'Lime', hex: '#8fb24a', assist: 'Li' },
{ key: 'color-green', name: 'Green', hex: '#4a9b5c', assist: 'Gr' },
{ key: 'color-teal', name: 'Teal', hex: '#2f9c8e', assist: 'Te' },
{ key: 'color-cyan', name: 'Cyan', hex: '#3aa6c4', assist: 'Cy' },
{ key: 'color-sky', name: 'Sky', hex: '#5b8fd4', assist: 'Sk' },
{ key: 'color-blue', name: 'Blue', hex: '#4f63c6', assist: 'Bl' },
{ key: 'color-indigo', name: 'Indigo', hex: '#6a59b2', assist: 'In' },
{ key: 'color-violet', name: 'Violet', hex: '#9460ba', assist: 'Vi' },
{ key: 'color-plum', name: 'Plum', hex: '#b25a95', assist: 'Pl' },
{ key: 'color-brown', name: 'Brown', hex: '#9c6b45', assist: 'Br' },
{ key: 'color-sand', name: 'Sand', hex: '#cdb38c', assist: 'Sa' },
{ key: 'color-slate', name: 'Slate', hex: '#5d6b78', assist: 'Sl' },
{ key: 'color-charcoal', name: 'Charcoal', hex: '#3a4250', assist: 'Ch' },
{ key: 'color-cream', name: 'Cream', hex: '#e6dfca', assist: 'Cm' },
];
function srgbToLin(c) { c /= 255; return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }
function hexToLab(hex) {
const r = srgbToLin(parseInt(hex.slice(1, 3), 16));
const g = srgbToLin(parseInt(hex.slice(3, 5), 16));
const b = srgbToLin(parseInt(hex.slice(5, 7), 16));
const X = r * 0.4124 + g * 0.3576 + b * 0.1805;
const Y = r * 0.2126 + g * 0.7152 + b * 0.0722;
const Z = r * 0.0193 + g * 0.1192 + b * 0.9505;
const f = (t) => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116);
const fx = f(X / 0.95047), fy = f(Y / 1), fz = f(Z / 1.08883);
return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
}
// CIE76 ΔE — adequate for "are these two swatches obviously different".
export function deltaE(a, b) { return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); }
export const COLORS = RAW.map((c) => ({ ...c, lab: hexToLab(c.hex) }));
export const COLOR_BY_KEY = Object.fromEntries(COLORS.map((c) => [c.key, c]));
export const COLOR_KEYS = COLORS.map((c) => c.key);
+43
View File
@@ -0,0 +1,43 @@
// Cross-device game-state sync. Local-first: games always paint from
// localStorage instantly; these calls reconcile with the server in the
// background. Signed-out players stay purely local (no-ops here).
import { getJSON, putJSON } from './api.js';
import { auth } from './auth.svelte.js';
// Push this device's state → the server merges it with any other device's
// progress → returns the merged state to adopt. null when signed out / on error
// (the game just stays on its local copy).
export async function pushGameState(game, variant, date, local) {
if (!auth.user) return null;
try {
const r = await putJSON('/api/games/state', { game, variant, date, state: local || {} }, { timeout: 8000 });
return r?.state ?? null;
} catch {
return null;
}
}
// Reconcile MANY (game, variant) states for one date in a single request — used by
// the hub so a /play load doesn't fan out a dozen calls. items: [{game, variant,
// state}]. Returns the merged states array to adopt, or null when signed out / on error.
export async function pushGameStatesBatch(date, items) {
if (!auth.user) return null;
try {
const r = await putJSON('/api/games/state/batch', { date, items }, { timeout: 8000 });
return r?.states ?? null;
} catch {
return null;
}
}
// Derived record (streak / distribution / best) computed server-side from the
// player's synced states, so it's consistent across devices. null when signed out.
export async function fetchGameStats(game, variant) {
if (!auth.user) return null;
try {
const q = `game=${encodeURIComponent(game)}&variant=${encodeURIComponent(variant)}`;
return (await getJSON('/api/games/stats?' + q))?.stats ?? null;
} catch {
return null;
}
}
+14 -4
View File
@@ -11,6 +11,9 @@ export function blank() {
// [] means "not customized" — the feed falls back to the default lane set.
// The backend filter parser ignores unknown keys, so this rides along safely.
lanes: [],
// UI-only: which daily items fill the reader's "calm set" (ritual item keys).
// null = not customized → all eligible items. Rides along like `lanes`.
ritual: null,
};
}
@@ -77,9 +80,16 @@ export function saveJSON(key, value) {
// "" or "prefs=<encoded json>" for a query string.
export function param(prefs) {
// Serialize ONLY real filter fields — UI-only keys (lanes, ritual) must not
// ride in ?prefs= or they churn the feed URL / edge-cache key for no reason.
const f = {
include_topics: prefs.include_topics, include_flavors: prefs.include_flavors,
mute_topics: prefs.mute_topics, mute_flavors: prefs.mute_flavors,
avoid_terms: prefs.avoid_terms, pauses: prefs.pauses, max_cortisol: prefs.max_cortisol,
};
const empty =
!prefs.include_topics.length && !prefs.include_flavors.length &&
!prefs.mute_topics.length && !prefs.mute_flavors.length &&
!prefs.avoid_terms.length && !prefs.pauses.length && prefs.max_cortisol == null;
return empty ? '' : 'prefs=' + encodeURIComponent(JSON.stringify(prefs));
!f.include_topics.length && !f.include_flavors.length &&
!f.mute_topics.length && !f.mute_flavors.length &&
!f.avoid_terms.length && !f.pauses.length && f.max_cortisol == null;
return empty ? '' : 'prefs=' + encodeURIComponent(JSON.stringify(f));
}
+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 };
}
+79
View File
@@ -0,0 +1,79 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { ritualState, ritualKeys, markBriefSeen, briefSeen, RITUAL_ITEMS, DEFAULT_KEYS } from './ritual.js';
// Minimal localStorage stand-in (jsdom may not provide one in this config).
beforeEach(() => {
const store = new Map();
globalThis.localStorage = {
getItem: (k) => (store.has(k) ? store.get(k) : null),
setItem: (k, v) => store.set(k, String(v)),
removeItem: (k) => store.delete(k),
clear: () => store.clear(),
};
});
const D = '2026-06-14';
const done = (s, key) => s.items.find((i) => i.key === key)?.done;
describe('ritualState', () => {
it('defaults to the curated DEFAULT_KEYS set, none done', () => {
const s = ritualState(D);
expect(s.items.map((i) => i.key)).toEqual(DEFAULT_KEYS);
expect(s.count).toBe(0);
expect(s.total).toBe(DEFAULT_KEYS.length);
});
it('counts the brief only once the end-cap is marked', () => {
expect(briefSeen(D)).toBe(false);
markBriefSeen(D);
expect(done(ritualState(D), 'brief')).toBe(true);
expect(ritualState(D).count).toBe(1);
});
it('counts a word at a terminal state, a word search when cleared', () => {
localStorage.setItem(`goodnews:word:5:${D}`, JSON.stringify({ status: 'playing', guesses: ['aaaaa'] }));
expect(done(ritualState(D), 'word')).toBe(false); // in-progress isn't "enjoyed"
localStorage.setItem(`goodnews:word:6:${D}`, JSON.stringify({ status: 'lost' }));
expect(done(ritualState(D), 'word')).toBe(true);
localStorage.setItem(`goodnews:wordsearch:med:${D}`, JSON.stringify({ status: 'done', foundWords: ['x'] }));
expect(done(ritualState(D), 'wordsearch')).toBe(true);
});
it('counts Bloom only when the daily reached the top tier (top flag)', () => {
localStorage.setItem(`goodnews:bloom:${D}`, JSON.stringify({ found: ['area'], score: 4 }));
expect(done(ritualState(D), 'bloom')).toBe(false); // played but not top tier
localStorage.setItem(`goodnews:bloom:${D}`, JSON.stringify({ found: ['area'], score: 99, top: true }));
expect(done(ritualState(D), 'bloom')).toBe(true);
});
it('honors a curated set — only chosen items appear and count', () => {
markBriefSeen(D);
localStorage.setItem(`goodnews:bloom:${D}`, JSON.stringify({ top: true }));
const s = ritualState(D, ['brief', 'bloom']); // reader's chosen set
expect(s.items.map((i) => i.key)).toEqual(['brief', 'bloom']);
expect(s.total).toBe(2);
expect(s.count).toBe(2);
});
it('is scoped per server date — yesterday does not leak into today', () => {
markBriefSeen('2026-06-13');
expect(ritualState(D).count).toBe(0);
});
it('empty/missing date → nothing done (offline-safe)', () => {
expect(ritualState('').count).toBe(0);
});
});
describe('ritualKeys', () => {
it('null/undefined → the curated default (NOT all eligible items)', () => {
expect(ritualKeys(null)).toEqual(DEFAULT_KEYS);
expect(ritualKeys(undefined)).toEqual(DEFAULT_KEYS);
// default is a subset of eligible — adding a future eligible game must not auto-join it
expect(DEFAULT_KEYS.every((k) => RITUAL_ITEMS.some((i) => i.key === k))).toBe(true);
});
it('an array → those keys in canonical order, unknown ignored', () => {
expect(ritualKeys(['bloom', 'brief', 'nope'])).toEqual(['brief', 'bloom']);
expect(ritualKeys([])).toEqual([]); // explicitly empty set
});
});
+177
View File
@@ -0,0 +1,177 @@
// The Zen Den aquarium — raw Three.js, lazy-loaded only on /zen (keeps three's
// ~150KB off every other page). Phase A: get UB alive on screen (Idle_swim),
// soft light, simple backdrop, capped pixel ratio, reduced-motion aware, clean
// teardown. The behavior engine (steering / spine-bend turns / darts) lands in
// Phase B; the structure here (kept animation actions, a single update loop)
// is set up to grow into it.
//
// UB ships (ub-split.glb) as THREE materials — UB_Body, UB_Fins, UB_Tail — so each
// renders differently. The single-mesh approach hit a hard ceiling: a transparent,
// double-sided whole fish can't depth-sort its own triangles (tail/eye drew through
// the face), and any alpha cutoff that cleaned the fin fringe also chewed the fins.
// Thin midline fans (tail especially) also self-overlap as they fold/sweep, so even
// single-sided *transparent* tail triangles bleed through each other → "two tails".
// The render is fully parameter-driven (see DEFAULTS) so it can be tuned live via
// /zen?debug=1 — tuning a blind WebGL fish through redeploys was the slow path.
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
const MODEL_URL = '/models/ub-split.glb';
// Converged Phase-A starting point (Claude + Codex): a hair off strict profile so
// the mouth/chin reads naturally; tail OPAQUE alpha-tested (one coherent tail, no
// blend bleed); fins translucent + single-sided (kills the double-sided ghosting
// that read as an off-center dorsal). All live-tunable at /zen?debug=1.
export const DEFAULTS = {
yaw: Math.PI / 2 + 0.10, // ub.rotation.y — slight 3/4 view
pitch: 0, // ub.rotation.x — nose up/down
tailTranslucent: false, // false = opaque alpha-tested (coherent); true = blended
tailSide: 'front', // front | back | double
tailAlphaTest: 0.035,
tailOpacity: 1.0, // only when translucent
finSide: 'front', // front | back | double
finOpacity: 0.75,
finAlphaTest: 0.02,
paused: false,
frame: 0, // 0..1 scrub position when paused
};
const SIDE = { front: THREE.FrontSide, back: THREE.BackSide, double: THREE.DoubleSide };
export async function createAquarium(canvas, initial = {}) {
const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
const params = { ...DEFAULTS, ...initial };
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5)); // mobile thermal guard
renderer.outputColorSpace = THREE.SRGBColorSpace;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 100);
camera.position.set(0, 0.25, 4.6);
camera.lookAt(0, 0, 0);
// Soft, warm light — calm, gently dimensional (no harsh shadows in Phase A).
scene.add(new THREE.HemisphereLight(0xffffff, 0x8aa0b0, 1.05));
const key = new THREE.DirectionalLight(0xfff2e0, 1.25);
key.position.set(2.5, 4, 3);
scene.add(key);
function resize() {
const w = canvas.clientWidth || 1;
const h = canvas.clientHeight || 1;
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
// Load UB.
const gltf = await new GLTFLoader().loadAsync(MODEL_URL);
const ub = gltf.scene;
// Center at origin and scale to a consistent on-screen size, regardless of the
// model's native units.
const box = new THREE.Box3().setFromObject(ub);
const size = box.getSize(new THREE.Vector3());
const center = box.getCenter(new THREE.Vector3());
ub.position.sub(center);
const maxDim = Math.max(size.x, size.y, size.z) || 1;
ub.scale.setScalar(2.6 / maxDim);
// Collect the three named materials/meshes so applyMaterials can retarget them
// live without re-traversing semantics.
const part = {};
ub.traverse((o) => { if (o.isMesh && o.material?.name) part[o.material.name] = o; });
function applyMaterials() {
const body = part.UB_Body, fins = part.UB_Fins, tail = part.UB_Tail;
if (body) {
const m = body.material;
m.transparent = false; m.opacity = 1; m.alphaTest = 0;
m.depthWrite = true; m.depthTest = true; m.side = THREE.FrontSide;
body.renderOrder = 1; m.needsUpdate = true;
}
if (fins) {
const m = fins.material;
m.transparent = true; m.opacity = params.finOpacity; m.alphaTest = params.finAlphaTest;
m.alphaToCoverage = true; m.depthWrite = false; m.depthTest = true;
m.side = SIDE[params.finSide] ?? THREE.FrontSide;
fins.renderOrder = 3; m.needsUpdate = true;
}
if (tail) {
const m = tail.material;
m.alphaTest = params.tailAlphaTest; m.alphaToCoverage = true; m.depthTest = true;
m.side = SIDE[params.tailSide] ?? THREE.FrontSide;
if (params.tailTranslucent) {
m.transparent = true; m.opacity = params.tailOpacity; m.depthWrite = false;
} else {
m.transparent = false; m.opacity = 1; m.depthWrite = true; // opaque, coherent
}
tail.renderOrder = 2; m.needsUpdate = true;
}
}
function applyTransform() {
ub.rotation.set(params.pitch, params.yaw, 0);
}
applyMaterials();
applyTransform();
scene.add(ub);
// Animation — keep every action by name so Phase B can crossfade (Idle_swim →
// Eat_Up / Roll). Idle_swim is the base loop.
const mixer = new THREE.AnimationMixer(ub);
const actions = {};
for (const clip of gltf.animations) actions[clip.name] = mixer.clipAction(clip);
const baseClip = (actions.Idle_swim ? actions.Idle_swim : mixer.clipAction(gltf.animations[0]));
baseClip.play();
const baseDuration = baseClip.getClip().duration || 1;
mixer.timeScale = reduced ? 0.6 : 1; // calmer when reduced-motion
resize();
// The canvas lives in a responsive container; a ResizeObserver catches layout
// shifts (mobile browser-chrome show/hide, future side panels) a bare 'resize' misses.
const onResize = () => resize();
window.addEventListener('resize', onResize);
const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(onResize) : null;
ro?.observe(canvas);
const clock = new THREE.Clock();
renderer.setAnimationLoop(() => {
const dt = clock.getDelta();
if (params.paused) {
mixer.setTime(params.frame * baseDuration); // scrub to a frozen frame
} else {
mixer.update(dt);
}
renderer.render(scene, camera);
});
return {
// exposed for Phase B tuning + the /zen?debug=1 panel
ub, actions, mixer, scene, camera, params, baseDuration,
// live setter: merge new values, re-apply materials + transform
setParams(next = {}) {
Object.assign(params, next);
applyMaterials();
applyTransform();
return { ...params };
},
getParams() { return { ...params }; },
dispose() {
renderer.setAnimationLoop(null);
window.removeEventListener('resize', onResize);
ro?.disconnect();
scene.traverse((o) => {
o.geometry?.dispose?.();
const mats = Array.isArray(o.material) ? o.material : o.material ? [o.material] : [];
for (const m of mats) {
for (const v of Object.values(m)) v?.isTexture && v.dispose();
m.dispose?.();
}
});
renderer.dispose();
},
};
}
+7 -1
View File
@@ -3,10 +3,16 @@
import { onMount } from 'svelte';
import FeedbackModal from '$lib/components/FeedbackModal.svelte';
import { fb, openFeedback, closeFeedback } from '$lib/feedback.svelte.js';
import { trackVisit } from '$lib/analytics.js';
let { children } = $props();
// Tell the boot-failure seatbelt (app.html) the app mounted — clears the
// recovery card + timeout as soon as the shell hydrates.
onMount(() => window.__ubBooted?.());
onMount(() => {
window.__ubBooted?.();
// Count the daily visit at the LAYOUT level so every landing page counts —
// direct /play and /a/ arrivals included, not just the news homepage.
trackVisit();
});
</script>
{@render children()}
+119 -3
View File
@@ -14,8 +14,9 @@
import { auth, refresh as refreshAuth, isFollowing, toggleFollow, followKeys } from '$lib/auth.svelte.js';
import { prefs, initPrefs, active as prefsActive, applyPrefAction, persistPrefs, syncPrefsOnLogin } from '$lib/prefs.svelte.js';
import { initHistory, deviceIds, record, loadServerHistory } from '$lib/history.svelte.js';
import { trackVisit, track } from '$lib/analytics.js';
import { track } from '$lib/analytics.js';
import { pwa, installApp, dismissPwa } from '$lib/pwa.svelte.js';
import { ritualState, markBriefSeen } from '$lib/ritual.js';
let moods = $state([]);
let topics = $state([]);
@@ -28,6 +29,7 @@
// /?view=<mood|topic>, or bare / for Highlights.
function parseView(url) {
const p = url.searchParams;
if ((p.get('q') || '').trim()) return 'search';
if (p.get('source')) return 'source:' + p.get('source');
if (p.get('tag')) return 'tag:' + p.get('tag');
return p.get('view') || 'today';
@@ -39,9 +41,25 @@
return '/?view=' + encodeURIComponent(key);
}
let selected = $derived(parseView($page.url));
let searchQuery = $derived(($page.url.searchParams.get('q') || '').trim());
let searchOpen = $state(false);
let searchText = $state('');
function toggleSearch() { searchOpen = !searchOpen; if (searchOpen) searchText = searchQuery; }
function runSearch() {
const q = searchText.trim();
goto(q ? '/?q=' + encodeURIComponent(q) : '/');
}
let sourceNames = $state({}); // source id -> name, for an instant header label
let brief = $state(null);
let heroIdx = $state(0);
// Daily Ritual ("today's calm set") — derived from local game state + a
// brief-seen flag, keyed on the brief's SERVER date (never the browser's).
let ritual = $state({ items: [], count: 0, total: 0 });
let endcapEl = $state(null);
function refreshRitual() {
const d = brief?.brief_date;
if (d) ritual = ritualState(d, prefs.data.ritual);
}
let feed = $state([]);
let feedDone = $state(false); // no more pages for the current feed view
let loadingMore = $state(false);
@@ -136,6 +154,7 @@
);
let viewLabel = $derived(
selected === 'today' ? 'Highlights from Today'
: selected === 'search' ? `Search: “${searchQuery}”`
: selected.startsWith('source:') ? (sourceNames[selected.slice(7)] ?? feed[0]?.source ?? 'Source')
: selected === 'latest' ? 'Latest'
: selected === 'following' ? 'Following'
@@ -144,6 +163,7 @@
);
let viewSubtitle = $derived(
selected === 'today' ? localDateLabel(brief)
: selected === 'search' ? 'Results across Upbeat Bytes'
: selected.startsWith('source:') ? 'Latest from this source'
: selected === 'latest' ? 'Freshest calm reads — newest first'
: selected === 'following' ? 'From the sources & topics you follow'
@@ -262,6 +282,10 @@
function feedUrl(key, offset) {
const ex = Array.from(dismissed).join(',');
const exq = ex ? `&exclude=${ex}` : '';
if (key === 'search') {
const q = P.param(prefs.data);
return `/api/search?q=${encodeURIComponent(searchQuery)}&limit=${PAGE}&offset=${offset}${q ? '&' + q : ''}`;
}
if (key === 'latest') {
const q = P.param(prefs.data);
return `/api/feed?sort=latest&limit=${PAGE}&offset=${offset}${q ? '&' + q : ''}${exq}`;
@@ -340,9 +364,25 @@
// an accurate count. Clamped at 0, so an out-of-app landing stays app-safe.
if (nav.type === 'goto' || nav.type === 'link') appNavDepth += 1;
else if (nav.type === 'popstate') appNavDepth = Math.max(0, appNavDepth + (nav.delta ?? -1));
if (selected === 'search') { searchText = searchQuery; searchOpen = true; } // prefill on shared/back links
loadView(selected);
});
// The brief is "enjoyed" only when its end-cap actually scrolls into view (the
// finite read), not on mere page-open. When it does, mark it for today and
// refresh the calm-set — which also picks up any Word / Word Search played in
// this session. The block lives inside the end-cap, so by the time the reader
// sees it, the brief tick has already settled.
$effect(() => {
if (!endcapEl || !brief?.brief_date) return;
const date = brief.brief_date;
const io = new IntersectionObserver((entries) => {
if (entries.some((e) => e.isIntersecting)) { markBriefSeen(date); refreshRitual(); }
}, { threshold: 0.4 });
io.observe(endcapEl);
return () => io.disconnect();
});
// "Load more" for any feed view (Latest, topics, tags, moods): fetch the next
// page at the current length and append, de-duping against what's shown.
async function loadMore() {
@@ -448,7 +488,8 @@
seenIds = new Set(P.loadJSON(SEEN_KEY, []));
dismissed = new Set(P.loadJSON(DISMISSED_KEY, []));
refreshAuth();
trackVisit();
// trackVisit() now fires once in the global layout (covers every landing page).
if (selected === 'search') { searchText = searchQuery; searchOpen = true; } // prefill on direct/shared link
// Instant paint: render the last saved Today brief immediately and refresh
// it behind the scenes, so the first view never blocks on a (personalized,
// origin-bound) /api/brief request. "Gathering the good news…" then only
@@ -517,6 +558,9 @@
{#if viewSubtitle}<p class="sub">{viewSubtitle}</p>{/if}
</div>
<div class="vh-actions">
<button class="searchtoggle" class:on={searchOpen || selected === 'search'} onclick={toggleSearch} aria-label="Search articles" title="Search articles">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7" fill="none" stroke="currentColor" stroke-width="2"/><path d="M21 21l-4.4-4.4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
</button>
{#if auth.user && followTarget}
<button class="followbtn" class:on={isFollowing(followTarget.kind, followTarget.value)}
onclick={() => toggleFollow(followTarget.kind, followTarget.value)}>
@@ -532,6 +576,16 @@
</div>
</header>
{#if searchOpen || selected === 'search'}
<div class="searchbar rise">
<input type="search" bind:value={searchText} placeholder="Search articles, topics, or a source…"
autocapitalize="off" autocomplete="off" spellcheck="false"
onkeydown={(e) => (e.key === 'Enter' ? runSearch() : e.key === 'Escape' ? (searchOpen = false) : null)} />
<button class="searchgo" onclick={runSearch}>Search</button>
{#if selected === 'search'}<button class="searchclear" onclick={() => { searchOpen = false; goto('/'); }}>Clear</button>{/if}
</div>
{/if}
{#if selected === 'today'}
{#if sinceCount > 0 && !sinceDismissed}
<div class="welcomeback rise">
@@ -563,9 +617,24 @@
</div>
{/if}
</section>
<div class="endcap rise">
<div class="endcap rise" bind:this={endcapEl}>
<p class="endmark">✦ that's the good news for today ✦</p>
<p class="endsub">You're caught up for now.</p>
{#if ritual.total}
<div class="calmset">
<p class="cs-head">Today's calm set</p>
<ul class="cs-items">
{#each ritual.items as it (it.key)}
<li class="cs-item" class:done={it.done}>
<span class="cs-mark" aria-hidden="true"></span>{#if it.done || it.key === 'brief'}{it.label}{:else}<a href={it.href}>{it.label}</a>{/if}
</li>
{/each}
</ul>
<p class="cs-foot">
{ritual.count === ritual.total ? `All ${ritual.total} enjoyed today` : `${ritual.count} of ${ritual.total} enjoyed today`} · fresh set tomorrow · <a class="cs-edit" href="/account?section=calmset">make it yours</a>
</p>
</div>
{/if}
{#if auth.user?.digest_enabled}
<p class="digestnote">Tomorrow's brief is headed to your inbox ☕</p>
{:else}
@@ -592,6 +661,8 @@
{:else}
<p class="endcap rise">✦ you're all caught up ✦</p>
{/if}
{:else if selected === 'search'}
<p class="muted center pad">No articles found for “{searchQuery}”. Try a different word, or a source name like “Nature”.</p>
{:else if selected === 'following'}
<p class="muted center pad">
{#if auth.user}Nothing here yet — open a source or a grouping and tap <strong>Follow</strong> to fill this lane with what you care about.
@@ -660,6 +731,21 @@
.viewback:hover { border-color: var(--accent); }
.viewback svg { width: 16px; height: 16px; display: block; }
.vh-actions { flex-shrink: 0; display: flex; align-items: center; gap: 8px; margin-top: 8px; }
.searchtoggle { display: inline-flex; align-items: center; justify-content: center; width: 34px; height: 34px;
background: none; border: 1px solid var(--line); color: var(--accent-deep); border-radius: 999px;
cursor: pointer; transition: border-color 0.14s ease, background 0.14s ease; }
.searchtoggle:hover { border-color: var(--accent); }
.searchtoggle.on { background: var(--accent); border-color: var(--accent); color: #fff; }
.searchtoggle svg { width: 17px; height: 17px; display: block; }
.searchbar { display: flex; gap: 8px; margin: 0 0 18px; }
.searchbar input { flex: 1; min-width: 0; font: inherit; font-size: 1rem; padding: 10px 14px;
border: 1px solid var(--line); border-radius: 10px; background: var(--surface); color: var(--ink); }
.searchbar input:focus { outline: none; border-color: var(--accent); }
.searchgo { background: var(--accent); color: #fff; border: none; border-radius: 10px; padding: 0 18px;
font: inherit; font-weight: 600; cursor: pointer; }
.searchgo:hover { background: var(--accent-deep); }
.searchclear { background: none; border: 1px solid var(--line); color: var(--muted); border-radius: 10px;
padding: 0 14px; font: inherit; cursor: pointer; }
.followbtn {
display: inline-flex; align-items: center; gap: 5px; white-space: nowrap;
background: none; border: 1px solid var(--accent); color: var(--accent-deep);
@@ -713,6 +799,36 @@
.endcap .digestcta:hover { background: var(--accent-deep); }
.endcap .digestcta:disabled { opacity: 0.6; cursor: default; }
/* Daily Ritual — "today's calm set". Gentle, non-instrumental: a soft check
for what's been enjoyed, no streak, no pressure to finish the rest. */
.calmset {
margin: 20px auto 4px; max-width: 320px; padding-top: 16px;
border-top: 1px solid var(--line); font-style: normal; font-family: var(--label);
}
.cs-head {
margin: 0 0 10px; text-transform: uppercase; letter-spacing: 0.13em;
font-size: 0.66rem; font-weight: 600; color: var(--accent-deep);
}
.cs-items {
list-style: none; margin: 0; padding: 0; display: flex; gap: 16px;
justify-content: center; flex-wrap: wrap;
}
.cs-item { display: inline-flex; align-items: center; gap: 7px; font-size: 0.9rem; color: var(--muted); }
.cs-item a { color: inherit; text-decoration: none; }
.cs-item a:hover { color: var(--accent-deep); }
.cs-item.done { color: var(--ink); }
.cs-mark {
width: 16px; height: 16px; border-radius: 50%; border: 1.5px solid var(--line);
flex-shrink: 0; transition: background 0.16s ease, border-color 0.16s ease;
}
.cs-item.done .cs-mark {
background: var(--accent); border-color: var(--accent);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M5 12l5 5 9-10' fill='none' stroke='white' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-size: 13px; background-repeat: no-repeat; background-position: center;
}
.cs-foot { margin: 12px 0 0; font-size: 0.82rem; color: var(--muted); }
.cs-edit { color: var(--accent-deep); text-decoration: underline; white-space: nowrap; }
/* "Since you last visited" — a calm welcome-back cue on Highlights */
.welcomeback {
display: flex; align-items: center; gap: 12px; justify-content: space-between;
+51
View File
@@ -4,6 +4,7 @@
import { getJSON, postJSON } from '$lib/api.js';
import { auth, savedIds, refresh, toggleFollow } from '$lib/auth.svelte.js';
import { prefs, initPrefs, persistPrefs } from '$lib/prefs.svelte.js';
import { RITUAL_ITEMS, ritualKeys, DEFAULT_KEYS } from '$lib/ritual.js';
import { history, initHistory, loadServerHistory, removeOne, clearAll, record } from '$lib/history.svelte.js';
import { track } from '$lib/analytics.js';
import { openFeedback } from '$lib/feedback.svelte.js';
@@ -25,9 +26,24 @@
{ key: 'following', label: 'Following' },
{ key: 'history', label: 'History' },
{ key: 'lanes', label: 'Lanes' },
{ key: 'calmset', label: 'Calm set' },
{ key: 'boundaries', label: 'Boundaries' },
];
// Calm set: which daily items make up the reader's gentle daily ritual.
let calmEnabled = $derived(new Set(ritualKeys(prefs.data.ritual)));
function toggleCalmItem(key) {
const cur = new Set(ritualKeys(prefs.data.ritual));
cur.has(key) ? cur.delete(key) : cur.add(key);
const keys = RITUAL_ITEMS.map((i) => i.key).filter((k) => cur.has(k));
// null = "follow the curated default" (only when the selection IS the default);
// otherwise store the explicit chosen list (frozen — future games won't auto-join).
const isDefault = keys.length === DEFAULT_KEYS.length && DEFAULT_KEYS.every((k) => cur.has(k));
prefs.data.ritual = isDefault ? null : keys;
prefs.data = { ...prefs.data };
persistPrefs();
}
let follows = $state([]);
let followsReady = $state(false);
async function loadFollowsList() {
@@ -112,6 +128,25 @@
<p class="muted">Loading…</p>
{/if}
{:else if section === 'calmset'}
<section class="panel">
<h2>Your calm set</h2>
<p class="dnote">The gentle daily loop shown on Highlights and Play — a finite "today's set"
you can finish and feel caught up. Choose what fills yours, like building a workout. No
pressure, no streaks; uncheck anything you'd rather not see there.</p>
<ul class="calmpick">
{#each RITUAL_ITEMS as it (it.key)}
<li>
<button class="cpitem" class:on={calmEnabled.has(it.key)} onclick={() => toggleCalmItem(it.key)}
aria-pressed={calmEnabled.has(it.key)}>
<span class="cpmark" aria-hidden="true"></span>{it.label}
</button>
</li>
{/each}
</ul>
{#if calmEnabled.size === 0}<p class="empty">Your calm set is empty — it won't show until you add something.</p>{/if}
</section>
{:else if section === 'boundaries'}
<BoundariesPanel prefs={prefs.data} onchange={persistPrefs} />
@@ -226,6 +261,22 @@
.digest { margin-top: 16px; }
.digest h2 { font-size: 1.1rem; margin: 0 0 6px; }
.dnote { color: var(--muted); font-size: 0.9rem; margin: 0 0 14px; line-height: 1.5; }
.calmpick { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; max-width: 360px; }
.cpitem {
display: flex; align-items: center; gap: 10px; width: 100%; text-align: left;
background: var(--surface); border: 1px solid var(--line); border-radius: 12px;
padding: 12px 16px; font: inherit; font-weight: 600; color: var(--muted); cursor: pointer;
transition: border-color 0.14s ease, color 0.14s ease;
}
.cpitem.on { color: var(--ink); border-color: var(--accent); }
.cpitem:hover { border-color: var(--accent); }
.cpmark { width: 18px; height: 18px; border-radius: 50%; border: 1.5px solid var(--line); flex-shrink: 0;
transition: background 0.14s ease, border-color 0.14s ease; }
.cpitem.on .cpmark {
background: var(--accent); border-color: var(--accent);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M5 12l5 5 9-10' fill='none' stroke='white' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-size: 14px; background-repeat: no-repeat; background-position: center;
}
.dtoggle {
font: inherit; font-size: 0.9rem; border-radius: 999px; padding: 9px 18px; cursor: pointer;
border: 1px solid var(--accent); background: var(--surface); color: var(--accent-deep);
+589 -6
View File
@@ -4,6 +4,7 @@
import { page } from '$app/stores';
import { getJSON, postJSON, delJSON } from '$lib/api.js';
import { auth, refresh } from '$lib/auth.svelte.js';
import EmojiPicker from '$lib/EmojiPicker.svelte';
let stats = $state(null);
let feedback = $state([]);
@@ -31,26 +32,192 @@
// Load all panels in PARALLEL — these were sequential awaits, so the page
// paid the (uncached, origin) round-trip six times back-to-back. One batch
// instead of a chain.
const [, fb, cand, wp, ce, ws] = await Promise.all([
const [, fb, cand, wp, ce, ws, bq] = await Promise.all([
loadStats(),
getJSON('/api/admin/feedback'),
getJSON('/api/admin/candidates'),
getJSON('/api/admin/word/pool'),
getJSON('/api/admin/client-errors'),
getJSON('/api/admin/wordsearch/themes'),
getJSON('/api/admin/bloom/reports').catch(() => null),
]);
feedback = fb;
candidates = cand;
wpPool = wp;
clientErrors = ce;
wsThemes = ws;
if (bq) { bloomReports = bq.reports || []; bloomOverrides = bq.overrides || []; }
} catch {
error = "Couldn't load stats.";
}
});
// --- Publishing Desk: build the X share queue, write blurbs, open in X ---------
let pubItems = $state([]); // active: queued/drafting/opened
let pubArchived = $state([]); // recoverable tray: skipped/snoozed
let pubBuilding = $state(false);
let pubLast = $state(null); // last build summary {added, active, ranked_by}
let pubError = $state(null); // last build error (e.g. non-model failure), surfaced to the user
let pubLoaded = $state(false);
let pubHandleInput = $state({}); // keyed "itemId:entity" → handle being saved
let pubPollTimer = null;
const pubDraftTimers = {};
async function loadPublish() {
try {
const r = await getJSON('/api/admin/publishing/queue?archived=true');
pubBuilding = r.building; pubLast = r.last; pubError = r.error || null;
const items = r.items || [];
pubItems = items.filter((i) => ['queued', 'drafting', 'opened'].includes(i.status));
pubArchived = items.filter((i) => ['skipped', 'snoozed'].includes(i.status));
pubLoaded = true;
} catch { /* leave as-is */ }
}
function pollPublish() {
clearTimeout(pubPollTimer);
pubPollTimer = setTimeout(async () => {
await loadPublish();
if (pubBuilding) pollPublish();
}, 1500);
}
async function buildPublish() {
pubBuilding = true;
try { await postJSON('/api/admin/publishing/build', {}); pollPublish(); }
catch { pubBuilding = false; }
}
function onDraftInput(item) {
clearTimeout(pubDraftTimers[item.id]);
pubDraftTimers[item.id] = setTimeout(() => {
postJSON(`/api/admin/publishing/${item.id}/draft`, { draft_text: item.draft_text || '' }).catch(() => {});
}, 700); // debounced autosave
}
async function pubSetStatus(item, status, extra = {}) {
clearTimeout(pubDraftTimers[item.id]); // cancel a pending autosave so it can't land after the transition
try { await postJSON(`/api/admin/publishing/${item.id}/status`, { status, ...extra }); await loadPublish(); }
catch (e) { item._err = e?.message || 'Action failed.'; }
}
// "Posted" is a confirm step: the user may have edited the text inside X, so we let
// them correct the saved text + paste the real post URL before recording it.
async function pubConfirmPosted(item) {
await pubSetStatus(item, 'posted', {
final_text: item.draft_text || '',
post_url: (item._postUrl || '').trim() || null,
});
}
async function pubRestore(item) {
try { await postJSON(`/api/admin/publishing/${item.id}/restore`, {}); await loadPublish(); } catch { /* ignore */ }
}
function markOpened(item) {
// fire-and-forget so the X tab opens immediately from the click (no awaited call
// before navigation, or the browser may block the popup)
if (item.status !== 'opened') {
postJSON(`/api/admin/publishing/${item.id}/status`, { status: 'opened' }).catch(() => {});
item.status = 'opened';
}
}
async function pubSaveHandle(item, entity) {
const h = (pubHandleInput[`${item.id}:${entity}`] || '').trim();
if (!h) return;
try { await postJSON('/api/admin/publishing/handles', { entity_name: entity, handle: h }); await loadPublish(); }
catch (e) { item._err = e?.message || 'Bad handle.'; }
}
function insertHandle(item, handle) {
item.draft_text = ((item.draft_text || '').trimEnd() + ' ' + handle + ' ').trimStart();
onDraftInput(item);
}
// Emoji picker: a full searchable set (EmojiPicker.svelte) inserted at the caret so it
// drops in mid-sentence cleanly. The textarea also still accepts your OS picker.
let pubEmojiOpen = $state(null); // id of the card whose palette is open
const pubTextareas = {}; // id -> <textarea> element, for caret-aware insert
function regTextarea(node, id) {
pubTextareas[id] = node;
return { destroy() { if (pubTextareas[id] === node) delete pubTextareas[id]; } };
}
function insertEmoji(item, emoji) {
const el = pubTextareas[item.id];
const cur = item.draft_text || '';
if (el && typeof el.selectionStart === 'number') {
const s = el.selectionStart, e = el.selectionEnd;
item.draft_text = cur.slice(0, s) + emoji + cur.slice(e);
const pos = s + emoji.length;
requestAnimationFrame(() => { el.focus(); el.setSelectionRange(pos, pos); }); // caret after the emoji
} else {
item.draft_text = cur + emoji;
}
onDraftInput(item);
}
function snoozeDate(days) {
return new Date(Date.now() + days * 86400000).toISOString().slice(0, 19).replace('T', ' ');
}
function intentURL(item) {
return `https://x.com/intent/tweet?text=${encodeURIComponent(item.draft_text || '')}&url=${encodeURIComponent(item.share_url || '')}`;
}
function findOnX(entity) {
return `https://x.com/search?q=${encodeURIComponent(entity)}&f=user`;
}
// Weighted length ≈ X's counting: any URL = 23. The share link X auto-appends adds
// ~24 (a space + the 23-char t.co link), so the live budget reflects the real tweet.
function weightedLen(t) {
const urls = (t || '').match(/https?:\/\/\S+/g) || [];
return (t || '').replace(/https?:\/\/\S+/g, '').length + urls.length * 23;
}
function pubRemaining(item) {
return 280 - (weightedLen(item.draft_text) + 24);
}
// Named entities (for Find-on-X + save-a-handle); the verified ones already show as
// chips above, so this is mostly the not-yet-known ones.
function namedEntities(item) {
return (item.entities || []).slice(0, 5);
}
$effect(() => { if (section === 'publish' && !pubLoaded) loadPublish(); });
let clientErrors = $state([]);
// "X ago" from a UTC 'YYYY-MM-DD HH:MM:SS' created_at — so a morning glance shows
// whether the newest error is fresh or just last night.
function ago(ts) {
if (!ts) return '—';
const d = new Date(ts.replace(' ', 'T') + 'Z');
const s = Math.max(0, (Date.now() - d.getTime()) / 1000);
if (s < 90) return 'just now';
if (s < 5400) return Math.round(s / 60) + 'm ago';
if (s < 129600) return Math.round(s / 3600) + 'h ago';
return Math.round(s / 86400) + 'd ago';
}
// Classify a beacon by layer so one scary count doesn't conflate a 30s HTML stall
// (incident) with a 5s app boot or a post-deploy chunk miss (benign).
function errType(e) {
if (e.bot) return { k: 'bot', label: 'bot' };
const r = e.reason || '';
if (/preload|dynamically imported|failed to fetch/i.test(r)) return { k: 'preload', label: 'preload' };
const m = r.match(/^boot-slow.*?\bhtml\s+(\d+)ms/i);
if (m) return Number(m[1]) >= 3000 ? { k: 'html', label: 'html-slow' } : { k: 'app', label: 'app-slow' };
if (/^boot-slow/i.test(r)) return { k: 'app', label: 'app-slow' };
return { k: 'runtime', label: 'runtime' };
}
// --- Games: Bloom word curation (reports queue + allow/block overrides) ---
let bloomReports = $state([]);
let bloomOverrides = $state([]);
let bloomOvrWord = $state('');
async function loadBloomQueue() {
try { const r = await getJSON('/api/admin/bloom/reports'); bloomReports = r.reports || []; bloomOverrides = r.overrides || []; }
catch { /* ignore */ }
}
async function resolveBloom(id, action) {
try { await postJSON('/api/admin/bloom/reports/' + id, { action }); await loadBloomQueue(); } catch { /* ignore */ }
}
async function addBloomOverride(action) {
const w = bloomOvrWord.trim().toLowerCase();
if (!w) return;
try { await postJSON('/api/admin/bloom/overrides', { word: w, action }); bloomOvrWord = ''; await loadBloomQueue(); }
catch { /* ignore */ }
}
async function removeBloomOverride(w) {
try { await delJSON('/api/admin/bloom/overrides/' + encodeURIComponent(w)); await loadBloomQueue(); } catch { /* ignore */ }
}
// --- Games: Daily Word pool ---
let wpWord = $state('');
let wpResult = $state(null); // lookup result for the current input
@@ -168,6 +335,7 @@
{ key: 'audience', label: 'Audience' },
{ key: 'feedback', label: 'Feedback' },
{ key: 'games', label: 'Games' },
{ key: 'publish', label: 'Publishing' },
];
const VALID_SECTIONS = new Set(TABS.map((t) => t.key));
// Unknown ?section= values fall back to Overview so the page never renders blank.
@@ -263,6 +431,45 @@
}
function dismissCheck(s) { s._check = null; s._checkErr = ''; }
// --- Source article inspector: the real articles behind the metrics ---
async function toggleArticles(s) {
if (s._showArts) { s._showArts = false; return; }
s._showArts = true;
if (!s._arts) await loadArticles(s, 'all', true);
}
async function loadArticles(s, filter, reset) {
s._artBusy = true; s._artErr = '';
if (reset) { s._artFilter = filter; s._artOffset = 0; s._arts = []; }
try {
const q = `filter=${s._artFilter}&limit=25&offset=${s._artOffset}`;
const r = await getJSON(`/api/admin/sources/${s.id}/articles?${q}`);
s._arts = reset ? r.articles : [...(s._arts || []), ...r.articles];
if (r.summary) s._artSummary = r.summary;
s._artMore = r.has_more;
s._artOffset += r.articles.length;
} catch (e) { s._artErr = e?.message || 'Could not load articles.'; }
finally { s._artBusy = false; }
}
const ART_FILTERS = [['all', 'All'], ['accepted', 'Accepted'], ['rejected', 'Rejected'], ['held', 'Held'], ['no_image', 'No image'], ['duplicates', 'Duplicates']];
function pwBasis(sum) {
if (!sum) return '';
if (sum.paywall_override === 'free') return 'OFF (override)';
if (sum.paywall_override === 'paywalled') return 'ON (override)';
return sum.paywalled ? 'ON (domain)' : 'off';
}
// Per-source paywall override — corrects a domain-rule false positive/negative
// and flows into feed/lead/brief ranking + the table's 🔒. Optimistic in place
// so the inspector panel stays open.
async function setPaywall(s, override) {
const ov = override || null;
try {
await postJSON(`/api/admin/sources/${s.id}/paywall`, { override: ov });
const eff = ov === 'free' ? false : ov === 'paywalled' ? true : (s._artSummary?.paywall_domain ?? s.paywalled);
s.paywall_override = ov; s.paywalled = eff; // updates the Media-column 🔒
if (s._artSummary) { s._artSummary.paywall_override = ov; s._artSummary.paywalled = eff; }
} catch (e) { s._artErr = e?.message || 'Could not set the override.'; }
}
// --- Source candidates: supervised "add a source" pipeline ---
let candidates = $state([]);
let newFeedUrl = $state('');
@@ -270,6 +477,7 @@
let addBusy = $state(false);
let addErr = $state('');
let pendingCandidates = $derived(candidates.filter((c) => c.status !== 'promoted' && c.status !== 'rejected'));
let rejectedCandidates = $derived(candidates.filter((c) => c.status === 'rejected'));
async function addCandidate() {
const url = newFeedUrl.trim();
@@ -299,7 +507,7 @@
try {
// ~5-7s/item on the LAN model; bound the wait so a model stall can't pin
// the button on "Deep-checking…" forever.
const res = await postJSON(`/api/admin/candidates/${c.id}/preview?deep=true`, undefined, { timeout: 120000 });
const res = await postJSON(`/api/admin/candidates/${c.id}/preview?deep=true`, undefined, { timeout: 180000 });
Object.assign(c, res, { _deep: false });
} catch (e) { c._err = e?.message || 'Deep preview failed.'; c._deep = false; }
}
@@ -326,6 +534,19 @@
try { Object.assign(c, await postJSON(`/api/admin/candidates/${c.id}/reject`)); }
catch { /* leave as-is */ }
}
// Codex's operating rule for the Deep-Preview access verdict, shown on hover.
const ACC_HELP = {
fine: 'Sample reads fine — judge mainly on content quality.',
review: 'Click 23 example links before promoting — catches false-positive paywalls and bot-blocks (readable in a browser, blocked to us).',
'reject-ready': 'Domain rule AND the sample agree its walled — usually reject, unless its a source you want and the examples open fine in your browser.',
};
// Send a rejected candidate back to staging for another look (status → suggested).
async function restoreCandidate(c) {
try {
Object.assign(c, await postJSON(`/api/admin/candidates/${c.id}/restore`));
candidates = [...candidates]; // nudge the derived queue/tray to recompute
} catch { /* leave as-is */ }
}
// Feedback inbox: filter + read/unread + delete.
let fbCat = $state('all'); // 'all' | 'unread' | a category
@@ -488,12 +709,13 @@
</div>
{#if clientErrors.length}
<h2>Recent load errors <span class="count">(last {clientErrors.length})</span></h2>
<h2>Recent load errors <span class="count">(last {clientErrors.length} · newest {ago(clientErrors[0]?.created_at)})</span></h2>
<ul class="cerrs">
{#each clientErrors as e (e.created_at + e.reason)}
{@const t = errType(e)}
<li class:bot={e.bot}>
<span class="ce-when">{fdate(e.created_at)}</span>
<span class="ce-reason">{e.reason || '—'}{#if e.bot}<span class="ce-bot">bot</span>{/if}</span>
<span class="ce-reason"><span class="ce-tag {t.k}">{t.label}</span>{e.reason || '—'}</span>
<span class="ce-path">{e.path || '/'}</span>
<span class="ce-ua">{e.user_agent}{#if e.app_version} · build {e.app_version}{/if}</span>
</li>
@@ -600,9 +822,19 @@
<div class="curl">{c.feed_url}</div>
{#if c.preview}
<div class="cprev">
{c.preview.accepted ?? 0}/{c.preview.sampled ?? 0} sampled would pass{#if c.preview.acceptance_rate != null} · {Math.round(c.preview.acceptance_rate * 100)}% accept{/if}{#if c.preview.recent_7d != null} · {c.preview.recent_7d} in last 7d{/if}
{c.preview.accepted ?? 0}/{c.preview.sampled ?? 0} sampled would pass{#if c.preview.acceptance_rate != null} · {Math.round(c.preview.acceptance_rate * 100)}% accept{/if}{#if c.preview.non_english} · <span class="held" title="Non-English items are held (English-only feed for now), not counted as rejections">{c.preview.non_english} held · non-English</span>{/if}{#if c.preview.recent_7d != null} · {c.preview.recent_7d} in last 7d{/if}
{#if c.preview.classified}<span class="vbadge model" title="Scored by the real classifier — the true acceptance view">model-checked</span>{:else}<span class="vbadge fast" title="Fast keyword heuristic — an estimate. Run Deep preview for the model's real verdict.">quick estimate</span>{/if}
{#if c.preview.paywall_rule}<span class="vbadge wall" title="This domain is on the paywall list (a hint, not a verdict)">paywall domain</span>{/if}
{#if c.preview.examples_accepted?.length}<div class="cex">e.g. {c.preview.examples_accepted.slice(0, 3).join(' · ')}</div>{/if}
{#if c.preview.access}
<div class="caccess">
<span class="acc-verdict {c.preview.access_verdict}" title={ACC_HELP[c.preview.access_verdict] || ''}>{c.preview.access_verdict}</span>
<span class="acc-counts">Access: {c.preview.access.readable} readable · {c.preview.access.paywalled} paywalled{#if c.preview.access.blocked} · <span title="Couldn't fetch — may be a bot-block (readable in a browser), not a reader paywall">{c.preview.access.blocked} blocked</span>{/if}{#if c.preview.access.unknown} · {c.preview.access.unknown} unknown{/if} <span class="acc-of">({c.preview.access.checked} sampled)</span></span>
{#if c.preview.access.examples?.length}
<div class="acc-ex-row">{#each c.preview.access.examples as ex (ex.url)}<a class="acc-ex {ex.access}" href={ex.url} target="_blank" rel="noopener">{ex.access}</a>{/each}</div>
{/if}
</div>
{/if}
</div>
{/if}
{#if c._err}<p class="cerr">{c._err}</p>{/if}
@@ -620,6 +852,29 @@
</section>
{/if}
{#if rejectedCandidates.length}
<section>
<details class="rejtray">
<summary>Rejected candidates <span class="count">({rejectedCandidates.length})</span></summary>
<ul class="candlist rejlist">
{#each rejectedCandidates as c (c.id)}
<li>
<div class="chead">
<span class="cname">{c.name || c.feed_url}</span>
<span class="cstatus">rejected</span>
</div>
<div class="curl">{c.feed_url}</div>
{#if c._err}<p class="cerr">{c._err}</p>{/if}
<div class="cactions">
<button class="csend" onclick={() => restoreCandidate(c)}>Send back to staging</button>
</div>
</li>
{/each}
</ul>
</details>
</section>
{/if}
<h2>Sources <a class="exportlink" href="/api/admin/export/sources.csv" download>export CSV ↓</a></h2>
<p class="sub2">{healthy} healthy · {resting} resting · {flagged} flagged · {paused} paused · {retired} retired · {sources.length} total</p>
<div class="srctools">
@@ -677,6 +932,7 @@
<button class="act" onclick={() => (s.review_flag ? clearReview(s) : openFlag(s))}>{s.review_flag ? 'Clear' : 'Flag'}</button>
<button class="act" onclick={() => toggleVisible(s)}>{s.content_visible ? 'Hide' : 'Show'}</button>
<button class="act" title="Read-only spot-check of the live feed" onclick={() => checkSource(s)} disabled={s._checking}>{s._checking ? 'Checking…' : 'Check'}</button>
<button class="act" title="Inspect this source's real ingested articles" onclick={() => toggleArticles(s)}>{s._showArts ? 'Hide' : 'Articles'}</button>
</td>
</tr>
{#if s._checking || s._check || s._checkErr}
@@ -698,6 +954,57 @@
</td>
</tr>
{/if}
{#if s._showArts}
<tr class="artrow">
<td colspan="10">
{#if s._artSummary}
<div class="artsum">
<strong>{s._artSummary.total}</strong> ingested · {s._artSummary.accepted} accepted ·
{s._artSummary.rejected} rejected{#if s._artSummary.non_english} · <span class="held">{s._artSummary.non_english} held · non-English</span>{/if} · {s._artSummary.no_image} no image · {s._artSummary.duplicates} dup
</div>
<div class="pwctl">
<span>Paywall: <span class="pwrule" class:on={s._artSummary.paywalled}>{pwBasis(s._artSummary)}</span></span>
<select class="pwsel" onchange={(e) => setPaywall(s, e.currentTarget.value)}>
<option value="" selected={!s._artSummary.paywall_override}>Use domain rule ({s._artSummary.paywall_domain ? 'on' : 'off'})</option>
<option value="free" selected={s._artSummary.paywall_override === 'free'}>Treat as free</option>
<option value="paywalled" selected={s._artSummary.paywall_override === 'paywalled'}>Treat as paywalled</option>
</select>
</div>
{/if}
<div class="artfilters">
{#each ART_FILTERS as [key, label] (key)}
<button class="chip sm" class:on={(s._artFilter || 'all') === key} onclick={() => loadArticles(s, key, true)}>{label}</button>
{/each}
<button class="act" onclick={() => (s._showArts = false)}>close</button>
</div>
{#if s._artErr}<p class="cerr">{s._artErr}</p>{/if}
{#if s._arts?.length}
<ul class="artlist">
{#each s._arts as a (a.id)}
<li>
<div class="art-row">
{#if typeof a.url === 'string' && /^https?:\/\//.test(a.url)}
<a class="art-title" href={a.url} target="_blank" rel="noopener">{a.title}</a>
{:else}<span class="art-title plain">{a.title}</span>{/if}
{#if a.accepted === 1}<span class="badge ok">accepted</span>{:else if a.held}<span class="badge held">held · non-English</span>{:else if a.accepted === 0}<span class="badge no">rejected</span>{/if}
{#if a.paywalled}<span class="pw" title="domain paywall rule">🔒</span>{/if}
{#if !a.has_image}<span class="art-flag" title="no image extracted">no img</span>{/if}
{#if a.duplicate}<span class="art-flag" title="marked duplicate">dup</span>{/if}
{#if a.topic}<span class="art-cat">{a.topic}</span>{/if}
<span class="art-when">{a.published_at ? fdate(a.published_at) : ''}</span>
</div>
{#if a.reason}<div class="art-reason" title={a.reason}>{a.reason}</div>{/if}
</li>
{/each}
</ul>
{#if s._artMore}<button class="act more" onclick={() => loadArticles(s, s._artFilter, false)} disabled={s._artBusy}>{s._artBusy ? 'Loading…' : 'Load more'}</button>{/if}
{:else if !s._artBusy}
<p class="muted small">No articles{s._artFilter && s._artFilter !== 'all' ? ' match this filter' : ' yet'}.</p>
{/if}
{#if s._artBusy && !s._arts?.length}<p class="muted small">Loading articles…</p>{/if}
</td>
</tr>
{/if}
{:else}
<tr><td colspan="10" class="srcempty">{srcSearch.trim() ? `No sources match “${srcSearch.trim()}.` : 'No sources in this view.'}</td></tr>
{/each}
@@ -743,6 +1050,26 @@
<div class="stat"><span class="n">{stats.funnel.full_story}</span><span class="l">Straight to source</span></div>
</div>
</section>
{#if stats.games}
<section>
<h2>Games funnel <span class="muted small">· the share loop</span></h2>
<div class="cards">
<div class="stat"><span class="n">{stats.games.totals.arrival}</span><span class="l">Share arrivals</span></div>
<div class="stat"><span class="n">{stats.games.totals.started}</span><span class="l">Engaged (first move)</span></div>
<div class="stat"><span class="n">{stats.games.totals.completed}</span><span class="l">Completed</span></div>
<div class="stat"><span class="n">{stats.games.totals.shared}</span><span class="l">Shared</span></div>
</div>
<table class="gfunnel">
<thead><tr><th>Game</th><th>Arrivals</th><th>Engaged</th><th>Completed</th><th>Shared</th></tr></thead>
<tbody>
{#each [['word', 'Daily Word'], ['wordsearch', 'Word Search'], ['bloom', 'Bloom'], ['match', 'Memory Match']] as [k, label] (k)}
<tr><td>{label}</td><td>{stats.games.by_game[k].arrival}</td><td>{stats.games.by_game[k].started}</td><td>{stats.games.by_game[k].completed}</td><td>{stats.games.by_game[k].shared}</td></tr>
{/each}
</tbody>
</table>
<p class="muted small">“Share arrivals” = landings via a shared game link (utm_source=game_share). “Engaged” = first move (guess/find/flip), not just opening the page.</p>
</section>
{/if}
<section>
<h2>Emotional mix &amp; friction</h2>
<div class="cards">
@@ -867,7 +1194,46 @@
{:else}<p class="muted">No feedback yet.</p>{/if}
{:else if section === 'games'}
<h2>Daily Word pool</h2>
<h2>Bloom words <span class="count">({bloomReports.length} to review)</span></h2>
<p class="muted">Acceptance is broad (every valid dictionary word) — these are player
“should this count?” reports. <strong>Approve</strong> allows the word everywhere
(takes effect immediately, no deploy); <strong>Block</strong> hides it; <strong>Dismiss</strong>
clears the report without a rule.</p>
{#if bloomReports.length}
<ul class="bloom-reports">
{#each bloomReports as r (r.id)}
<li>
<span class="br-word">{r.word}</span>
<span class="br-ctx">{r.format || ''}{r.letters ? ' · ' + r.letters.toUpperCase() : ''}{r.puzzle_date ? ' · ' + r.puzzle_date : ''}</span>
<span class="br-acts">
<button class="wp-add" onclick={() => resolveBloom(r.id, 'approve')}>Approve</button>
<button class="act del" onclick={() => resolveBloom(r.id, 'block')}>Block</button>
<button class="link" onclick={() => resolveBloom(r.id, 'dismiss')}>Dismiss</button>
</span>
</li>
{/each}
</ul>
{:else}
<p class="empty">No pending word reports.</p>
{/if}
<div class="wp-lookup">
<input type="text" bind:value={bloomOvrWord} maxlength="24" autocapitalize="off"
autocomplete="off" spellcheck="false" placeholder="Manually allow/block a word…" />
<button class="wp-add" onclick={() => addBloomOverride('allow')}>Allow</button>
<button class="act del" onclick={() => addBloomOverride('block')}>Block</button>
</div>
{#if bloomOverrides.length}
<div class="bloom-ovr">
{#each bloomOverrides as o (o.word)}
<span class="ovr-chip {o.action}">{o.word} · {o.action}
<button class="ovr-x" aria-label="Remove override" onclick={() => removeBloomOverride(o.word)}>×</button>
</span>
{/each}
</div>
{/if}
<h2 style="margin-top:32px">Daily Word pool</h2>
<p class="muted">Look up a word to add or remove it from the answer pool. Only real, 5- or 6-letter
words in the guess dictionary qualify, so the daily answer is always solvable. Removals take
effect for future puzzles and can be restored any time.</p>
@@ -991,6 +1357,109 @@
{/each}
</ul>
{:else}<p class="muted small">No custom themes yet — the daily rotation uses the built-in ones.</p>{/if}
{:else if section === 'publish'}
<h2>Publishing Desk <span class="count">({pubItems.length})</span></h2>
<p class="sub2">Build a queue of share-worthy stories, write the blurb in your own voice, and open it in X. Posts your <code>/a/</code> share link (your card, drives to UB, still credits the source). Opening X can't confirm a post — you mark Posted.</p>
<div class="pubtools">
<button class="csend" onclick={buildPublish} disabled={pubBuilding}>{pubBuilding ? 'Building…' : 'Build queue'}</button>
{#if pubLast}<span class="muted small">Last build: +{pubLast.added} · {pubLast.active} active · ranked {pubLast.ranked_by}</span>{/if}
</div>
{#if pubError}<p class="cerr">Build error: {pubError}</p>{/if}
{#if !pubItems.length && !pubBuilding}
<p class="muted small">Queue is empty — hit “Build queue” to gather candidates.</p>
{/if}
<ul class="publist">
{#each pubItems as item (item.id)}
{@const remaining = pubRemaining(item)}
<li class="pubcard" class:opened={item.status === 'opened'}>
<div class="pub-head">
{#if item.image_url}<img class="pub-img" src={item.image_url} alt="" loading="lazy" />{/if}
<div class="pub-meta">
<a class="pub-title" href={item.share_url} target="_blank" rel="noopener">{item.title}</a>
<span class="pub-src">{item.source_name}{#if item.social_score != null} · interest {item.social_score}/10{/if}{#if item.status === 'opened'} · <span class="pub-openedtag">opened</span>{/if}</span>
</div>
</div>
{#if item.rationale}<p class="pub-why">{item.rationale}</p>{/if}
{#if item.talking_points?.length}
<ul class="pub-points">{#each item.talking_points as p}<li>{p}</li>{/each}</ul>
{/if}
{#if item.angle}<p class="pub-angle"><span class="hlbl">Angle:</span> {item.angle}</p>{/if}
{#if item.suggested_handles?.length}
<div class="pub-handles">
<span class="hlbl">Tag:</span>
{#each item.suggested_handles as h}<button class="hchip" onclick={() => insertHandle(item, h.handle)} title="Insert into your post">{h.handle}</button>{/each}
</div>
{/if}
<div class="pub-toolbar">
<button type="button" class="emoji-toggle" class:on={pubEmojiOpen === item.id}
onclick={() => (pubEmojiOpen = pubEmojiOpen === item.id ? null : item.id)}
title="Insert emoji">😊 Emoji</button>
{#if pubEmojiOpen === item.id}
<div class="emoji-pop">
<EmojiPicker onpick={(em) => insertEmoji(item, em)} />
</div>
{/if}
</div>
<textarea class="pub-draft" rows="3" placeholder="Write your post — in your voice…"
use:regTextarea={item.id}
bind:value={item.draft_text} oninput={() => onDraftInput(item)}></textarea>
<div class="pub-count" class:over={remaining < 0}>{remaining} left <span class="muted">(+ your /a/ link)</span></div>
{#if namedEntities(item).length}
<details class="pub-find">
<summary>Find / save handles</summary>
{#each namedEntities(item) as ent (ent)}
<div class="pub-ent">
<span class="pub-entname">{ent}</span>
<a class="act mini" href={findOnX(ent)} target="_blank" rel="noopener">Find on X ↗</a>
<input class="hin" placeholder="@handle" bind:value={pubHandleInput[`${item.id}:${ent}`]} />
<button class="act mini" onclick={() => pubSaveHandle(item, ent)}>Save</button>
</div>
{/each}
</details>
{/if}
{#if item._err}<p class="cerr">{item._err}</p>{/if}
{#if item._confirm}
<div class="pub-confirm">
<p class="muted small">If you tweaked the post inside X, edit the text above to match before confirming — it's saved as your final wording.</p>
<input class="hin wide" placeholder="Post URL (optional)" bind:value={item._postUrl} />
<div class="pub-actions">
<button class="csend" onclick={() => pubConfirmPosted(item)}>Confirm posted</button>
<button class="act" onclick={() => (item._confirm = false)}>Cancel</button>
</div>
</div>
{:else}
<div class="pub-actions">
<a class="csend" href={intentURL(item)} target="_blank" rel="noopener" onclick={() => markOpened(item)}>Open in X ↗</a>
<button class="act" onclick={() => (item._confirm = true)}>Posted ✓</button>
<button class="act" onclick={() => pubSetStatus(item, 'snoozed', { snooze_until: snoozeDate(1) })}>Snooze 1d</button>
<button class="act del" onclick={() => pubSetStatus(item, 'skipped')}>Skip</button>
</div>
{/if}
</li>
{/each}
</ul>
{#if pubArchived.length}
<details class="rejtray">
<summary>Archived · skipped &amp; snoozed <span class="count">({pubArchived.length})</span></summary>
<ul class="candlist">
{#each pubArchived as item (item.id)}
<li>
<div class="chead"><span class="cname">{item.title}</span><span class="cstatus">{item.status}{#if item.snooze_until} · until {item.snooze_until.slice(0, 16)}{/if}</span></div>
<div class="curl">{item.source_name}</div>
<div class="cactions"><button class="csend" onclick={() => pubRestore(item)}>Restore to queue</button></div>
</li>
{/each}
</ul>
</details>
{/if}
{/if}
{/if}
</main>
@@ -1057,6 +1526,12 @@
.stat .n { font-size: 1.7rem; font-weight: 700; font-family: var(--label); color: var(--ink); }
.stat .l { color: var(--muted); font-size: 0.8rem; }
.gfunnel { width: 100%; border-collapse: collapse; margin-top: 12px; font-size: 0.9rem; }
.gfunnel th, .gfunnel td { text-align: right; padding: 6px 10px; border-bottom: 1px solid var(--line); }
.gfunnel th:first-child, .gfunnel td:first-child { text-align: left; }
.gfunnel th { color: var(--muted); font-weight: 600; font-size: 0.78rem; }
.gfunnel td { font-variant-numeric: tabular-nums; }
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 30px; }
@media (max-width: 620px) { .two { grid-template-columns: 1fr; } }
@@ -1125,6 +1600,8 @@
.addrow input { flex: 1 1 200px; box-sizing: border-box; font: inherit; font-size: 0.9rem; padding: 8px 11px; border: 1px solid var(--line); border-radius: 9px; background: var(--bg); color: var(--ink); }
.addrow input:focus { outline: none; border-color: var(--accent); }
ul.candlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.rejtray > summary { cursor: pointer; font-family: var(--label); font-weight: 600; color: var(--muted); margin-bottom: 10px; }
.rejtray .rejlist { opacity: 0.85; } /* tucked-away, lower-emphasis tray */
ul.candlist li { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; }
.chead { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.chead .cname { font-weight: 600; color: var(--ink); }
@@ -1138,11 +1615,26 @@
.curl { font-size: 0.76rem; color: var(--muted); word-break: break-all; margin-top: 2px; }
.cprev { font-size: 0.84rem; color: var(--ink); margin-top: 7px; }
.cprev .cex { color: var(--muted); font-size: 0.8rem; margin-top: 2px; font-style: italic; }
.held { color: #8a5a18; font-weight: 600; } /* language-held (not a rejection) */
/* Heuristic-vs-model preview badge */
.vbadge { display: inline-block; margin-left: 8px; padding: 1px 8px; border-radius: 999px;
font-size: 0.68rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; cursor: help; }
.vbadge.model { background: #e3efe4; color: #3f7048; }
.vbadge.fast { background: var(--line); color: var(--muted); }
.vbadge.wall { background: #fdf0d8; color: #8a5a18; }
/* Deep-preview accessibility sample */
.caccess { margin-top: 6px; font-size: 0.8rem; color: var(--ink); display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.acc-verdict { padding: 1px 8px; border-radius: 999px; font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; }
.acc-verdict.fine { background: #e3efe4; color: #3f7048; }
.acc-verdict.review { background: #fdf0d8; color: #8a5a18; }
.acc-verdict.reject-ready { background: #fbe3e3; color: #9a3b3b; }
.acc-of { color: var(--muted); }
.acc-ex-row { display: flex; gap: 6px; flex-wrap: wrap; flex-basis: 100%; }
.acc-ex { font-size: 0.7rem; padding: 1px 7px; border-radius: 7px; text-decoration: none; }
.acc-ex.readable { background: #e3efe4; color: #3f7048; }
.acc-ex.paywalled { background: #fbe3e3; color: #9a3b3b; }
.acc-ex.blocked { background: #eceff2; color: #67727d; }
.acc-ex.unknown { background: var(--line); color: var(--muted); }
.cactions { display: flex; gap: 9px; align-items: center; flex-wrap: wrap; margin-top: 10px; }
.cactions .ccat { font: inherit; font-size: 0.8rem; padding: 5px 9px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--ink); width: 150px; }
.cactions .cchk { font-size: 0.8rem; color: var(--muted); display: inline-flex; align-items: center; gap: 5px; }
@@ -1188,6 +1680,33 @@
.chkex { margin-top: 5px; color: var(--ink); }
.chkex .chklbl { color: var(--muted); }
.chkex.chkrej { color: var(--muted); }
/* Source article inspector */
.srctable tr.artrow td { background: var(--bg); font-size: 0.84rem; padding: 10px 12px; }
.artsum { color: var(--ink); margin-bottom: 8px; }
.pwrule { color: var(--muted); font-weight: 600; }
.pwrule.on { color: #9a3b3b; }
.pwctl { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 8px; font-size: 0.8rem; color: var(--muted); }
.pwsel { font: inherit; font-size: 0.78rem; padding: 3px 8px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface); color: var(--ink); }
.artfilters { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-bottom: 8px; }
.chip.sm { font-size: 0.74rem; padding: 3px 10px; }
.artlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 7px; max-height: 360px; overflow-y: auto; }
.artlist li { border-bottom: 1px solid var(--line); padding-bottom: 6px; }
.art-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.art-title { color: var(--accent-deep); font-weight: 600; text-decoration: none; }
.art-title:hover { text-decoration: underline; }
.art-row .badge { font-size: 0.66rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; padding: 1px 7px; border-radius: 999px; }
.badge.ok { background: #e3efe4; color: #3f7048; }
.badge.no { background: #f3e0e0; color: #9a3b3b; }
.badge.held { background: #fdf0d8; color: #8a5a18; } /* language-held, not rejected */
.art-row .pw { font-size: 0.78rem; }
.art-flag { font-size: 0.7rem; color: var(--muted); border: 1px solid var(--line); border-radius: 999px; padding: 0 7px; }
.art-cat { font-size: 0.72rem; color: var(--muted); text-transform: capitalize; }
.art-when { font-size: 0.72rem; color: var(--muted); margin-left: auto; white-space: nowrap; }
.art-title.plain { color: var(--ink); font-weight: 600; }
.art-reason { font-size: 0.76rem; color: var(--muted); font-style: italic; margin-top: 2px;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; cursor: help; }
.act.more { margin-top: 8px; }
.srctable .rowactions { white-space: nowrap; }
.srctable .rowactions .act {
background: none; border: 1px solid var(--line); color: var(--accent-deep);
@@ -1283,6 +1802,15 @@
font-size: 0.82rem; padding: 8px 12px; background: var(--surface); border: 1px solid var(--line); border-radius: 8px; }
.ce-when { color: var(--muted); white-space: nowrap; }
.ce-reason { font-family: var(--label); color: #9a3b3b; }
/* Layer tag — html-slow is the only incident-grade one, so it reads loudest. */
.ce-tag { display: inline-block; margin-right: 8px; padding: 1px 7px; border-radius: 999px;
font-size: 0.66rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em;
vertical-align: middle; }
.ce-tag.html { background: #fbe3e3; color: #9a3b3b; }
.ce-tag.app { background: #fdf0d8; color: #8a5a18; }
.ce-tag.preload { background: var(--accent-soft); color: var(--accent-deep); }
.ce-tag.runtime { background: #fbe3e3; color: #9a3b3b; }
.ce-tag.bot { background: #eceff2; color: #67727d; }
.cerrs li.bot { opacity: 0.6; }
.cerrs li.bot .ce-reason { color: var(--muted); }
.ce-bot { display: inline-block; margin-left: 8px; padding: 1px 8px; border-radius: 999px;
@@ -1305,6 +1833,20 @@
font: inherit; font-weight: 600; cursor: pointer; }
.wp-add:hover { background: var(--accent-deep); }
.wp-msg { color: var(--accent-deep); font-size: 0.9rem; margin: 6px 0 0; }
.bloom-reports { list-style: none; padding: 0; margin: 10px 0 18px; display: flex; flex-direction: column; gap: 8px; }
.bloom-reports li { display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 10px 14px; }
.br-word { font-weight: 700; text-transform: capitalize; }
.br-ctx { color: var(--muted); font-size: 0.82rem; }
.br-acts { margin-left: auto; display: flex; align-items: center; gap: 10px; }
.br-acts .wp-add { padding: 5px 13px; font-size: 0.85rem; }
.bloom-ovr { display: flex; flex-wrap: wrap; gap: 8px; margin: 2px 0 8px; }
.ovr-chip { display: inline-flex; align-items: center; gap: 6px; border-radius: 999px; padding: 4px 6px 4px 12px;
font-size: 0.85rem; color: #fff; }
.ovr-chip.allow { background: #2e8b57; }
.ovr-chip.block { background: #b8553f; }
.ovr-x { background: rgba(255,255,255,0.25); border: none; color: #fff; border-radius: 50%; width: 18px; height: 18px;
line-height: 1; cursor: pointer; font-size: 0.9rem; }
.wp-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 22px; margin-top: 24px; }
.wp-col h3 { margin: 0 0 2px; font-size: 1.05rem; }
.wp-col .count { font-size: 0.85rem; }
@@ -1357,4 +1899,45 @@
border: 1px solid var(--line); border-radius: 12px; padding: 12px 16px; }
.wt-name { font-weight: 600; }
.wt-count { color: var(--muted); font-size: 0.84rem; margin-right: auto; }
/* Publishing Desk */
.pubtools { display: flex; align-items: center; gap: 12px; margin: 6px 0 16px; }
.publist { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 14px; }
.pubcard { background: var(--surface); border: 1px solid var(--line); border-radius: 14px; padding: 14px 16px; }
.pubcard.opened { border-color: var(--accent); }
.pub-head { display: flex; gap: 12px; align-items: flex-start; }
.pub-img { width: 88px; height: 64px; object-fit: cover; border-radius: 9px; flex-shrink: 0; }
.pub-meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.pub-title { font-weight: 600; color: var(--ink); text-decoration: none; }
.pub-title:hover { text-decoration: underline; }
.pub-src { font-size: 0.8rem; color: var(--muted); }
.pub-openedtag { color: var(--accent-deep); font-weight: 600; }
.pub-why { margin: 10px 0 4px; font-style: italic; color: var(--ink); }
.pub-points { margin: 4px 0; padding-left: 18px; color: var(--ink); font-size: 0.9rem; }
.pub-points li { margin: 1px 0; }
.pub-angle { margin: 4px 0; font-size: 0.88rem; color: var(--muted); }
.hlbl { font-family: var(--label); font-weight: 600; font-size: 0.78rem; color: var(--muted); }
.pub-handles { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 8px 0; }
.hchip { font-size: 0.8rem; padding: 2px 9px; border: 1px solid var(--accent); border-radius: 999px;
background: var(--accent-soft); color: var(--accent-deep); cursor: pointer; }
.pub-draft { width: 100%; box-sizing: border-box; margin-top: 8px; padding: 9px 11px; font: inherit;
border: 1px solid var(--line); border-radius: 10px; background: var(--bg); color: var(--ink); resize: vertical; }
.pub-toolbar { position: relative; margin-top: 8px; }
.emoji-toggle { font: inherit; font-size: 0.8rem; padding: 3px 10px; border: 1px solid var(--line);
border-radius: 999px; background: var(--bg); color: var(--ink); cursor: pointer; }
.emoji-toggle.on { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-deep); }
.emoji-pop { position: absolute; z-index: 20; top: calc(100% + 4px); left: 0; padding: 8px;
border: 1px solid var(--line); border-radius: 10px; background: var(--card, var(--bg));
box-shadow: 0 6px 20px rgba(0,0,0,0.18); }
.pub-count { font-size: 0.78rem; color: var(--muted); text-align: right; margin-top: 2px; font-variant-numeric: tabular-nums; }
.pub-count.over { color: #9a3b3b; font-weight: 700; }
.pub-find { margin: 8px 0; font-size: 0.85rem; }
.pub-find > summary { cursor: pointer; color: var(--accent-deep); }
.pub-ent { display: flex; align-items: center; gap: 8px; margin: 6px 0; flex-wrap: wrap; }
.pub-entname { min-width: 120px; }
.hin { font: inherit; font-size: 0.82rem; padding: 4px 8px; border: 1px solid var(--line); border-radius: 8px;
background: var(--bg); color: var(--ink); width: 130px; }
.pub-actions { display: flex; gap: 9px; align-items: center; flex-wrap: wrap; margin-top: 12px; }
.pub-confirm { margin-top: 10px; padding: 10px 12px; border: 1px solid var(--accent); border-radius: 10px; background: var(--accent-soft); }
.hin.wide { width: 100%; box-sizing: border-box; margin: 4px 0; }
</style>
+278 -14
View File
@@ -3,27 +3,55 @@
import { goto, afterNavigate } from '$app/navigation';
import { page } from '$app/stores';
import { getJSON } from '$lib/api.js';
import { pushGameStatesBatch } from '$lib/gamesync.js';
import { ritualState } from '$lib/ritual.js';
import { prefs, initPrefs } from '$lib/prefs.svelte.js';
import { auth } from '$lib/auth.svelte.js';
import { isDevGated, blockedForViewer } from '$lib/devgate.js';
import { trackGame } from '$lib/analytics.js';
import WordGame from '$lib/components/WordGame.svelte';
import WordSearchGame from '$lib/components/WordSearchGame.svelte';
import BloomGame from '$lib/components/BloomGame.svelte';
import MatchGame from '$lib/components/MatchGame.svelte';
// Screen is derived from the URL so the device/browser Back button steps through
// Hub → Game Selection → Game (each screen is its own history entry), instead of
// jumping straight out of /play.
let sp = $derived($page.url.searchParams);
let game = $derived(sp.get('game') === 'wordsearch' ? 'wordsearch' : 'word');
let game = $derived(['wordsearch', 'bloom', 'match'].includes(sp.get('game')) ? sp.get('game') : 'word');
let view = $derived(!sp.get('game') ? 'hub' : (sp.get('v') ? 'play' : 'select'));
// Bloom v: 'daily' (shared Center Circle) | 'free-center' | 'free-wild'.
let bloomMode = $derived(sp.get('v') === 'daily' ? 'daily' : 'free');
let bloomFormat = $derived(sp.get('v') === 'free-wild' ? 'wild' : 'center');
// Hide an in-dev game's card from non-admins, and bounce them off its route.
let bloomBlocked = $derived(blockedForViewer('bloom', auth.user, $page.url));
let bloomStatus = $state(null);
let variant = $derived(['5', '6'].includes(sp.get('v')) ? sp.get('v') : '5');
let wsSize = $derived(['small', 'med', 'large'].includes(sp.get('v')) ? sp.get('v') : 'med');
// Memory Match. v = "<mode>-<format>-<tier>" e.g. daily-icons-standard / free-colors-expert.
let matchBlocked = $derived(blockedForViewer('match', auth.user, $page.url));
let matchParts = $derived((sp.get('v') || '').split('-'));
let matchMode = $derived(matchParts[0] === 'free' ? 'free' : 'daily');
let matchFormat = $derived(matchParts[1] === 'colors' ? 'colors' : 'icons');
let matchTier = $derived(['gentle', 'standard', 'expert'].includes(matchParts[2]) ? matchParts[2] : 'standard');
let matchStatus = $state(null);
let matchFmt = $state('icons'); // format toggle on the Match selection screen
let date = $state('');
let wordStatus = $state({ 5: null, 6: null });
let wsStatus = $state(null);
// Daily Ritual ("today's calm set") — Brief · Daily Word · Word Search, keyed
// on the server puzzle date; the Brief tick is set on the home end-cap, read here.
let ritual = $state({ items: [], count: 0, total: 0 });
function readWord(v) {
try {
const s = JSON.parse(localStorage.getItem(`goodnews:word:${v}:${date}`) || 'null');
if (s && (s.status === 'won' || s.status === 'lost')) {
return { status: s.status, tries: (s.guesses || []).length, max: v === '6' ? 7 : 6 };
const tries = (s?.guesses || []).length;
// Surface in-progress too (so "continue on another device" shows on the card),
// not just finished games.
if (s && (s.status === 'won' || s.status === 'lost' || (s.status === 'playing' && tries > 0))) {
return { status: s.status, tries, max: v === '6' ? 7 : 6 };
}
} catch { /* ignore */ }
return null;
@@ -35,6 +63,36 @@
} catch { /* ignore */ }
return null;
}
function readBloom() {
try {
const s = JSON.parse(localStorage.getItem(`goodnews:bloom:${date}`) || 'null');
if (s && Array.isArray(s.found)) return { count: s.found.length, full: !!s.full };
} catch { /* ignore */ }
return null;
}
// All daily Match boards (any tier × format) for the day.
const MATCH_TIERS = [
['gentle', 'Gentle', '4×3 · 6 pairs'],
['standard', 'Standard', '4×4 · 8 pairs'],
['expert', 'Expert', '6×4 · match 3 of a kind'],
];
const MATCH_VARIANTS = MATCH_TIERS.flatMap(([t]) => ['icons', 'colors'].map((f) => `${t}-${f}`));
const MATCH_VS = ['daily', 'free'].flatMap((m) =>
['icons', 'colors'].flatMap((f) => MATCH_TIERS.map(([t]) => `${m}-${f}-${t}`)));
function readMatchVariant(v) {
try { return JSON.parse(localStorage.getItem(`goodnews:match:${v}:${date}`) || 'null'); } catch { return null; }
}
function readMatch() {
let best = null;
for (const v of MATCH_VARIANTS) {
const s = readMatchVariant(v);
if (!s) continue;
if (s.done) return { done: true };
const count = (s.matched || []).length;
if (count > 0 && (!best || count > best.count)) best = { done: false, count };
}
return best;
}
function refreshStatus() {
wordStatus = { 5: readWord('5'), 6: readWord('6') };
let ws = null;
@@ -44,17 +102,62 @@
if (s && s.found > 0 && !ws) ws = s;
}
wsStatus = ws;
bloomStatus = readBloom();
matchStatus = readMatch();
if (date) ritual = ritualState(date, prefs.data.ritual);
}
function fmtMs(ms) {
const s = Math.round(ms / 1000);
return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
}
// The hub itself reconciles every game with the server (signed-in), so cards
// show cross-device status WITHOUT having to open each game first, and this
// device's local progress gets uploaded even for games it hasn't reopened.
// Every daily board the hub surfaces, as [game, variant, localStorage key]. Match
// includes all tier×format variants so cross-device progress shows without opening
// the game.
function gameSpecs() {
return [
['word', '5', `goodnews:word:5:${date}`],
['word', '6', `goodnews:word:6:${date}`],
['wordsearch', 'small', `goodnews:wordsearch:small:${date}`],
['wordsearch', 'med', `goodnews:wordsearch:med:${date}`],
['wordsearch', 'large', `goodnews:wordsearch:large:${date}`],
['bloom', '', `goodnews:bloom:${date}`],
...MATCH_VARIANTS.map((v) => ['match', v, `goodnews:match:${v}:${date}`]),
];
}
// One batch request reconciles ALL boards (instead of a dozen calls on every /play
// load — that fan-out was tripping the boot-slow beacon). Still server-merged, so
// cross-device pull is preserved.
async function syncAllGames() {
if (!auth.user || !date) return;
const specs = gameSpecs();
const items = specs.map(([game, variant, key]) => {
let local = null;
try { local = JSON.parse(localStorage.getItem(key) || 'null'); } catch { /* ignore */ }
return { game, variant, state: local || {} };
});
const states = await pushGameStatesBatch(date, items);
if (states) {
const keyOf = (g, v) => specs.find((s) => s[0] === g && s[1] === v)?.[2];
for (const { game, variant, state } of states) {
if (!state) continue;
const merged = { ...state };
if (game === 'wordsearch') merged.status = merged.ms ? 'done' : 'playing'; // card reads .status
const key = keyOf(game, variant);
if (key) { try { localStorage.setItem(key, JSON.stringify(merged)); } catch { /* ignore */ } }
}
}
refreshStatus();
}
// Hub card one-liners
function wordLabel() {
const a = wordStatus['5'], b = wordStatus['6'];
if (!a && !b) return 'Guess the days word';
const part = (s, mx) => s ? (s.status === 'won' ? `${s.tries}/${mx}` : 'X') : '';
const part = (s, mx) => !s ? '' : s.status === 'won' ? `${s.tries}/${mx}` : s.status === 'lost' ? 'X' : `${s.tries}…`;
return `Today · 5:${part(a, 6)} 6:${part(b, 7)}`;
}
function wsHubLabel() {
@@ -63,12 +166,32 @@
if (wsStatus.found > 0) return `Today: ${wsStatus.found} found`;
return 'Find the days themed words';
}
function bloomHubLabel() {
if (!bloomStatus || !bloomStatus.count) return 'Make words from todays letters';
if (bloomStatus.full) return 'Today: Full Bloom 🌸';
return `Today: ${bloomStatus.count} ${bloomStatus.count === 1 ? 'word' : 'words'}`;
}
function matchHubLabel() {
if (!matchStatus) return 'Match the days pairs';
if (matchStatus.done) return 'Today: cleared';
return `Today: ${matchStatus.count} matched`;
}
function matchOpt(t) {
const s = readMatchVariant(`${t}-${matchFmt}`);
if (!s) return 'Play';
if (s.done) return 'Cleared';
const c = (s.matched || []).length;
return c > 0 ? `${c} matched` : 'Play';
}
// Game-selection option statuses
function wordOpt(v) {
const s = wordStatus[v];
if (!s) return 'Play';
return s.status === 'won' ? `Solved ${s.tries}/${s.max}` : 'Out of guesses';
if (s.status === 'won') return `Solved ${s.tries}/${s.max}`;
if (s.status === 'lost') return 'Out of guesses';
return `Continue · ${s.tries}/${s.max}`;
}
function wsOpt(sz) {
const s = readWsSize(sz);
@@ -91,13 +214,19 @@
}
// Canonicalize shareable/bookmarked URLs: unknown game → hub; invalid v for the
// game → its default (replaceState, so it doesn't add a history entry).
// game → its default (replaceState, so it doesn't add a history entry). An in-dev
// game's route bounces non-admins back to the hub.
$effect(() => {
const g = sp.get('game'), v = sp.get('v');
if (g && g !== 'word' && g !== 'wordsearch') { goto('/play', { replaceState: true }); return; }
if (g && !['word', 'wordsearch', 'bloom', 'match'].includes(g)) { goto('/play', { replaceState: true }); return; }
if (g && blockedForViewer(g, auth.user, $page.url)) { goto('/play', { replaceState: true }); return; }
if (g && v) {
const valid = g === 'word' ? ['5', '6'] : ['small', 'med', 'large'];
if (!valid.includes(v)) goto(`/play?game=${g}&v=${g === 'word' ? '5' : 'med'}`, { replaceState: true });
const valid = g === 'word' ? ['5', '6']
: g === 'wordsearch' ? ['small', 'med', 'large']
: g === 'match' ? MATCH_VS
: ['daily', 'free-center', 'free-wild'];
const def = g === 'word' ? '5' : g === 'wordsearch' ? 'med' : g === 'match' ? 'daily-icons-standard' : 'daily';
if (!valid.includes(v)) goto(`/play?game=${g}&v=${def}`, { replaceState: true });
}
});
@@ -120,15 +249,26 @@
let wsTheme = $state('');
onMount(async () => {
// Share-loop acquisition: record an arrival when someone lands via a shared game
// link (gameShareUrl tags it utm_source=game_share), attributed to that game.
if ($page.url.searchParams.get('utm_source') === 'game_share') trackGame(game, 'arrival');
initPrefs(); // so the reader's chosen calm set is available on a direct /play landing
try { date = (await getJSON('/api/puzzle/word?variant=5')).date; } catch { /* offline */ }
try { wsTheme = (await getJSON('/api/puzzle/wordsearch?variant=med')).theme; } catch { /* offline */ }
refreshStatus();
syncAllGames(); // signed-in: pull cross-device status into the cards + upload local progress
});
// Refresh hub/selection statuses whenever we land on a screen (incl. Back).
afterNavigate(() => refreshStatus());
</script>
<svelte:head><title>Play · Upbeat Bytes</title></svelte:head>
<svelte:head>
<!-- Canonical/OG/description for /play are baked into the static play.html at build
time (scripts/patch-play-head.mjs) so non-JS social scrapers get them; we keep
only the browser-tab title + dev-gate noindex here to avoid duplicate tags. -->
<title>Play · Upbeat Bytes — calm daily games</title>
{#if isDevGated(game)}<meta name="robots" content="noindex" />{/if}
</svelte:head>
<header class="bar">
<div class="container inner">
@@ -147,6 +287,19 @@
{#if view === 'hub'}
<h1>Play</h1>
<p class="sub">A small calm thing after the brief. One of each a day — no rush, no score to beat but your own.</p>
{#if date && ritual.total}
<div class="calmset">
<p class="cs-head">Today's calm set</p>
<ul class="cs-items">
{#each ritual.items as it (it.key)}
<li class="cs-item" class:done={it.done}>
<span class="cs-mark" aria-hidden="true"></span>{#if it.done}{it.label}{:else}<a href={it.href}>{it.label}</a>{/if}
</li>
{/each}
</ul>
<p class="cs-foot">{ritual.count === ritual.total ? `All ${ritual.total} enjoyed today` : `${ritual.count} of ${ritual.total} enjoyed today`} · fresh set tomorrow · <a class="cs-edit" href="/account?section=calmset">make it yours</a></p>
</div>
{/if}
<div class="cards">
<button class="gamecard" onclick={() => openGame('word')}>
<div class="gc-icon"></div>
@@ -164,19 +317,81 @@
<p class="gc-status" class:played={wsStatus && (wsStatus.status === 'done' || wsStatus.found > 0)}>{wsHubLabel()}</p>
</div>
</button>
{#if !bloomBlocked}
<button class="gamecard" onclick={() => openGame('bloom')}>
<div class="gc-icon"></div>
<div class="gc-body">
<h2>Bloom{#if isDevGated('bloom')}<span class="devtag">dev</span>{/if}</h2>
<p class="gc-sub">Make words from todays letters</p>
<p class="gc-status" class:played={bloomStatus && bloomStatus.count > 0}>{bloomHubLabel()}</p>
</div>
</button>
{/if}
{#if !matchBlocked}
<button class="gamecard" onclick={() => openGame('match')}>
<div class="gc-icon"></div>
<div class="gc-body">
<h2>Memory Match{#if isDevGated('match')}<span class="devtag">dev</span>{/if}</h2>
<p class="gc-sub">Find the pairs — icons or colors</p>
<p class="gc-status" class:played={matchStatus}>{matchHubLabel()}</p>
</div>
</button>
{/if}
{#if !blockedForViewer('zen', auth.user, $page.url)}
<a class="gamecard zencard" href="/zen">
<div class="gc-icon">🐟</div>
<div class="gc-body">
<h2>Zen Den{#if isDevGated('zen')}<span class="devtag">dev</span>{/if}</h2>
<p class="gc-sub">A calm corner — drop in with UB</p>
<p class="gc-status zen">Visit · no scores, just quiet</p>
</div>
</a>
{/if}
</div>
{:else if view === 'select'}
<h1 class="seltitle">{game === 'word' ? 'Daily Word' : 'Word Search'}</h1>
<h1 class="seltitle">{game === 'word' ? 'Daily Word' : game === 'wordsearch' ? 'Word Search' : game === 'match' ? 'Memory Match' : 'Bloom'}</h1>
{#if game === 'wordsearch' && wsTheme}
<div class="themecard">
<span class="tc-label">Todays theme</span>
<span class="tc-name">{wsTheme}</span>
</div>
{/if}
<p class="sub">{game === 'word' ? 'Pick your length.' : 'Pick your size.'}</p>
<p class="sub">{game === 'word' ? 'Pick your length.' : game === 'wordsearch' ? 'Pick your size.' : game === 'match' ? 'Pick a format, then todays board or free play.' : 'Play todays shared puzzle, or graze freely.'}</p>
<div class="opts">
{#if game === 'word'}
{#if game === 'match'}
<div class="seg">
<button class="segbtn" class:on={matchFmt === 'icons'} onclick={() => (matchFmt = 'icons')}>Memory · icons</button>
<button class="segbtn" class:on={matchFmt === 'colors'} onclick={() => (matchFmt = 'colors')}>Color Match</button>
</div>
<p class="grp">Todays board — shared daily</p>
{#each MATCH_TIERS as [t, name, desc] (t)}
<button class="opt" onclick={() => pick(`daily-${matchFmt}-${t}`)}>
<span class="opt-main"><strong>{name}</strong><span>{desc}</span></span>
<span class="opt-go" class:done={readMatchVariant(`${t}-${matchFmt}`)?.done}>{matchOpt(t)}</span>
</button>
{/each}
<p class="grp">Free play — fresh boards anytime</p>
{#each MATCH_TIERS as [t, name, desc] (t)}
<button class="opt" onclick={() => pick(`free-${matchFmt}-${t}`)}>
<span class="opt-main"><strong>{name}</strong><span>{desc}</span></span>
<span class="opt-go">Play</span>
</button>
{/each}
{:else if game === 'bloom'}
<button class="opt" onclick={() => pick('daily')}>
<span class="opt-main"><strong>Todays Bloom</strong><span>the shared daily · center letter</span></span>
<span class="opt-go" class:done={bloomStatus && bloomStatus.count > 0}>{bloomStatus && bloomStatus.count ? (bloomStatus.full ? 'Full Bloom 🌸' : `${bloomStatus.count} found`) : 'Play'}</span>
</button>
<button class="opt" onclick={() => pick('free-center')}>
<span class="opt-main"><strong>Free Play · Center Circle</strong><span>fresh wheels anytime · center letter</span></span>
<span class="opt-go">Play</span>
</button>
<button class="opt" onclick={() => pick('free-wild')}>
<span class="opt-main"><strong>Free Play · Wild Bloom</strong><span>fresh wheels · use any letters</span></span>
<span class="opt-go">Play</span>
</button>
{:else if game === 'word'}
<button class="opt" onclick={() => pick('5')}>
<span class="opt-main"><strong>Daily Word</strong><span>5 letters · 6 guesses</span></span>
<span class="opt-go" class:done={wordStatus['5']}>{wordOpt('5')}</span>
@@ -198,8 +413,16 @@
{:else if view === 'play'}
{#if game === 'word'}
<WordGame {variant} onstatus={refreshStatus} />
{:else}
{:else if game === 'wordsearch'}
<WordSearchGame size={wsSize} onstatus={refreshStatus} />
{:else if game === 'bloom'}
{#key sp.get('v')}
<BloomGame mode={bloomMode} format={bloomFormat} onstatus={refreshStatus} />
{/key}
{:else if game === 'match'}
{#key sp.get('v')}
<MatchGame mode={matchMode} format={matchFormat} tier={matchTier} {date} onstatus={refreshStatus} />
{/key}
{/if}
{/if}
</main>
@@ -219,8 +442,42 @@
margin: 8px 0 24px; max-width: 460px; text-align: center; box-shadow: var(--shadow); }
.tc-label { display: block; text-transform: uppercase; letter-spacing: 0.13em; font-size: 0.66rem;
font-family: var(--label); font-weight: 600; color: var(--accent-deep); margin-bottom: 4px; }
/* Memory Match selection: format toggle + grouped tier options */
.seg { display: flex; gap: 6px; background: var(--bg); border: 1px solid var(--line);
border-radius: 12px; padding: 4px; margin-bottom: 6px; }
.segbtn { flex: 1; padding: 9px 10px; border: none; border-radius: 9px; background: none;
font-family: inherit; font-size: 0.9rem; color: var(--muted); cursor: pointer; }
.segbtn.on { background: var(--surface); color: var(--accent-deep); font-weight: 600; box-shadow: var(--shadow); }
.grp { margin: 14px 0 2px; font-family: var(--label); text-transform: uppercase; letter-spacing: 0.04em;
font-size: 0.7rem; color: var(--muted); }
.tc-name { font-family: var(--serif); font-size: 1.7rem; color: var(--accent-deep); line-height: 1.15; }
/* Daily Ritual — "today's calm set". Gentle, non-instrumental. */
.calmset {
max-width: 460px; margin: 0 0 24px; padding: 14px 18px;
background: var(--surface); border: 1px solid var(--line); border-radius: 14px; box-shadow: var(--shadow);
}
.cs-head {
margin: 0 0 10px; text-transform: uppercase; letter-spacing: 0.13em;
font-family: var(--label); font-size: 0.64rem; font-weight: 600; color: var(--accent-deep);
}
.cs-items { list-style: none; margin: 0; padding: 0; display: flex; gap: 18px; flex-wrap: wrap; }
.cs-item { display: inline-flex; align-items: center; gap: 7px; font-size: 0.9rem; color: var(--muted); }
.cs-item a { color: inherit; text-decoration: none; }
.cs-item a:hover { color: var(--accent-deep); }
.cs-item.done { color: var(--ink); }
.cs-mark {
width: 16px; height: 16px; border-radius: 50%; border: 1.5px solid var(--line);
flex-shrink: 0; transition: background 0.16s ease, border-color 0.16s ease;
}
.cs-item.done .cs-mark {
background: var(--accent); border-color: var(--accent);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M5 12l5 5 9-10' fill='none' stroke='white' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-size: 13px; background-repeat: no-repeat; background-position: center;
}
.cs-foot { margin: 12px 0 0; font-family: var(--label); font-size: 0.82rem; color: var(--muted); }
.cs-edit { color: var(--accent-deep); text-decoration: underline; white-space: nowrap; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px; }
.gamecard {
display: flex; gap: 14px; align-items: center; text-align: left;
@@ -229,8 +486,15 @@
box-shadow: var(--shadow); transition: border-color 0.14s ease, transform 0.14s ease;
}
.gamecard:hover { border-color: var(--accent); transform: translateY(-1px); }
/* The Zen Den isn't a game — give it a soft aqua identity so it reads as a calm corner. */
.zencard { text-decoration: none; background: linear-gradient(180deg, #f2fbfc, var(--surface)); }
.zencard:hover { border-color: #7cc3cc; }
.zencard .gc-status.zen { color: #2b8c98; font-weight: 600; }
.gc-icon { font-size: 2rem; color: var(--accent); line-height: 1; flex-shrink: 0; }
.gc-body h2 { font-size: 1.2rem; margin: 0 0 3px; }
.devtag { margin-left: 8px; font-size: 0.6rem; font-family: var(--label); font-weight: 700;
text-transform: uppercase; letter-spacing: 0.08em; color: #fff; background: #c2569b;
border-radius: 5px; padding: 2px 6px; vertical-align: middle; }
.gc-sub { color: var(--muted); font-size: 0.86rem; margin: 0 0 8px; }
.gc-status { font-size: 0.84rem; color: var(--accent-deep); font-weight: 600; margin: 0; }
+166
View File
@@ -0,0 +1,166 @@
<script>
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { auth } from '$lib/auth.svelte.js';
import { isDevGated, blockedForViewer } from '$lib/devgate.js';
let canvas = $state();
let failed = $state(false);
let loading = $state(true);
// Live tuning panel (admins, /zen?debug=1) — dial in the render without redeploys.
let handle = $state(null);
let dbg = $state(null);
let debug = $state(false);
let copied = $state(false);
function apply() { handle?.setParams($state.snapshot(dbg)); }
async function copyValues() {
try {
await navigator.clipboard.writeText(JSON.stringify($state.snapshot(dbg), null, 2));
copied = true; setTimeout(() => (copied = false), 1500);
} catch { /* clipboard blocked — values are still visible in the panel */ }
}
onMount(() => {
// Dev-gated while UB is being ironed out: non-admins (no preview token) bounce.
if (blockedForViewer('zen', auth.user, $page.url)) { goto('/play'); return; }
debug = $page.url.searchParams.get('debug') === '1';
let h;
let cancelled = false; // guard the async load against an early unmount
(async () => {
try {
// WebGL guard — fall back to a warm card rather than a blank canvas.
const t = document.createElement('canvas');
if (!t.getContext('webgl2') && !t.getContext('webgl')) throw new Error('no-webgl');
const { createAquarium } = await import('$lib/zen/aquarium.js'); // lazy: three loads only here
h = await createAquarium(canvas);
if (cancelled) { h.dispose(); return; } // left /zen mid-load — don't start a loop
handle = h;
if (debug) dbg = h.getParams();
loading = false;
} catch (e) {
console.warn('Zen Den could not start:', e);
if (!cancelled) { failed = true; loading = false; }
}
})();
return () => { cancelled = true; h?.dispose(); };
});
</script>
<svelte:head>
<title>The Zen Den · Upbeat Bytes</title>
{#if isDevGated('zen')}<meta name="robots" content="noindex" />{/if}
</svelte:head>
<header class="bar">
<div class="container inner">
<a class="brand" href="/"><img class="logo" src="/logo.svg" alt="Upbeat Bytes" /></a>
<a class="back" href="/play">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 12H5M11 6l-6 6 6 6" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/></svg>Play
</a>
</div>
</header>
<main class="zen container">
<h1>The Zen Den</h1>
<p class="sub">Drop in with UB for a quiet minute.</p>
<div class="tankwrap">
{#if failed}
<div class="fallback">
<div class="fish">🐟</div>
<p>UB's tank needs a browser with WebGL. He's resting for now — try again on a different device or browser.</p>
</div>
{:else}
<canvas bind:this={canvas} class="tank" aria-label="UB the koi, swimming"></canvas>
{#if loading}<p class="loadnote">UB is settling in…</p>{/if}
{/if}
</div>
{#if debug && dbg}
<div class="panel">
<div class="prow"><strong>UB render tuner</strong><button class="copy" onclick={copyValues}>{copied ? 'copied ✓' : 'copy values'}</button></div>
<label>yaw <span>{dbg.yaw.toFixed(2)}</span>
<input type="range" min="-3.15" max="3.15" step="0.01" bind:value={dbg.yaw} oninput={apply} /></label>
<label>pitch <span>{dbg.pitch.toFixed(2)}</span>
<input type="range" min="-0.6" max="0.6" step="0.01" bind:value={dbg.pitch} oninput={apply} /></label>
<hr />
<div class="ph">Tail</div>
<label class="chk"><input type="checkbox" bind:checked={dbg.tailTranslucent} onchange={apply} /> translucent (off = opaque, coherent)</label>
<label>side
<select bind:value={dbg.tailSide} onchange={apply}><option>front</option><option>back</option><option>double</option></select></label>
<label>alphaTest <span>{dbg.tailAlphaTest.toFixed(3)}</span>
<input type="range" min="0" max="0.2" step="0.005" bind:value={dbg.tailAlphaTest} oninput={apply} /></label>
{#if dbg.tailTranslucent}
<label>opacity <span>{dbg.tailOpacity.toFixed(2)}</span>
<input type="range" min="0" max="1" step="0.05" bind:value={dbg.tailOpacity} oninput={apply} /></label>
{/if}
<hr />
<div class="ph">Fins</div>
<label>side
<select bind:value={dbg.finSide} onchange={apply}><option>front</option><option>back</option><option>double</option></select></label>
<label>opacity <span>{dbg.finOpacity.toFixed(2)}</span>
<input type="range" min="0" max="1" step="0.05" bind:value={dbg.finOpacity} oninput={apply} /></label>
<label>alphaTest <span>{dbg.finAlphaTest.toFixed(3)}</span>
<input type="range" min="0" max="0.2" step="0.005" bind:value={dbg.finAlphaTest} oninput={apply} /></label>
<hr />
<label class="chk"><input type="checkbox" bind:checked={dbg.paused} onchange={apply} /> freeze frame</label>
{#if dbg.paused}
<label>frame <span>{dbg.frame.toFixed(2)}</span>
<input type="range" min="0" max="1" step="0.01" bind:value={dbg.frame} oninput={apply} /></label>
{/if}
</div>
{/if}
</main>
<style>
header.bar { background: var(--surface); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 20; }
.inner { display: flex; align-items: center; justify-content: space-between; height: 64px; }
.logo { height: 40px; display: block; }
.back { color: var(--accent-deep); font-size: 0.9rem; display: inline-flex; align-items: center; gap: 5px; }
.back svg { width: 17px; height: 17px; display: block; }
.zen { padding: 22px 20px 60px; }
h1 { font-size: clamp(2rem, 5vw, 2.6rem); margin: 6px 0 4px; }
.sub { color: var(--muted); margin: 0 0 18px; }
/* A calm tank: soft aqua gradient backdrop; the transparent WebGL canvas sits
on top so the water reads even before we build the real bowl (Phase C). */
.tankwrap {
position: relative; width: 100%; max-width: 640px; aspect-ratio: 4 / 3;
border-radius: 20px; overflow: hidden; box-shadow: var(--shadow);
background: radial-gradient(120% 100% at 50% 0%, #d6f0f2 0%, #aadbe0 45%, #7cc3cc 100%);
}
.tank { display: block; width: 100%; height: 100%; }
.loadnote { position: absolute; inset: auto 0 16px 0; text-align: center; color: var(--accent-deep);
font-family: var(--label); font-size: 0.9rem; }
.fallback { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center;
justify-content: center; gap: 12px; text-align: center; padding: 24px; color: #2b5560; }
.fallback .fish { font-size: 3rem; }
.fallback p { max-width: 340px; margin: 0; }
/* Dev tuning panel (admins only, ?debug=1). */
.panel { margin-top: 18px; max-width: 360px; padding: 14px 16px; border: 1px solid var(--line);
border-radius: 14px; background: var(--surface); font-size: 0.82rem; }
.prow { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.panel hr { border: none; border-top: 1px solid var(--line); margin: 10px 0 6px; }
.ph { font-family: var(--label); text-transform: uppercase; letter-spacing: 0.04em; font-size: 0.72rem;
color: var(--muted); margin-bottom: 4px; }
.panel label { display: block; margin: 6px 0; color: var(--ink); }
.panel label span { float: right; color: var(--accent-deep); font-variant-numeric: tabular-nums; }
.panel label.chk { display: flex; align-items: center; gap: 7px; }
.panel input[type="range"] { width: 100%; margin-top: 3px; }
.panel select { width: 100%; margin-top: 3px; }
.copy { font-size: 0.75rem; padding: 4px 9px; border: 1px solid var(--line); border-radius: 8px;
background: var(--bg); color: var(--accent-deep); cursor: pointer; }
@media (max-width: 720px) {
.tankwrap { aspect-ratio: 3 / 4; } /* taller on phones */
}
</style>
Binary file not shown.
Binary file not shown.
+454 -23
View File
@@ -18,10 +18,13 @@ import hashlib
import hmac
import io
import json
import logging
import os
import re
import secrets
import sqlite3
import threading
import time
from collections import Counter
from contextlib import contextmanager
from datetime import datetime, timezone
@@ -33,7 +36,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from . import auth, email_send, feeds, games, oauth_google, queries, share, sources, summarize
from . import auth, bloom, email_send, feeds, games, oauth_google, publishing, queries, share, sources, summarize
from .localtime import local_today
from .markup import reply_html_to_text, sanitize_reply_html
from .db import connect
@@ -42,7 +45,7 @@ from .hero import safe_to_lead
from .llm import LocalModelClient
from .moods import MOODS, mood_filter
from .lanes import build_lane_pool
from .paywall import is_paywalled
from .paywall import is_paywalled, is_paywalled_for_source
from .taxonomy import FAMILIES, FLAVORS, TOPICS
# Edge-cache directives for GLOBAL endpoints — responses that depend only on the
@@ -55,6 +58,8 @@ _EDGE_DERIVED = "public, max-age=0, s-maxage=120, stale-while-revalidate=120"
_EDGE_FEED = "public, max-age=0, s-maxage=45, stale-while-revalidate=30" # global feed (URL-keyed, shareable only)
_PRIVATE = "private, no-store" # never share across users
log = logging.getLogger("goodnews.api")
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_DB = ROOT / "data" / "goodnews.sqlite3"
# Prefer the built SvelteKit site; fall back to the legacy single-page harness.
@@ -147,6 +152,32 @@ def _user_out(user: sqlite3.Row) -> dict:
# scrapers don't each kick off a duplicate LLM call.
_summarizing: set[int] = set()
# In-process cache of fully-rendered /a/{id} share pages. We're direct-origin (no
# CDN), so Cache-Control alone can't shield the box from crawler bursts hitting the
# sitemap's article URLs while the LAN LLM / cycle is loading it. Only COMPLETE
# pages (summary + explanation present) are cached, so a "still generating" page is
# never pinned; a short TTL still picks up edits. Per-process (fine across workers).
# INVARIANT: the share page is PUBLIC/anonymous — the cache key is article_id alone.
# If /a/{id} ever personalizes (per-viewer content), key by viewer or drop the cache,
# or one visitor's variant would be served to another.
_SHARE_CACHE: dict[int, tuple[float, str]] = {}
_SHARE_TTL = 900.0 # 15 min
_SHARE_CACHE_MAX = 512
def _share_cache_get(aid: int) -> str | None:
hit = _SHARE_CACHE.get(aid)
if hit and (time.monotonic() - hit[0]) < _SHARE_TTL:
return hit[1]
return None
def _share_cache_put(aid: int, html: str) -> None:
if len(_SHARE_CACHE) >= _SHARE_CACHE_MAX:
oldest = min(_SHARE_CACHE, key=lambda k: _SHARE_CACHE[k][0])
_SHARE_CACHE.pop(oldest, None)
_SHARE_CACHE[aid] = (time.monotonic(), html)
def _run_summary(article_id: int) -> None:
try:
@@ -158,6 +189,29 @@ def _run_summary(article_id: int) -> None:
_summarizing.discard(article_id)
# Publishing Desk: the "Build queue" job runs in the background (one bounded
# comparative LLM call can be slow); the admin polls the queue endpoint. Mirrors the
# summary-kick pattern — never holds an HTTP request open on the model. The lock makes
# the check-and-set atomic so two rapid clicks can't launch two expensive jobs.
_publish_build: dict = {"building": False, "result": None, "error": None}
_publish_build_lock = threading.Lock()
def _run_publish_build() -> None:
try:
try:
client = LocalModelClient.from_env()
except Exception: # noqa: BLE001 — model down → deterministic fallback inside build_queue
client = None
with get_conn() as conn:
res = publishing.build_queue(conn, PUBLIC_BASE_URL, client=client)
_publish_build.update(result=res, error=None)
except Exception as exc: # noqa: BLE001 — surface, don't crash the worker
_publish_build.update(error=str(exc)[:300])
finally:
_publish_build["building"] = False
def _kick_summary(article_id: int, background_tasks: BackgroundTasks) -> None:
if article_id in _summarizing:
return
@@ -216,7 +270,7 @@ def _pick_lead(items: list[dict]) -> list[dict]:
still appear in the set they just don't lead.
"""
def gentle(a: dict) -> bool:
return safe_to_lead(a) and not is_paywalled(a.get("canonical_url"))
return safe_to_lead(a) and not is_paywalled_for_source(a.get("canonical_url"), a.get("paywall_override"))
for ok in (
lambda a: gentle(a) and bool(a.get("image_url")),
@@ -290,7 +344,7 @@ class Article(BaseModel):
reason_text=row.get("reason_text"),
model_name=row.get("model_name"),
rank=row.get("rank"),
paywalled=is_paywalled(row.get("canonical_url")),
paywalled=is_paywalled_for_source(row.get("canonical_url"), row.get("paywall_override")),
tags=[t for t in (raw_tags.split(",") if raw_tags else []) if t],
)
@@ -332,7 +386,7 @@ class SourcePreview(BaseModel):
sampled: int
classified: bool
accepted: int
acceptance_rate: float
acceptance_rate: float | None # None when there are no English items to judge (all held)
avg_cortisol: float
avg_ragebait: float
avg_pr_risk: float
@@ -350,6 +404,61 @@ class WordGuessRequest(BaseModel):
n: int = 1 # this guess's position (1-based); the answer is revealed only at n >= max
class GameStateBody(BaseModel):
game: str
variant: str
date: str
state: dict = {}
class PublishStatusBody(BaseModel):
status: str
draft_text: str | None = None
final_text: str | None = None
post_url: str | None = None
snooze_until: str | None = None
class PublishDraftBody(BaseModel):
draft_text: str = ""
class EntityHandleBody(BaseModel):
entity_name: str
handle: str
profile_url: str | None = None
class GameStateItem(BaseModel):
game: str
variant: str
state: dict = {}
class GameStateBatchBody(BaseModel):
date: str
items: list[GameStateItem] = []
class BloomReportBody(BaseModel):
word: str = ""
date: str | None = None
mode: str | None = None
format: str | None = None
letters: str | None = None
reason: str | None = None
class BloomOverrideBody(BaseModel):
word: str = ""
action: str = "allow" # 'allow' | 'block'
reason: str | None = None
class BloomReportActionBody(BaseModel):
action: str = "" # 'approve' | 'block' | 'dismiss'
class WordPoolBody(BaseModel):
word: str
@@ -441,6 +550,10 @@ class SourceVisibilityBody(BaseModel):
visible: bool = True
class SourcePaywallBody(BaseModel):
override: str | None = None # None = use domain rule · 'free' · 'paywalled'
class CandidateSuggestBody(BaseModel):
feed_url: str = ""
name: str | None = None
@@ -475,13 +588,28 @@ class SourceReviewBody(BaseModel):
_FEEDBACK_CATEGORIES = {"idea", "concern", "bug", "praise", "other"}
# The only event kinds we record. All aggregate, non-personal.
# Per-game funnel events (article_id is reused as 0 — no article dimension). Per-game
# kinds (not a generic "game_started") so the admin kind-count breakdown shows which
# game drives play and, crucially, shares — the growth loop we're instrumenting.
_GAME_NAMES = ("word", "wordsearch", "bloom", "match")
# arrival = landed on the game via a shared link (utm_source=game_share) — the share
# loop's acquisition signal; started/completed/shared are the engagement funnel.
_GAME_EVENT_KINDS = {f"{g}_{e}" for g in _GAME_NAMES for e in ("started", "completed", "shared", "arrival")}
_EVENT_KINDS = {
"visit", "open", "summary_viewed", "full_story", "source_click",
"share_ub", "copy_source", "native_share",
"not_today", "less_like_this", "hide_topic",
"replace_used", "replace_none", "paywall_replace", "paywalled_source_open",
"client_error", # boot-failure seatbelt beacon (blank-screen risk signal)
}
} | _GAME_EVENT_KINDS
def _fts_query(q: str) -> str:
"""Raw search box → safe FTS5 query: alnum terms only (no operator/quote
injection), each prefix-matched and AND'd together. '' when nothing usable."""
terms = re.findall(r"[A-Za-z0-9]+", q or "")[:8]
return " ".join(f"{t}*" for t in terms)
def _visitor_hash(token: str | None) -> str:
@@ -649,22 +777,38 @@ def create_app() -> FastAPI:
state: str | None = None,
error: str | None = None,
) -> RedirectResponse:
fail = RedirectResponse(f"{PUBLIC_BASE_URL}/auth/verify?error=google", status_code=302)
if error or not code or not state:
return fail
# The user always sees the same generic error=google (no detail leaked),
# but we log WHY internally so device/host-specific failures (e.g. a www
# vs apex cookie loss, a state mismatch, a token-exchange error) are
# diagnosable instead of all looking identical.
def fail(reason: str, exc: Exception | None = None) -> RedirectResponse:
host = request.headers.get("Host", "?")
if exc is not None:
log.warning("google callback failed: %s (host=%s): %s", reason, host, exc)
else:
log.warning("google callback failed: %s (host=%s)", reason, host)
return RedirectResponse(f"{PUBLIC_BASE_URL}/auth/verify?error=google", status_code=302)
if error:
return fail(f"provider_error:{error}")
if not code or not state:
return fail("missing_code_or_state")
saved = _unsign(request.cookies.get(OAUTH_COOKIE))
if not saved:
return fail
# Most likely the host-only ub_oauth cookie was set on a different
# host than this callback (www vs apex). Canonicalizing www→apex at
# the edge prevents this.
return fail("missing_oauth_cookie")
saved_state, _, verifier = saved.partition(":")
if not hmac.compare_digest(saved_state, state):
return fail
return fail("state_mismatch")
try:
tokens = oauth_google.exchange_code(code, _google_redirect_uri(), verifier)
info = oauth_google.verify_id_token(tokens["id_token"])
if not info.get("picture") and tokens.get("access_token"):
info["picture"] = oauth_google.fetch_userinfo(tokens["access_token"]).get("picture")
except Exception:
return fail
except Exception as exc: # noqa: BLE001 — log reason, show generic error
return fail("token_exchange_or_verify", exc)
with get_conn() as conn:
user_id = auth.find_or_create_user(
conn, info["email"], "google", info["sub"],
@@ -914,13 +1058,19 @@ def create_app() -> FastAPI:
# --- Public share/landing page for an article -------------------------
@app.get("/a/{article_id}", response_class=HTMLResponse)
# GET + HEAD: FastAPI's @app.get registers GET only (no auto-HEAD), so a HEAD would
# fall through to the catch-all StaticFiles mount at "/" and 404. Register both so
# HEAD returns the same status (200/301/404) as GET, sans body.
@app.api_route("/a/{article_id}", methods=["GET", "HEAD"], response_class=HTMLResponse)
def share_page(article_id: str, background_tasks: BackgroundTasks) -> HTMLResponse:
not_found = HTMLResponse(share.render_not_found(PUBLIC_BASE_URL), status_code=404)
try:
aid = int(article_id)
except (TypeError, ValueError):
return not_found # malformed id → calm 404, no stack trace
cached = _share_cache_get(aid)
if cached is not None: # serve a rendered page without touching SQLite/render
return HTMLResponse(cached, headers={"Cache-Control": "public, max-age=300"})
with get_conn() as conn:
row = conn.execute(
"SELECT a.id, a.title, a.description, a.image_url, a.canonical_url, "
@@ -930,16 +1080,45 @@ def create_app() -> FastAPI:
"LEFT JOIN article_scores s ON s.article_id = a.id WHERE a.id = ?",
(aid,),
).fetchone()
# Only render real, accepted, non-duplicate stories.
if not row or row["duplicate_of"] is not None or not row["accepted"]:
if not row:
return not_found
# A duplicate's URL may already be indexed by Google. A hard 404 silently
# drops it (and any newer twin that arrives later retires the OLDER, already
# indexed URL) — that's what tanked impressions. So 301 to the canonical twin
# instead: Google consolidates the page onto the survivor. dedup stores a star
# (dup -> rep, rep.duplicate_of IS NULL); we still follow a short chain with a
# cycle guard as cheap insurance.
if row["duplicate_of"] is not None:
seen, cur, target = {aid}, row["duplicate_of"], None
for _ in range(8):
if cur in seen:
break
seen.add(cur)
r2 = conn.execute(
"SELECT a.id, a.duplicate_of, s.accepted FROM articles a "
"LEFT JOIN article_scores s ON s.article_id = a.id WHERE a.id = ?",
(cur,),
).fetchone()
if not r2:
break
if r2["duplicate_of"] is None:
target = r2 if r2["accepted"] else None
break
cur = r2["duplicate_of"]
if target is not None:
return RedirectResponse(f"/a/{target['id']}", status_code=301)
return not_found # canonical itself is gone/rejected → genuinely 404
if not row["accepted"]:
return not_found
summary = summarize.get_summary(conn, aid)
explanation = summarize.get_explanation(conn, aid)
if not summary or not explanation:
complete = bool(summary and explanation)
if not complete:
_kick_summary(aid, background_tasks) # generate/top-up for next time; page polls
return HTMLResponse(
share.render_share_page(dict(row), PUBLIC_BASE_URL, summary=summary, explanation=explanation)
)
html = share.render_share_page(dict(row), PUBLIC_BASE_URL, summary=summary, explanation=explanation)
if complete:
_share_cache_put(aid, html) # cache only the finished page (never the "generating" state)
return HTMLResponse(html, headers={"Cache-Control": "public, max-age=300" if complete else "no-cache"})
# --- Privacy-respecting first-party analytics -------------------------
@@ -1139,6 +1318,40 @@ def create_app() -> FastAPI:
url = src["feed_url"]
return _preview_or_502(url) # safe fetch, no DB connection held
@app.get("/api/admin/sources/{sid}/articles")
def admin_source_articles(sid: int, request: Request, filter: str = "all",
limit: int = 25, offset: int = 0) -> dict:
# Read-only inspector: the REAL ingested articles behind a source's metrics,
# so paywall/image/acceptance/duplicate signals can be verified against evidence.
limit = max(1, min(int(limit), 100))
offset = max(0, int(offset))
with get_conn() as conn:
_require_admin(conn, request)
if not conn.execute("SELECT 1 FROM sources WHERE id = ?", (sid,)).fetchone():
raise HTTPException(status_code=404, detail="source not found")
arts = queries.source_articles(conn, sid, filter, limit, offset)
return {
"articles": arts,
"summary": queries.source_articles_summary(conn, sid) if offset == 0 else None,
"has_more": len(arts) == limit,
}
@app.post("/api/admin/sources/{sid}/paywall")
def admin_source_paywall(sid: int, body: SourcePaywallBody, request: Request) -> dict:
# Per-source paywall override: corrects domain-rule false positives
# (NY Times Learning is free) / negatives. Threaded into feed/lead/brief
# ranking + badges via is_paywalled_for_source.
ov = body.override or None
if ov not in (None, "free", "paywalled"):
raise HTTPException(status_code=422, detail="override must be null, 'free', or 'paywalled'")
with get_conn() as conn:
_require_admin(conn, request)
cur = conn.execute("UPDATE sources SET paywall_override = ? WHERE id = ?", (ov, sid))
if cur.rowcount == 0:
raise HTTPException(status_code=404, detail="source not found")
conn.commit()
return {"ok": True, "override": ov}
# --- Source candidates (supervised add-a-source pipeline) ----------------
def _candidate_dict(row) -> dict:
@@ -1260,6 +1473,76 @@ def create_app() -> FastAPI:
cand = conn.execute("SELECT * FROM source_candidates WHERE id = ?", (cid,)).fetchone()
return _candidate_dict(cand)
@app.post("/api/admin/candidates/{cid}/restore")
def admin_candidate_restore(cid: int, request: Request) -> dict:
# Send a rejected candidate back to staging for another look.
with get_conn() as conn:
_require_admin(conn, request)
if not sources.restore_candidate(conn, cid):
raise HTTPException(status_code=404, detail="no rejected candidate with that id")
cand = conn.execute("SELECT * FROM source_candidates WHERE id = ?", (cid,)).fetchone()
return _candidate_dict(cand)
# --- Publishing Desk (admin): outbound-share queue for X (platform-neutral) ---
@app.post("/api/admin/publishing/build")
def admin_publishing_build(request: Request, background_tasks: BackgroundTasks) -> dict:
# Kick the queue build in the background (the comparative LLM call can be slow);
# the client polls /queue. No-op if a build is already running.
with get_conn() as conn:
_require_admin(conn, request)
with _publish_build_lock: # atomic check-and-set: one job at a time
if not _publish_build["building"]:
_publish_build.update(building=True, result=None, error=None)
background_tasks.add_task(_run_publish_build)
return {"building": True}
@app.get("/api/admin/publishing/queue")
def admin_publishing_queue(request: Request, archived: bool = False) -> dict:
with get_conn() as conn:
_require_admin(conn, request)
items = publishing.list_queue(conn, include_archived=archived)
return {"building": _publish_build["building"], "last": _publish_build.get("result"),
"error": _publish_build.get("error"), "items": items}
@app.post("/api/admin/publishing/{sid}/status")
def admin_publishing_status(sid: int, body: PublishStatusBody, request: Request) -> dict:
with get_conn() as conn:
_require_admin(conn, request)
ok = publishing.set_status(conn, sid, body.status, draft_text=body.draft_text,
final_text=body.final_text, post_url=body.post_url,
snooze_until=body.snooze_until)
if not ok:
raise HTTPException(status_code=400, detail="bad status or id")
return {"ok": True}
@app.post("/api/admin/publishing/{sid}/draft")
def admin_publishing_draft(sid: int, body: PublishDraftBody, request: Request) -> dict:
with get_conn() as conn:
_require_admin(conn, request)
ok = publishing.save_draft(conn, sid, body.draft_text)
if not ok:
raise HTTPException(status_code=404, detail="no such share")
return {"ok": True}
@app.post("/api/admin/publishing/{sid}/restore")
def admin_publishing_restore(sid: int, request: Request) -> dict:
with get_conn() as conn:
_require_admin(conn, request)
ok = publishing.restore(conn, sid)
if not ok:
raise HTTPException(status_code=400, detail="not a restorable (skipped/snoozed) share")
return {"ok": True}
@app.post("/api/admin/publishing/handles")
def admin_publishing_add_handle(body: EntityHandleBody, request: Request) -> dict:
# Save a verified handle (e.g. after confirming one via 'Find on X').
with get_conn() as conn:
_require_admin(conn, request)
ok = publishing.add_entity_handle(conn, body.entity_name, body.handle, body.profile_url)
if not ok:
raise HTTPException(status_code=400, detail="bad entity or handle")
return {"ok": True}
# --- CSV exports (admin-gated, for inspection / archiving) ---------------
def _csv_cell(v):
@@ -1540,7 +1823,7 @@ def create_app() -> FastAPI:
)
# Keep the top of a browse view readable: stable-sort paywalled items
# below readable ones (composite order preserved within each group).
rows = sorted(rows, key=lambda r: is_paywalled(r["canonical_url"]))
rows = sorted(rows, key=lambda r: is_paywalled_for_source(r["canonical_url"], r["paywall_override"]))
return FeedResponse(
topic=topic,
flavor=flavor,
@@ -1548,6 +1831,32 @@ def create_app() -> FastAPI:
items=[Article.from_row(r) for r in rows],
)
@app.get("/api/search", response_model=FeedResponse)
def search(response: Response, q: str = Query("", max_length=120),
prefs: str | None = Query(None), limit: int = Query(30, ge=1, le=60),
offset: int = Query(0, ge=0)) -> FeedResponse:
# Public article search across the visitor-facing corpus. Mirrors the feed's
# boundaries (accepted/visible/non-duplicate + the reader's Calm Filters /
# avoid-terms) but NOT a lane scope — you searched on purpose. Ranked by
# relevance (bm25), recency as a tie-break. Per-reader → never edge-cached.
response.headers["Cache-Control"] = _PRIVATE
fts = _fts_query(q)
if not fts:
return FeedResponse(topic=None, flavor=None, count=0, items=[])
fp = prefs_from_json(prefs)
now = datetime.now(timezone.utc)
kw = _prefs_sql_kw(fp, now)
with get_conn() as conn:
if not conn.execute("SELECT 1 FROM article_search LIMIT 1").fetchone():
queries.reindex_search(conn) # lazy build (fresh deploy / before first cycle)
fetch_n = min(2000, (offset + limit) * 4 + 40) if fp.avoid_terms else (offset + limit)
raw = queries.feed(conn, accepted_only=True, limit=fetch_n, offset=0, match=fts, **kw)
kept = filter_articles(raw, fp, now) if fp.avoid_terms else raw # word-boundary avoid-terms
items = kept[offset:offset + limit]
# Keep relevance order (don't paywall-reorder); the badge still shows true status.
return FeedResponse(topic=None, flavor=None, count=len(items),
items=[Article.from_row(r) for r in items])
@app.get("/api/puzzle/{game}")
def daily_puzzle(game: str, variant: str = Query("5")) -> dict:
with get_conn() as conn:
@@ -1555,8 +1864,29 @@ def create_app() -> FastAPI:
return games.word_puzzle_response(conn, local_today(), variant)
if game == "wordsearch":
return games.wordsearch_response(conn, local_today(), variant)
if game == "bloom":
return bloom.bloom_response(conn, local_today())
raise HTTPException(status_code=404, detail="no such puzzle")
@app.get("/api/puzzle/bloom/free")
def bloom_free(response: Response, format: str = "center", seed: str | None = None) -> dict:
# A free-play wheel: deterministic by `seed` (client stores it to resume),
# random when none is given. Center Circle or Wild Bloom. No DB, no sync.
fmt = "wild" if format == "wild" else "center"
s = seed if (seed and re.fullmatch(r"[A-Za-z0-9_-]{1,32}", seed)) else secrets.token_urlsafe(6)
response.headers["Cache-Control"] = "no-store"
with get_conn() as conn:
return bloom.bloom_free_response(conn, s, fmt)
@app.post("/api/bloom/report")
def bloom_report(body: BloomReportBody) -> dict:
# A player flagging a rejected word as "should count". Public + deduped;
# lands in the admin queue (approve→allow / block / dismiss).
with get_conn() as conn:
ok = bloom.add_report(conn, body.word, body.date, body.mode, body.format,
body.letters, body.reason)
return {"ok": bool(ok)}
@app.post("/api/puzzle/word/guess")
def word_guess(body: WordGuessRequest) -> dict:
if body.variant not in games.WORD_VARIANTS:
@@ -1567,7 +1897,108 @@ def create_app() -> FastAPI:
raise HTTPException(status_code=400, detail=res["error"])
return res
# --- Cross-device game state sync (signed-in only; merged server-side) ---
def _game_ok(game: str, variant: str) -> bool:
return (game == "word" and variant in games.WORD_VARIANTS) or \
(game == "wordsearch" and variant in games.WS_TIERS) or \
(game == "bloom" and variant == "") or \
(game == "match" and variant in games.MATCH_VARIANTS) # "<tier>-<format>"
def _valid_pdate(d: str) -> bool:
return bool(re.match(r"^\d{4}-\d{2}-\d{2}$", d or "")) # plain YYYY-MM-DD, no junk rows
@app.get("/api/games/state")
def game_state_get(game: str, variant: str, date: str, request: Request) -> dict:
if not _game_ok(game, variant):
raise HTTPException(status_code=404, detail="no such game")
if not _valid_pdate(date):
raise HTTPException(status_code=400, detail="bad date")
with get_conn() as conn:
user = _current_user(conn, request)
if not user:
return {"state": None}
return {"state": games.load_game_state(conn, user["id"], game, variant, date)}
@app.put("/api/games/state")
def game_state_put(body: GameStateBody, request: Request) -> dict:
if not _game_ok(body.game, body.variant):
raise HTTPException(status_code=404, detail="no such game")
if not _valid_pdate(body.date):
raise HTTPException(status_code=400, detail="bad date")
if len(json.dumps(body.state)) > 20000: # a real game state is tiny — reject junk
raise HTTPException(status_code=413, detail="state too large")
with get_conn() as conn:
user = _current_user(conn, request)
if not user:
return {"state": body.state} # signed out → no sync, just echo
merged = games.save_game_state(conn, user["id"], body.game, body.variant, body.date, body.state or {})
return {"state": merged}
@app.put("/api/games/state/batch")
def game_state_put_batch(body: GameStateBatchBody, request: Request) -> dict:
"""Reconcile many (game, variant) states for one date in a SINGLE request, so
the hub doesn't fan out a dozen calls on every /play load. Each item is
validated/sanitized/merged exactly like the single PUT; unknown or oversized
items are dropped (not fatal). Signed-out echo (no sync), same as the single
endpoint, so cross-device pull is preserved for signed-in users."""
if not _valid_pdate(body.date):
raise HTTPException(status_code=400, detail="bad date")
items = [it for it in body.items[:32]
if _game_ok(it.game, it.variant) and len(json.dumps(it.state)) <= 20000]
with get_conn() as conn:
user = _current_user(conn, request)
if not user:
return {"states": [{"game": it.game, "variant": it.variant, "state": it.state} for it in items]}
out = []
for it in items:
merged = games.save_game_state(conn, user["id"], it.game, it.variant, body.date, it.state or {})
out.append({"game": it.game, "variant": it.variant, "state": merged})
return {"states": out}
@app.get("/api/games/stats")
def game_stats_get(game: str, variant: str, request: Request) -> dict:
if not _game_ok(game, variant):
raise HTTPException(status_code=404, detail="no such game")
with get_conn() as conn:
user = _current_user(conn, request)
return {"stats": games.game_stats(conn, user["id"], game, variant) if user else None}
# --- Admin: Daily Word pool curation ---
# --- Admin: Bloom word curation (runtime, no deploy) ---
@app.get("/api/admin/bloom/reports")
def admin_bloom_reports(request: Request, status: str = "pending") -> dict:
with get_conn() as conn:
_require_admin(conn, request)
st = status if status in ("pending", "approved", "blocked", "dismissed") else "pending"
return {"status": st, "reports": bloom.list_reports(conn, st),
"overrides": bloom.list_overrides(conn)}
@app.post("/api/admin/bloom/reports/{report_id}")
def admin_bloom_resolve(report_id: int, body: BloomReportActionBody, request: Request) -> dict:
with get_conn() as conn:
admin = _require_admin(conn, request)
ok = bloom.resolve_report(conn, report_id, body.action, by=admin["email"])
if not ok:
raise HTTPException(status_code=400, detail="bad report or action")
return {"ok": True}
@app.post("/api/admin/bloom/overrides")
def admin_bloom_override(body: BloomOverrideBody, request: Request) -> dict:
with get_conn() as conn:
admin = _require_admin(conn, request)
ok = bloom.set_override(conn, body.word, body.action, reason=body.reason, by=admin["email"])
if not ok:
raise HTTPException(status_code=422,
detail="allow needs a real ≥4-letter word with no 'S'; block accepts any word")
return {"ok": True}
@app.delete("/api/admin/bloom/overrides/{word}")
def admin_bloom_override_clear(word: str, request: Request) -> dict:
with get_conn() as conn:
_require_admin(conn, request)
bloom.clear_override(conn, word)
return {"ok": True}
@app.get("/api/admin/word/lookup")
def admin_word_lookup(word: str, request: Request) -> dict:
with get_conn() as conn:
@@ -1671,7 +2102,7 @@ def create_app() -> FastAPI:
rows = queries.feed(conn, sort="latest", since=since, limit=60, **kw)
if fp.avoid_terms:
rows = filter_articles(rows, fp, now)
rows = sorted(rows, key=lambda r: is_paywalled(r["canonical_url"]))
rows = sorted(rows, key=lambda r: is_paywalled_for_source(r["canonical_url"], r["paywall_override"]))
return FeedResponse(topic=None, flavor=None, count=len(rows), items=[Article.from_row(r) for r in rows[:8]])
@app.get("/api/brief", response_model=BriefResponse)
@@ -1752,7 +2183,7 @@ def create_app() -> FastAPI:
for r in filter_articles(rows, fp, now):
if r["id"] in excl:
continue
if avoid_paywall and is_paywalled(r["canonical_url"]):
if avoid_paywall and is_paywalled_for_source(r["canonical_url"], r["paywall_override"]):
continue
if gentle and not safe_to_lead(r):
continue
+317
View File
@@ -0,0 +1,317 @@
"""Bloom — the daily word wheel (Center Circle / Wild Bloom).
DESIGN and ACCEPTANCE are decoupled:
DESIGN (wheel selection, tiers, pangram, the Full-Bloom target) uses the small
COMMON list only deterministic, stored in daily_puzzles, and unaffected by
curation. Tiers are scored on COMMON so "Flourishing" is always reachable with
everyday vocabulary, and "Full Bloom" = finding the whole *designed* puzzle
(the broad bonus words are extra credit beyond it, never required).
ACCEPTANCE is BROAD and DYNAMIC every valid dictionary word buildable from
the wheel, computed at RESPONSE TIME as: broad dict {allow} {block}, where
allow/block are runtime admin overrides (bloom_word_overrides). So a missed
word can be allowed (or a junk word blocked) with NO deploy or regeneration.
Accept words never sit in the network response: clients validate against salted
hashes and compute their own score/tier/pangram from the 7 letters.
"""
from __future__ import annotations
import hashlib
import json
import random
import sqlite3
from itertools import combinations
from pathlib import Path
_DATA = Path(__file__).parent / "data"
_W = json.loads((_DATA / "bloom_words.json").read_text())
ACCEPT: list[str] = _W["accept"] # broad: all valid dictionary words
_COMMON: set[str] = set(_W["common"]) # tight: design / tiers / pangrams only
_COMMON_LS: list[tuple[str, frozenset]] = [(w, frozenset(w)) for w in _COMMON]
_AVOID: set[str] = set(json.loads((_DATA / "bloom_avoid.json").read_text()))
# Broad accept words bucketed by distinct-letter set, so the accepted set for a
# 7-letter wheel is gathered by unioning its ≤127 letter-subsets (fast) — no scan
# of the whole ~68k list per request.
_BY_SET: dict[frozenset, list[str]] = {}
for _w in ACCEPT:
_BY_SET.setdefault(frozenset(_w), []).append(_w)
# Candidate wheels = letter-sets of 7-distinct-letter COMMON words (every wheel
# has ≥1 recognizable pangram). Sorted for deterministic order.
_PANGRAM_SETS: dict[frozenset, list[str]] = {}
for _w in _COMMON:
_s = frozenset(_w)
if len(_s) == 7:
_PANGRAM_SETS.setdefault(_s, []).append(_w)
_CANDIDATES: list[frozenset] = sorted(_PANGRAM_SETS, key=lambda s: "".join(sorted(s)))
MIN_COMMON_WORDS, MAX_COMMON_WORDS = 14, 45
PANGRAM_BONUS = 7
# 8 / 30 / 70 — Flourishing at 70% keeps Bloom from becoming a completionist
# grind. Do NOT raise Flourishing above 0.70 (Codex).
TIER_PCTS: tuple[tuple[str, float], ...] = (
("Sprouting", 0.0), ("Budding", 0.08), ("Blooming", 0.30), ("Flourishing", 0.70),
)
TOP_TIER_PCT = 0.70
def score_word(word: str) -> int:
"""4-letter word = 1 point; longer = its length. Pangram bonus added on top."""
return 1 if len(word) == 4 else len(word)
def score_words(payload: dict, words) -> int:
"""Score found words for a wheel (pangram = uses all 7 letters). Used for the
player's running score AND the Full-Bloom check (vs the design's max_score)."""
letters = frozenset(payload["center"]) | frozenset(payload["outer"])
total = 0
for w in words:
total += score_word(w)
if frozenset(w) == letters:
total += PANGRAM_BONUS
return total
# --- DESIGN: common-only, deterministic, stored --------------------------------
def tiers_for(common_max: int) -> list[dict]:
return [{"name": n, "score": int(p * common_max)} for n, p in TIER_PCTS]
def _design(letters: frozenset, center: str):
"""Center-mode design from the COMMON list only."""
commons = [w for (w, s) in _COMMON_LS if center in w and s <= letters]
pangrams = [w for w in commons if frozenset(w) == letters]
common_max = sum(score_word(w) for w in commons) + PANGRAM_BONUS * len(pangrams)
display = sorted((p for p in pangrams if p not in _AVOID), key=lambda p: (len(p), p))
return commons, display, common_max
def _design_wild(letters: frozenset):
"""Wild design (no required center) from the COMMON list only."""
commons = [w for (w, s) in _COMMON_LS if s <= letters]
pangrams = [w for w in commons if frozenset(w) == letters]
common_max = sum(score_word(w) for w in commons) + PANGRAM_BONUS * len(pangrams)
display = sorted((p for p in pangrams if p not in _AVOID), key=lambda p: (len(p), p))
vowels = [c for c in sorted(letters) if c in "aeiou"]
return commons, display, common_max, (vowels[0] if vowels else sorted(letters)[0])
def _payload(letters: frozenset, center: str, display, common_max: int) -> dict:
return {
"center": center,
"outer": sorted(letters - {center}),
"pangram": display[0],
"tiers": tiers_for(common_max),
# Full Bloom = finding the whole designed (common) puzzle; broad bonus
# words push score past this but are never required.
"max_score": common_max,
}
def _generate(seed_str: str, fmt: str) -> dict:
"""Deterministically pick a wheel design for a seed + format."""
rng = random.Random(int(hashlib.sha256(seed_str.encode()).hexdigest(), 16))
order = _CANDIDATES[:]
rng.shuffle(order)
for letters in order:
if fmt == "wild":
commons, display, cmax, center = _design_wild(letters)
if len(commons) >= MIN_COMMON_WORDS and display:
return _payload(letters, center, display, cmax)
else:
centers = sorted(letters)
rng.shuffle(centers)
for center in centers:
commons, display, cmax = _design(letters, center)
if MIN_COMMON_WORDS <= len(commons) <= MAX_COMMON_WORDS and display:
return _payload(letters, center, display, cmax)
raise RuntimeError("bloom: no valid wheel found") # impossible with the vendored dict
def build_puzzle(date: str) -> dict:
"""The day's shared Center Circle wheel design (deterministic by date)."""
return {"date": date, **_generate(f"bloom:{date}", "center")}
def build_free(seed: str, fmt: str = "center") -> dict:
"""A free-play wheel design (deterministic by seed) — Center Circle or Wild."""
fmt = "wild" if fmt == "wild" else "center"
return {"seed": seed, "format": fmt, **_generate(f"free:{fmt}:{seed}", fmt)}
# --- ACCEPTANCE: broad + runtime overrides, computed at response time ----------
def overrides(conn: sqlite3.Connection) -> tuple[set, set]:
allow, block = set(), set()
for r in conn.execute("SELECT word, action FROM bloom_word_overrides"):
(allow if r["action"] == "allow" else block).add(r["word"])
return allow, block
def _broad_words_for(letters: frozenset) -> list[str]:
"""Every broad-dictionary word buildable from `letters` (distinct-set ⊆ letters)."""
ls = sorted(letters)
out = []
for r in range(1, len(ls) + 1):
for combo in combinations(ls, r):
out.extend(_BY_SET.get(frozenset(combo), ()))
return out
def accepted_words(conn: sqlite3.Connection, center: str, outer, require_center: bool) -> list[str]:
"""The wheel's accepted set RIGHT NOW: broad words buildable from the letters
(optionally requiring the center), plus allow-overrides, minus block-overrides."""
letters = frozenset(outer) | {center}
allow, block = overrides(conn)
seen, out = set(), []
for w in _broad_words_for(letters):
if w in seen or w in block:
continue
if require_center and center not in w:
continue
seen.add(w)
out.append(w)
for w in allow: # allow words that may not be in the broad dict
if w in seen or w in block or len(w) < 4 or "s" in w:
continue
if not (frozenset(w) <= letters) or (require_center and center not in w):
continue
seen.add(w)
out.append(w)
return sorted(out)
# --- daily_puzzles storage -----------------------------------------------------
def generate_bloom_puzzle(conn: sqlite3.Connection, date: str) -> dict:
"""Ensure the day's Bloom DESIGN exists in daily_puzzles. Idempotent, pure code."""
existing = conn.execute(
"SELECT payload_json FROM daily_puzzles WHERE puzzle_date=? AND game='bloom' AND variant=''", (date,)
).fetchone()
if existing:
return json.loads(existing["payload_json"])
payload = build_puzzle(date)
conn.execute(
"INSERT OR IGNORE INTO daily_puzzles (puzzle_date, game, variant, payload_json) VALUES (?, 'bloom', '', ?)",
(date, json.dumps(payload)),
)
conn.commit()
row = conn.execute(
"SELECT payload_json FROM daily_puzzles WHERE puzzle_date=? AND game='bloom' AND variant=''", (date,)
).fetchone()
return json.loads(row["payload_json"])
def stored_payload(conn: sqlite3.Connection, date: str) -> dict | None:
"""The day's design IF it already exists — never generates (used by the state
sanitizer, which must not trigger generation)."""
row = conn.execute(
"SELECT payload_json FROM daily_puzzles WHERE puzzle_date=? AND game='bloom' AND variant=''", (date,)
).fetchone()
return json.loads(row["payload_json"]) if row else None
def word_hash(salt: str, word: str) -> str:
return hashlib.sha256(f"{salt}:{word}".encode()).hexdigest()
def _response(salt: str, p: dict, words: list[str], extra: dict) -> dict:
return {
"game": "bloom",
"center": p["center"],
"outer": p["outer"],
"accepted": [word_hash(salt, w) for w in words], # NO plaintext words leak
"max_score": p["max_score"], # Full Bloom = designed puzzle
"tiers": p["tiers"],
**extra,
}
def bloom_response(conn: sqlite3.Connection, date: str) -> dict:
"""Daily Center Circle — accepted set computed live (broad + overrides)."""
p = generate_bloom_puzzle(conn, date)
words = accepted_words(conn, p["center"], p["outer"], require_center=True)
return _response(date, p, words, {"date": date})
def bloom_free_response(conn: sqlite3.Connection, seed: str, fmt: str) -> dict:
"""Free-play wheel keyed by `seed` (resumable). Accepted set computed live."""
p = build_free(seed, fmt)
words = accepted_words(conn, p["center"], p["outer"], require_center=p["format"] != "wild")
return _response(seed, p, words, {"mode": "free", "format": p["format"], "seed": p["seed"]})
# --- runtime curation: overrides + player reports ------------------------------
def set_override(conn: sqlite3.Connection, word: str, action: str, reason: str | None = None,
by: str | None = None) -> bool:
word = (word or "").strip().lower()
if not (word.isalpha() and action in ("allow", "block")):
return False
# An ALLOW that violates Bloom's hard rules (≥4 letters, no 'S') could never
# count — reject it rather than store an inert override. BLOCK stays permissive.
if action == "allow" and (len(word) < 4 or "s" in word):
return False
conn.execute(
"INSERT INTO bloom_word_overrides (word, action, reason, created_by) VALUES (?,?,?,?) "
"ON CONFLICT(word) DO UPDATE SET action=excluded.action, reason=excluded.reason, "
"created_by=excluded.created_by, created_at=CURRENT_TIMESTAMP",
(word, action, reason, by),
)
conn.commit()
return True
def clear_override(conn: sqlite3.Connection, word: str) -> None:
conn.execute("DELETE FROM bloom_word_overrides WHERE word=?", ((word or "").strip().lower(),))
conn.commit()
def list_overrides(conn: sqlite3.Connection) -> list[dict]:
return [dict(r) for r in conn.execute(
"SELECT word, action, reason, created_by, created_at FROM bloom_word_overrides ORDER BY created_at DESC")]
def add_report(conn: sqlite3.Connection, word: str, puzzle_date, mode, fmt, letters, reason) -> bool:
word = (word or "").strip().lower()
if not (word.isalpha() and 4 <= len(word) <= 24):
return False
# Don't pile up duplicate pending reports for the same word.
dup = conn.execute(
"SELECT 1 FROM bloom_word_reports WHERE word=? AND status='pending'", (word,)).fetchone()
if dup:
return True
conn.execute(
"INSERT INTO bloom_word_reports (word, puzzle_date, mode, format, letters, reason) "
"VALUES (?,?,?,?,?,?)",
(word, str(puzzle_date or "")[:16], str(mode or "")[:8], str(fmt or "")[:8],
str(letters or "")[:16], str(reason or "")[:60]),
)
conn.commit()
return True
def list_reports(conn: sqlite3.Connection, status: str = "pending", limit: int = 100) -> list[dict]:
return [dict(r) for r in conn.execute(
"SELECT id, word, puzzle_date, mode, format, letters, reason, status, created_at "
"FROM bloom_word_reports WHERE status=? ORDER BY created_at DESC LIMIT ?", (status, limit))]
def resolve_report(conn: sqlite3.Connection, report_id: int, action: str, by: str | None = None) -> bool:
"""action: 'approve' (→ allow override) | 'block' (→ block override) | 'dismiss'."""
status = {"approve": "approved", "block": "blocked", "dismiss": "dismissed"}.get(action)
row = conn.execute("SELECT word FROM bloom_word_reports WHERE id=?", (report_id,)).fetchone()
if not row or not status:
return False
if action == "approve":
if not set_override(conn, row["word"], "allow", reason="report", by=by):
return False # can't allow (hard rule) — leave pending; dismiss instead
elif action == "block":
set_override(conn, row["word"], "block", reason="report", by=by)
conn.execute("UPDATE bloom_word_reports SET status=? WHERE id=?", (status, report_id))
conn.commit()
return True
+3 -2
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import sqlite3
from .localtime import local_today
from .paywall import is_paywalled
from .paywall import is_paywalled, is_paywalled_for_source
def build_daily_brief(
@@ -19,7 +19,7 @@ def build_daily_brief(
# changed. A calm daily brief shouldn't repeatedly hand the reader a locked
# door: push paywalled candidates below readable ones (stable sort) first.
rows = _candidate_articles(conn, target_date, window_days)
rows = sorted(rows, key=lambda r: is_paywalled(r["canonical_url"]))
rows = sorted(rows, key=lambda r: is_paywalled_for_source(r["canonical_url"], r["paywall_override"]))
selected = _select_diverse(rows, limit)
selected_ids = [row["id"] for row in selected]
@@ -121,6 +121,7 @@ def _candidate_articles(
src.name AS source_name,
src.default_category,
src.trust_score,
src.paywall_override AS paywall_override,
s.constructive_score,
s.cortisol_score,
s.ragebait_score,
+77 -27
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import argparse
import contextlib
import os
import sqlite3
from pathlib import Path
@@ -10,7 +11,7 @@ from .db import connect, init_db
from .digest import send_due_digests
from .games import generate_daily_puzzles
from .localtime import local_today
from .dedup import DEFAULT_THRESHOLD, DEFAULT_WINDOW_DAYS, dedup as run_dedup
from .dedup import DEFAULT_THRESHOLD, DEFAULT_WINDOW_DAYS, cluster_duplicates, dedup as run_dedup
from .enrich import enrich_brief_images, enrich_recent_images, enrich_summarized_images
from .summarize import generate_summary, get_summary
from .feeds import (
@@ -39,9 +40,17 @@ DEFAULT_DB = ROOT / "data" / "goodnews.sqlite3"
DEFAULT_SOURCES = ROOT / "config" / "sources.toml"
def _default_db() -> Path:
# Honor GOODNEWS_DB like the rest of the app (db.connect) does, so `GOODNEWS_DB=… `
# actually targets that DB instead of being silently ignored — otherwise a copy-DB
# maintenance run (e.g. dedup --force-recluster) can land on production by surprise.
return Path(os.environ.get("GOODNEWS_DB") or DEFAULT_DB)
def main() -> None:
parser = argparse.ArgumentParser(prog="goodnews")
parser.add_argument("--db", type=Path, default=DEFAULT_DB, help="SQLite database path")
parser.add_argument("--db", type=Path, default=_default_db(),
help="SQLite database path (defaults to $GOODNEWS_DB, else the bundled data/ DB)")
subparsers = parser.add_subparsers(dest="command", required=True)
subparsers.add_parser("init-db", help="Create or update the SQLite schema")
@@ -144,6 +153,9 @@ def main() -> None:
dedup_parser.add_argument("--embed-limit", type=int, help="Cap how many missing embeddings to compute")
dedup_parser.add_argument("--base-url", help="OpenAI-compatible base URL")
dedup_parser.add_argument("--model", help="Chat model name (unused for embeddings)")
dedup_parser.add_argument("--force-recluster", action="store_true",
help="Re-cluster the EXISTING corpus even if no new embeddings "
"(re-applies representative policy; cycle-locked, no model needed)")
check_llm_parser = subparsers.add_parser("check-llm", help="Check local OpenAI-compatible model endpoint")
check_llm_parser.add_argument("--base-url", help="OpenAI-compatible base URL, e.g. http://127.0.0.1:1234/v1")
@@ -221,7 +233,9 @@ def main() -> None:
import json as _json
p = _json.loads(r["preview_json"])
line += f" (accept {round(p.get('acceptance_rate', 0) * 100)}%, sampled {p.get('sampled', 0)})"
_rate = p.get("acceptance_rate")
_rate_str = f"{round(_rate * 100)}%" if _rate is not None else ""
line += f" (accept {_rate_str}, sampled {p.get('sampled', 0)})"
print(line)
elif args.command == "promote-candidate":
init_db(conn)
@@ -286,15 +300,31 @@ def main() -> None:
print(f"enrich-images: {found} new image(s) for summarized articles")
elif args.command == "dedup":
init_db(conn)
client = llm_client_from_args(args)
stats = run_dedup(
conn, client, threshold=args.threshold, window_days=args.window_days, embed_limit=args.embed_limit
)
print(
f"dedup: embedded={stats['embedded']} articles={stats['articles']} "
f"clusters={stats['clusters']} duplicate_clusters={stats['duplicate_clusters']} "
f"duplicates_hidden={stats['duplicates']}"
)
if args.force_recluster:
# Re-apply representative policy to the EXISTING corpus. The normal path
# fast-skips when no new embeddings exist, so it would NOT pick up a policy
# change. Cycle-locked so it can't overlap the scheduled timer; no model
# needed (pure re-cluster over stored embeddings).
with cycle_lock(args.db) as acquired:
if not acquired:
print("dedup: a cycle is already running; re-run --force-recluster after it finishes")
return
stats = cluster_duplicates(conn, threshold=args.threshold, window_days=args.window_days)
print(
f"dedup (forced recluster): articles={stats['articles']} "
f"clusters={stats['clusters']} duplicate_clusters={stats['duplicate_clusters']} "
f"duplicates_hidden={stats['duplicates']}"
)
else:
client = llm_client_from_args(args)
stats = run_dedup(
conn, client, threshold=args.threshold, window_days=args.window_days, embed_limit=args.embed_limit
)
print(
f"dedup: embedded={stats['embedded']} articles={stats['articles']} "
f"clusters={stats['clusters']} duplicate_clusters={stats['duplicate_clusters']} "
f"duplicates_hidden={stats['duplicates']}"
)
elif args.command == "check-llm":
client = llm_client_from_args(args)
try:
@@ -368,7 +398,9 @@ def list_recent(conn: sqlite3.Connection, limit: int, accepted_only: bool) -> No
def print_preview(p: dict) -> None:
mode = "model" if p["classified"] else "heuristic"
print(f"Preview of {p['url']} ({mode})")
print(f" sampled={p['sampled']} accepted={p['accepted']} ({p['acceptance_rate']*100:.0f}%)")
rate = p.get("acceptance_rate")
rate_str = f"{rate * 100:.0f}%" if rate is not None else "— (all held)"
print(f" sampled={p['sampled']} accepted={p['accepted']} ({rate_str})")
print(f" freshness: newest={p['newest_published'] or 'unknown'} in_last_7d={p['recent_7d']}")
print(f" averages: cortisol={p['avg_cortisol']} ragebait={p['avg_ragebait']} pr_risk={p['avg_pr_risk']}")
if p["topic_mix"]:
@@ -398,6 +430,28 @@ def check_feeds(conn: sqlite3.Connection, include_inactive: bool = False) -> Non
print(f"--- {ok}/{len(rows)} feeds healthy ---")
@contextlib.contextmanager
def cycle_lock(db_path):
"""Exclusive, non-blocking lock shared by the scheduled cycle and any manual job
that mutates the corpus (e.g. a forced dedup re-cluster), so they can never overlap
and contend on the database/model. Yields True if acquired, False if already held."""
import fcntl
lock_path = Path(db_path).parent / ".goodnews-cycle.lock"
lock_file = open(lock_path, "w")
try:
fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
lock_file.close()
yield False
return
try:
yield True
finally:
fcntl.flock(lock_file, fcntl.LOCK_UN)
lock_file.close()
def run_cycle(conn: sqlite3.Connection, args: argparse.Namespace) -> None:
"""One end-to-end pass for a scheduler: poll due sources, classify the new
arrivals, dedup, rebuild today's brief. Each step is independent and
@@ -406,21 +460,11 @@ def run_cycle(conn: sqlite3.Connection, args: argparse.Namespace) -> None:
Holds an exclusive lock so a manual run and the systemd timer (or two timer
ticks) can never overlap and contend on the database and model.
"""
import fcntl
lock_path = Path(args.db).parent / ".goodnews-cycle.lock"
lock_file = open(lock_path, "w")
try:
fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
print("cycle: another cycle is already running; skipping")
lock_file.close()
return
try:
with cycle_lock(args.db) as acquired:
if not acquired:
print("cycle: another cycle is already running; skipping")
return
_run_cycle_locked(conn, args)
finally:
fcntl.flock(lock_file, fcntl.LOCK_UN)
lock_file.close()
def _run_cycle_locked(conn: sqlite3.Connection, args: argparse.Namespace) -> None:
@@ -505,6 +549,12 @@ def _run_cycle_locked(conn: sqlite3.Connection, args: argparse.Namespace) -> Non
except Exception as exc:
print(f"review: skipped ({exc})")
try:
from .queries import reindex_search
print(f"search: indexed {reindex_search(conn)} articles")
except Exception as exc: # noqa: BLE001 — search index is non-critical
print(f"search: skipped ({exc})")
if not args.no_digest:
try:
sent = send_due_digests(conn) # morning-gated + deduped internally
+1
View File
@@ -0,0 +1 @@
["vagina", "vulva", "nipple", "rectum", "anal", "fecal", "ejaculation", "eunuch", "nude", "nudity", "butt"]
+1
View File
@@ -0,0 +1 @@
["death","dying","died","killed","killing","murder","murdered","corpse","coffin","funeral","grave","buried","burial","weapon","gunshot","warfare","violent","violence","deadly","lethal","poison","poisoned","suicide","slaughter","victim","bleeding","wound","wounded","vomit","vomiting","vomited","diarrhea","disease","diseased","cancer","tumor","illness","infection","infected","plague","disabled","lucifer","satan","demon","demonic","devil","damned","hatred","hateful","terror","terrorize","hostage","kidnap","kidnapped","abuse","abused","assault","trauma","traumatic","anxiety","depression","depressed","divorce","divorced","bankrupt","eviction","evicted","layoff","drowned","drowning","choking","suffocate","starving","famine","poverty","despair","misery","miserable","tragic","tragedy","horror","horrible","nightmare","panic","dread","grief","grieving","mourning","rotting","decay","decayed","maggot","vermin","filth","sewage","manure"]
File diff suppressed because one or more lines are too long
+91 -1
View File
@@ -28,6 +28,7 @@ CREATE TABLE IF NOT EXISTS sources (
retry_after_at TEXT,
review_flag INTEGER NOT NULL DEFAULT 0,
review_reason TEXT,
x_handle TEXT, -- the source's own verified X handle, if known
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@@ -69,6 +70,7 @@ CREATE TABLE IF NOT EXISTS article_scores (
reason_text TEXT,
topic TEXT,
flavor TEXT,
language TEXT,
model_name TEXT,
scored_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@@ -300,6 +302,47 @@ CREATE TABLE IF NOT EXISTS daily_puzzles (
UNIQUE (puzzle_date, game, variant)
);
-- Full-text search over the PUBLIC article corpus (title/description/source/tags).
-- Standalone FTS5 (not external-content) since the searchable text spans tables;
-- rebuilt from the accepted, non-duplicate set on each ingest cycle (+ lazily).
CREATE VIRTUAL TABLE IF NOT EXISTS article_search USING fts5(
article_id UNINDEXED, title, body, source_name, tags
);
CREATE TABLE IF NOT EXISTS game_state (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
game TEXT NOT NULL, -- 'word' | 'wordsearch'
variant TEXT NOT NULL, -- '5'|'6' | 'small'|'med'|'large'
puzzle_date TEXT NOT NULL,
state_json TEXT NOT NULL, -- per-puzzle progress; merged server-side on save
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, game, variant, puzzle_date)
);
-- Bloom runtime word curation (no deploy needed). The accepted set is computed
-- live as: broad dictionary {allow} {block}. Admin-managed; one row per word.
CREATE TABLE IF NOT EXISTS bloom_word_overrides (
word TEXT PRIMARY KEY, -- lowercase
action TEXT NOT NULL, -- 'allow' | 'block'
reason TEXT,
created_by TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Player "this should count" reports admin queue (approveallow / block / dismiss).
CREATE TABLE IF NOT EXISTS bloom_word_reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
word TEXT NOT NULL, -- lowercase
puzzle_date TEXT,
mode TEXT, -- 'daily' | 'free'
format TEXT, -- 'center' | 'wild'
letters TEXT, -- the wheel's 7 letters (for context)
reason TEXT, -- why it was rejected (e.g. 'not in the word list')
status TEXT NOT NULL DEFAULT 'pending', -- 'pending' | 'approved' | 'blocked' | 'dismissed'
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_bloom_reports_status ON bloom_word_reports(status, created_at);
CREATE TABLE IF NOT EXISTS user_follows (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -317,6 +360,49 @@ CREATE TABLE IF NOT EXISTS digest_sends (
sent_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, brief_date)
);
-- Publishing Desk: a platform-NEUTRAL outbound-share record (X first; Bluesky /
-- Threads / newsletter later reuse this). One row per (article, platform); the
-- queue tops up without ever overwriting saved text/handles. opened != posted
-- Web Intents can't confirm a post, so the human confirms the terminal state.
CREATE TABLE IF NOT EXISTS outbound_shares (
id INTEGER PRIMARY KEY AUTOINCREMENT,
article_id INTEGER NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
platform TEXT NOT NULL DEFAULT 'x',
status TEXT NOT NULL DEFAULT 'queued', -- queued|drafting|opened|posted|skipped|snoozed
social_score INTEGER, -- LLM "stop-scrolling" interest (0-10)
rationale TEXT, -- why someone would stop scrolling
talking_points TEXT, -- JSON array of factual points
angle TEXT, -- a suggested conversational angle
entities TEXT, -- JSON array of raw named entities (LLM-extracted)
suggested_handles TEXT, -- JSON array of {handle, profile_url, via}
draft_text TEXT, -- autosaved in-progress blurb (the human writes it)
final_text TEXT, -- what was actually posted (teaches voice later)
share_url TEXT, -- the exact /a/{id}?utm... link used
post_url TEXT, -- the resulting tweet URL, if captured
snooze_until TEXT, -- 'not right now' (re-eligible after this)
opened_at TEXT,
posted_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (article_id, platform)
);
CREATE INDEX IF NOT EXISTS idx_outbound_shares_status ON outbound_shares(platform, status);
-- Verified handle directory the LLM only ever proposes NAMES; the @handle comes
-- only from here (or a source's own x_handle). Aliases resolve consistently by each
-- having its own row pointing at the same handle (e.g. "Johns Hopkins University"
-- and "Johns Hopkins").
CREATE TABLE IF NOT EXISTS entity_handles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_name TEXT NOT NULL, -- display name as entered
normalized_name TEXT NOT NULL, -- lowercased/stripped match key
platform TEXT NOT NULL DEFAULT 'x',
handle TEXT NOT NULL, -- e.g. @AnthropicAI
profile_url TEXT,
verified_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (normalized_name, platform)
);
"""
@@ -349,7 +435,7 @@ def _migrate(conn: sqlite3.Connection) -> None:
need an explicit, idempotent ALTER guarded by the current column set.
"""
score_cols = {row["name"] for row in conn.execute("PRAGMA table_info(article_scores)")}
for column in ("topic", "flavor"):
for column in ("topic", "flavor", "language"):
if column not in score_cols:
conn.execute(f"ALTER TABLE article_scores ADD COLUMN {column} TEXT")
@@ -382,10 +468,14 @@ def _migrate(conn: sqlite3.Connection) -> None:
"consecutive_failures": "INTEGER NOT NULL DEFAULT 0",
"review_flag": "INTEGER NOT NULL DEFAULT 0",
"review_reason": "TEXT",
"paywall_override": "TEXT", # NULL = use domain rule · 'free' · 'paywalled'
}
for column, decl in health_columns.items():
if column not in source_cols:
conn.execute(f"ALTER TABLE sources ADD COLUMN {column} {decl}")
# Publishing Desk: the source's own verified X handle (suggested when sharing).
if "x_handle" not in source_cols:
conn.execute("ALTER TABLE sources ADD COLUMN x_handle TEXT")
# Lifecycle: status (active/paused/retired) + content_visible. `active` is
# kept as a synced mirror so legacy code (scheduler/CLI) keeps working.
+35 -3
View File
@@ -102,7 +102,8 @@ def cluster_duplicates(
(COALESCE(s.constructive_score,0) + COALESCE(s.agency_score,0)
+ COALESCE(s.human_benefit_score,0) + src.trust_score
- COALESCE(s.cortisol_score,0) - COALESCE(s.ragebait_score,0)
- COALESCE(s.pr_risk_score,0)) AS rank_score
- COALESCE(s.pr_risk_score,0)) AS rank_score,
COALESCE(s.accepted, 0) AS accepted
FROM articles a
JOIN article_embeddings e ON e.article_id = a.id
JOIN sources src ON src.id = a.source_id
@@ -114,7 +115,8 @@ def cluster_duplicates(
items = []
for r in rows:
vec = _unit(array("f", r["vector"]).tolist())
items.append({"id": r["id"], "ord": _day_ordinal(r["dt"]), "vec": vec, "score": r["rank_score"]})
items.append({"id": r["id"], "ord": _day_ordinal(r["dt"]), "vec": vec,
"score": r["rank_score"], "accepted": bool(r["accepted"])})
clusters: list[dict] = [] # {anchor_vec, anchor_ord, members:[item]}
for it in items:
@@ -130,6 +132,14 @@ def cluster_duplicates(
if not placed:
clusters.append({"anchor_vec": it["vec"], "anchor_ord": it["ord"], "members": [it]})
# Which articles are CURRENTLY a representative (something points at them)? Captured
# BEFORE we reset, so we can keep an established canonical stable across runs.
prior_reps = {
row[0] for row in conn.execute(
"SELECT DISTINCT duplicate_of FROM articles WHERE duplicate_of IS NOT NULL"
)
}
# Reset prior decisions for everything we considered, then re-apply.
considered = [it["id"] for it in items]
conn.executemany(
@@ -142,7 +152,19 @@ def cluster_duplicates(
if len(cl["members"]) < 2:
continue
dup_clusters += 1
rep = max(cl["members"], key=lambda m: (m["score"], -m["id"]))
# Representative priority (highest wins), in order:
# 1. accepted/serveable — an accepted page must never be retired to a REJECTED
# rep (that page would 404 with nothing to redirect to).
# 2. established rep — if a member is already the cluster's canonical, keep it,
# so an indexed URL doesn't churn when a newer twin arrives.
# 3. quality score — decides genuinely-new clusters.
# 4. -id — deterministic final tiebreak (older wins).
rep = max(cl["members"], key=lambda m: (
1 if m["accepted"] else 0,
1 if m["id"] in prior_reps else 0,
m["score"],
-m["id"],
))
for m in cl["members"]:
if m["id"] != rep["id"]:
conn.execute(
@@ -166,6 +188,16 @@ def dedup(
embed_limit: int | None = None,
) -> dict:
embedded = ensure_embeddings(conn, client, limit=embed_limit)
if embedded == 0:
# Nothing new entered the corpus → the clusters and duplicate_of links are
# unchanged, so skip the full re-cluster. It was re-running an O(n²) cosine
# pass over EVERY article and rewriting duplicate_of for all ~3.7k of them
# every cycle (~53s + a large WAL commit), which starved live API reads
# (/api/brief 2-7s). Most cycles find no new articles, so this makes the
# cycle near-instant and keeps reads fast. A real new article re-runs it.
dups = conn.execute("SELECT COUNT(*) FROM articles WHERE duplicate_of IS NOT NULL").fetchone()[0]
return {"embedded": 0, "articles": 0, "clusters": 0, "duplicate_clusters": 0,
"duplicates": dups, "skipped": True}
stats = cluster_duplicates(conn, threshold=threshold, window_days=window_days)
stats["embedded"] = embedded
return stats
+5 -5
View File
@@ -16,7 +16,7 @@ from html import escape
from . import email_send
from .localtime import local_now, local_today
from .paywall import is_paywalled
from .paywall import is_paywalled, is_paywalled_for_source
DIGEST_HOUR = int(os.environ.get("GOODNEWS_DIGEST_HOUR", "7"))
DIGEST_WINDOW_HOURS = 4 # send between DIGEST_HOUR and +4h, site-local
@@ -31,7 +31,7 @@ def digest_items(conn: sqlite3.Connection, brief_date: str, limit: int = 7) -> l
"""The brief's items with the bits a calm email needs (visible sources only)."""
rows = conn.execute(
"""
SELECT a.id, a.title, a.canonical_url, s.name AS source, sc.reason_text,
SELECT a.id, a.title, a.canonical_url, s.name AS source, s.paywall_override, sc.reason_text,
(SELECT summary FROM article_summaries WHERE article_id = a.id) AS summary
FROM daily_briefs b
JOIN daily_brief_items bi ON bi.brief_id = b.id
@@ -47,7 +47,7 @@ def digest_items(conn: sqlite3.Connection, brief_date: str, limit: int = 7) -> l
items = []
for r in rows:
d = dict(r)
d["paywalled"] = is_paywalled(d["canonical_url"])
d["paywalled"] = is_paywalled_for_source(d["canonical_url"], d.get("paywall_override"))
items.append(d)
return items
@@ -74,7 +74,7 @@ def followed_digest_items(conn: sqlite3.Connection, user_id: int, exclude_ids, l
params += ftags
rows = conn.execute(
f"""
SELECT a.id, a.title, a.canonical_url, s.name AS source, a.source_id, sc.reason_text,
SELECT a.id, a.title, a.canonical_url, s.name AS source, s.paywall_override, a.source_id, sc.reason_text,
(SELECT summary FROM article_summaries WHERE article_id = a.id) AS summary
FROM articles a
JOIN sources s ON s.id = a.source_id
@@ -92,7 +92,7 @@ def followed_digest_items(conn: sqlite3.Connection, user_id: int, exclude_ids, l
if d["id"] in exclude or per_source.get(d["source_id"], 0) >= 1:
continue
per_source[d["source_id"]] = 1
d["paywalled"] = is_paywalled(d["canonical_url"])
d["paywalled"] = is_paywalled_for_source(d["canonical_url"], d.get("paywall_override"))
out.append(d)
if len(out) >= limit:
break
+84 -1
View File
@@ -243,6 +243,11 @@ def poll_source(conn: sqlite3.Connection, source: sqlite3.Row) -> dict:
}
# Deep-preview accessibility sample bounds (module-level so tests can shrink them).
_ACCESS_FETCH_TIMEOUT = 6 # per-article socket timeout (seconds)
_ACCESS_DEADLINE_S = 12.0 # hard wall-clock cap for the whole access phase
def preview_feed(url: str, sample: int = 25, pr_risk_default: int = 3, client=None, fetcher=None) -> dict:
"""Fetch and score a sample of a feed WITHOUT persisting anything.
@@ -302,12 +307,85 @@ def preview_feed(url: str, sample: int = 25, pr_risk_default: int = 3, client=No
cortisol=ns["cortisol_score"],
ragebait=ns["ragebait_score"],
pr_risk=ns["pr_risk_score"],
reason_code=ns["reason_code"],
language=ns.get("language", ""),
)
except Exception:
pass # one bad item shouldn't sink the whole preview
total = len(rows)
accepted = sum(1 for r in rows if r["accepted"])
# Non-English items are HELD (English-only feed for now), not calm-filter
# rejections — surface the count and judge acceptance over English items only, so
# a multilingual wire (e.g. PR Newswire) isn't unfairly penalized in the preview.
non_english = sum(1 for r in rows if r.get("reason_code") == "non_english")
judged = total - non_english
# Accessibility sample — deep preview only (it already means "spend ~a minute to
# really know"). Layered per Codex: the instant DOMAIN rule + a small sampled
# article fetch, so a paywall verdict rests on evidence, not domain alone (NYT
# Learning proved domain rules false-positive).
from .paywall import check_article_access, is_paywalled
domain_paywalled = is_paywalled(url)
access = None
access_verdict = None
if classified and rows:
from concurrent.futures import ThreadPoolExecutor, as_completed
# prefer the URLs the model would actually surface, then fill from the rest
ordered = [r["url"] for r in rows if r["accepted"] and r["url"]] + \
[r["url"] for r in rows if not r["accepted"] and r["url"]]
seen, sample_urls = set(), []
for u in ordered:
if u not in seen:
seen.add(u)
sample_urls.append(u)
if len(sample_urls) >= 6:
break
results = []
if sample_urls:
af = fetcher or fetch_feed
ex = ThreadPoolExecutor(max_workers=min(6, len(sample_urls)))
futs = {ex.submit(check_article_access, u, af, _ACCESS_FETCH_TIMEOUT): u for u in sample_urls}
done = {}
try:
# Hard wall-clock cap: the access step can NEVER stall the whole
# preview. Fetches run in parallel; whatever hasn't finished by the
# deadline is left 'unknown' (unverified — never counts as walled).
# shutdown(wait=False, cancel_futures=True) below means we don't block
# on stragglers (no `with ... as ex` join), so wall-clock == the cap.
for fut in as_completed(futs, timeout=_ACCESS_DEADLINE_S):
done[futs[fut]] = fut.result()
except Exception: # noqa: BLE001 — overall deadline hit; use what finished
pass
ex.shutdown(wait=False, cancel_futures=True)
results = [(u, done.get(u, "unknown")) for u in sample_urls]
counts = Counter(a for _, a in results)
readable, paywalled = counts.get("readable", 0), counts.get("paywalled", 0)
assessable = readable + paywalled
inacc = (paywalled / assessable) if assessable else None
# `blocked` is deliberately NOT counted as inaccessible: a bot-block isn't a
# reader paywall (it may open fine in a browser), so it can never push a
# source to reject-ready — only readable-vs-paywalled evidence does. Need a
# few clearly-assessable samples before judging confidently.
ENOUGH = 3
if assessable < ENOUGH:
access_verdict = "review" # mostly blocked/unknown — can't confirm; click examples
elif domain_paywalled and inacc >= 0.7:
access_verdict = "reject-ready" # domain rule AND sample agree it's walled
elif domain_paywalled:
access_verdict = "review" # domain says walled but the sample isn't — likely a false positive, look
elif inacc >= 0.7:
access_verdict = "review" # not on the list but mostly walled — candidate for the rule
elif inacc <= 0.3:
access_verdict = "fine"
else:
access_verdict = "review" # mixed
access = {
"checked": len(results),
"readable": readable, "paywalled": paywalled,
"blocked": counts.get("blocked", 0), "unknown": counts.get("unknown", 0),
"examples": [{"url": u, "access": a} for u, a in results][:5],
}
def _avg(key: str) -> float:
return round(sum(r[key] for r in rows) / total, 1) if total else 0.0
@@ -329,12 +407,17 @@ def preview_feed(url: str, sample: int = 25, pr_risk_default: int = 3, client=No
"sampled": total,
"classified": classified,
"accepted": accepted,
"acceptance_rate": round(accepted / total, 2) if total else 0.0,
"non_english": non_english, # held for language (English-only feed for now)
# None (not 0%) when there are no English items to judge — "all held", not "all rejected".
"acceptance_rate": round(accepted / judged, 2) if judged else None,
"avg_cortisol": _avg("cortisol"),
"avg_ragebait": _avg("ragebait"),
"avg_pr_risk": _avg("pr_risk"),
"newest_published": newest,
"recent_7d": recent_7d,
"paywall_rule": domain_paywalled, # instant domain hint
"access": access, # sampled readable/paywalled/blocked/unknown (deep only)
"access_verdict": access_verdict, # fine | review | reject-ready
"topic_mix": dict(Counter(r["topic"] for r in rows if r["topic"])),
"flavor_mix": dict(Counter(r["flavor"] for r in rows if r["flavor"])),
"examples_accepted": [r["title"] for r in rows if r["accepted"]][:5],
+411 -18
View File
@@ -17,6 +17,8 @@ import re
import sqlite3
from pathlib import Path
from . import bloom
_DATA = Path(__file__).parent / "data"
_POOL = json.loads((_DATA / "wordpool.json").read_text()) # curated static answer pool
# Guess dictionaries (same lists the client validates against) — used server-side to
@@ -26,6 +28,9 @@ _DICT = {v: set(json.loads((_DATA / f"words-{v}.json").read_text())) for v in ("
# Daily Word: 5 letters / 6 guesses · Long Word: 6 letters / 7 guesses.
WORD_VARIANTS = {"5": {"length": 5, "guesses": 6}, "6": {"length": 6, "guesses": 7}}
# Memory Match daily sync variants = "<tier>-<format>" (free play stays local).
MATCH_VARIANTS = {f"{t}-{f}" for t in ("gentle", "standard", "expert") for f in ("icons", "colors")}
def _seed(*parts: str) -> int:
return int(hashlib.sha256(":".join(parts).encode()).hexdigest(), 16)
@@ -309,6 +314,20 @@ def adjudicate_word_guess(conn: sqlite3.Connection, date: str, variant: str, gue
_DIRS = [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]
def _neighbour_fill(grid, cells, size: int) -> int:
"""Filled cells in a candidate's footprint+border that AREN'T its own cells —
a crowding measure, so placement can spread words out instead of clumping."""
own = set(cells)
rs = [r for r, _ in cells]
cs = [c for _, c in cells]
cnt = 0
for r in range(max(0, min(rs) - 1), min(size, max(rs) + 2)):
for c in range(max(0, min(cs) - 1), min(size, max(cs) + 2)):
if (r, c) not in own and grid[r][c] is not None:
cnt += 1
return cnt
# Size tiers. The three sizes draw DISJOINT word slices from the day's pool, so
# each is its own fresh puzzle (no repeats across sizes). small+med+large counts
# sum to WS_NEEDED, the minimum unique words a theme must supply.
@@ -504,31 +523,400 @@ def generate_wordsearch_puzzle(conn: sqlite3.Connection, date: str, client=None)
return json.loads(row["payload_json"])
_WS_CROSS_TARGET = 0.5 # aim: about half the placements cross an existing word
def _zone(r: int, c: int, size: int) -> tuple[int, int]:
"""Which quadrant a cell falls in — coarse occupancy used to spread words."""
return (r * 2 // size, c * 2 // size)
def _place_words(words: list[str], size: int, seed: int) -> tuple[list[list[str | None]], list[tuple[str, list[tuple[int, int]]]]]:
"""Core placement (date-seeded, deterministic). Returns the letter grid (None
where unfilled) and [(word, cells)] for every word genuinely placed.
Interlock is a TARGET, not a side effect: each word either (a) must cross an
already-placed word when crossings are running below ~half of placements
or (b) anchors in open ground. Both modes steer toward the least crowded /
least developed quadrant, so crossings attach to lonely words at the edges of
structure rather than thickening one knot, and anchors spread across the
board. All valid spots are enumerated (the grid is tiny) earlier random
sampling kept missing the rare crossing spots, which is why grids came out
as disconnected "clean" words."""
rng = random.Random(seed)
grid: list[list[str | None]] = [[None] * size for _ in range(size)]
zone_fill = {(zr, zc): 0 for zr in (0, 1) for zc in (0, 1)}
placements: list[tuple[str, list[tuple[int, int]]]] = []
crossed = 0
for word in sorted(words, key=len, reverse=True):
n = len(word)
if n > size:
continue
cands = [] # (overlap, cells) over every legal placement
for dr, dc in _DIRS:
for r0 in range(size):
for c0 in range(size):
if not (0 <= r0 + dr * (n - 1) < size and 0 <= c0 + dc * (n - 1) < size):
continue
cells = [(r0 + dr * i, c0 + dc * i) for i in range(n)]
if not all(grid[r][c] in (None, word[i]) for i, (r, c) in enumerate(cells)):
continue
cands.append((sum(1 for i, (r, c) in enumerate(cells) if grid[r][c] == word[i]), cells))
if not cands:
continue
crossing = [t for t in cands if t[0] > 0]
want_cross = bool(crossing) and crossed < _WS_CROSS_TARGET * len(placements)
scored = [] # (score, overlap, cells)
for overlap, cells in crossing if want_cross else cands:
crowd = _neighbour_fill(grid, cells, size)
zload = sum(zone_fill[_zone(r, c, size)] for r, c in cells) // n
# Crossing mode rewards extra overlaps; anchor mode is overlap-neutral
# (crowding already steers it to open ground).
scored.append(((overlap * 4 if want_cross else 0) - 2 * crowd - zload, overlap, cells))
scored.sort(key=lambda t: t[0], reverse=True)
top = [t for t in scored if t[0] >= scored[0][0] - 1] # near-best: variety without losing intent
_, overlap, cells = rng.choice(top)
for i, (r, c) in enumerate(cells):
if grid[r][c] is None:
grid[r][c] = word[i]
zone_fill[_zone(r, c, size)] += 1
placements.append((word, cells))
if overlap:
crossed += 1
return grid, placements
def _build_grid(words: list[str], size: int, seed: int) -> tuple[list[str], list[str]]:
"""Place words in a size×size grid (date-seeded, deterministic) and fill the
rest. Returns (rows, placed_words). Every returned word is genuinely placed."""
rng = random.Random(seed)
grid: list[list[str | None]] = [[None] * size for _ in range(size)]
placed = []
for word in sorted(words, key=len, reverse=True):
if len(word) > size:
continue
for _ in range(400):
dr, dc = rng.choice(_DIRS)
r0, c0 = rng.randrange(size), rng.randrange(size)
cells = [(r0 + dr * i, c0 + dc * i) for i in range(len(word))]
if any(not (0 <= r < size and 0 <= c < size) for r, c in cells):
continue
if all(grid[r][c] in (None, word[i]) for i, (r, c) in enumerate(cells)):
for i, (r, c) in enumerate(cells):
grid[r][c] = word[i]
placed.append(word)
break
grid, placements = _place_words(words, size, seed)
rng = random.Random(_seed(str(seed), "fill"))
for r in range(size):
for c in range(size):
if grid[r][c] is None:
grid[r][c] = chr(65 + rng.randrange(26))
return ["".join(row) for row in grid], placed
return ["".join(row) for row in grid], [w for w, _ in placements]
# --- Cross-device game state sync -------------------------------------------
# Per-puzzle progress is merged SERVER-SIDE on every save, so two devices
# converge regardless of push order. The merge is tailored to each game's nature.
def _merge_wordsearch(a: dict, b: dict) -> dict:
"""Union the found words (a find is monotonic — you can't un-find one, so the
union is always correct), credit the most ACTIVE play time either device has
banked (max the clock only runs while the puzzle is on screen, so wall-clock
gaps between sittings never count), and keep the best (min) finish time."""
by_word = {}
for fw in list(a.get("foundWords") or []) + list(b.get("foundWords") or []):
w = fw.get("word") if isinstance(fw, dict) else None
if w and w not in by_word:
by_word[w] = fw
times = [m for m in (a.get("ms"), b.get("ms")) if m]
return {
"foundWords": list(by_word.values()),
"played": max(_int(a.get("played")), _int(b.get("played"))),
"ms": min(times) if times else 0,
}
def _word_rank(s: dict) -> tuple:
return (1 if s.get("status") in ("won", "lost") else 0, len(s.get("guesses") or []))
def _merge_word(a: dict, b: dict) -> dict:
"""Furthest progress wins: a finished game beats in-progress, more guesses
beats fewer. Picks one device's game WHOLE — never splices guess sequences."""
return a if _word_rank(a) >= _word_rank(b) else b
def _merge_bloom(a: dict, b: dict) -> dict:
"""Union found words — a find is monotonic (you can't un-find one), so the
union across devices is always correct. Score is recomputed by the sanitizer."""
found, seen = [], set()
for w in list(a.get("found") or []) + list(b.get("found") or []):
if isinstance(w, str) and w not in seen:
seen.add(w)
found.append(w)
return {"found": found}
def merge_game_state(game: str, a: dict | None, b: dict | None) -> dict:
if not a:
return dict(b or {})
if not b:
return dict(a or {})
if game == "wordsearch":
return _merge_wordsearch(a, b)
if game == "bloom":
return _merge_bloom(a, b)
if game == "match":
return _merge_match(a, b)
return _merge_word(a, b)
def load_game_state(conn: sqlite3.Connection, user_id: int, game: str, variant: str, date: str) -> dict | None:
row = conn.execute(
"SELECT state_json FROM game_state WHERE user_id=? AND game=? AND variant=? AND puzzle_date=?",
(user_id, game, variant, date),
).fetchone()
if not row:
return None
try:
return json.loads(row["state_json"])
except (ValueError, TypeError):
return None
def _int(x) -> int:
try:
return int(x)
except (TypeError, ValueError):
return 0
_WS_MS_CAP = 86_400_000 # clamp client-sent timings to one day — beyond that is junk
def _ms(x) -> int:
return max(0, min(_int(x), _WS_MS_CAP))
def _sanitize_wordsearch(conn: sqlite3.Connection, variant: str, date: str, state: dict) -> dict:
"""Trust only finds that are real for THIS puzzle: word in the day's list and
cells that actually spell it in the grid (validated when the puzzle exists,
shape-only otherwise). Dedupes, renumbers colours, and derives completion from
the real word count never from a client-sent `ms` alone."""
words: list[str] = []
grid: list[str] = []
if variant in WS_TIERS and conn.execute(
"SELECT 1 FROM daily_puzzles WHERE puzzle_date=? AND game='wordsearch' AND variant=''", (date,)
).fetchone():
try:
p = wordsearch_response(conn, date, variant) # read-only; today's puzzle already exists
words, grid = list(p.get("words") or []), list(p.get("grid") or [])
except Exception: # noqa: BLE001
words, grid = [], []
wset = set(words)
clean: list[dict] = []
seen: set[str] = set()
for fw in (state.get("foundWords") or []):
if not isinstance(fw, dict):
continue
w, cells = fw.get("word"), fw.get("cells")
if not isinstance(w, str) or w in seen or not isinstance(cells, list):
continue
try:
cells = [[int(r), int(c)] for r, c in cells]
except (TypeError, ValueError):
continue
if len(cells) != len(w):
continue
if grid: # validate the find spells the word in the real grid
if w not in wset:
continue
spelled = "".join(grid[r][c] for r, c in cells if 0 <= r < len(grid) and 0 <= c < len(grid[r]))
if spelled != w:
continue
elif not (4 <= len(w) <= 12 and w.isalpha()): # no puzzle to check against → shape only
continue
seen.add(w)
clean.append({"word": w, "cells": cells, "ci": len(clean) % 10})
done = bool(words) and len(clean) == len(words)
return {"foundWords": clean, "played": _ms(state.get("played")),
"ms": _ms(state.get("ms")) if done else 0}
_WORD_COLOURS = {"absent", "present", "correct"}
def _sanitize_word(variant: str, state: dict) -> dict:
"""Validate shapes: status enum, guess count/length, colour rows, terminal fields."""
n = WORD_VARIANTS[variant]["length"]
maxg = WORD_VARIANTS[variant]["guesses"]
status = state.get("status") if state.get("status") in ("playing", "won", "lost") else "playing"
guesses = [g.lower() for g in (state.get("guesses") or [])[:maxg]
if isinstance(g, str) and len(g) == n and g.isalpha()]
cols = []
if isinstance(state.get("cols"), list):
for row in state["cols"][:len(guesses)]:
cols.append([c for c in row if c in _WORD_COLOURS][:n] if isinstance(row, list) else [])
out = {"guesses": guesses, "cols": cols, "status": status}
if status in ("won", "lost"):
ans = state.get("answer")
if isinstance(ans, str) and len(ans) == n and ans.isalpha():
out["answer"] = ans.lower()
if isinstance(state.get("why"), str):
out["why"] = state["why"][:600]
return out
def _sanitize_bloom(conn: sqlite3.Connection, date: str, state: dict) -> dict:
"""Trust only finds real for THIS wheel — a word in the day's DYNAMIC accept
set (broad dict + overrides, computed live; shape-only if the puzzle doesn't
exist yet). Dedupes and recomputes score server-side; Full Bloom = reaching the
designed puzzle's total (max_score). Never trusts a client-sent score/full."""
payload = bloom.stored_payload(conn, date)
valid = (set(bloom.accepted_words(conn, payload["center"], payload["outer"], True))
if payload else None)
clean, seen = [], set()
for w in (state.get("found") or []):
if not isinstance(w, str):
continue
w = w.strip().lower()
if not w or w in seen:
continue
if valid is not None:
if w not in valid:
continue
elif not (len(w) >= 4 and w.isalpha() and "s" not in w): # no puzzle yet → shape only
continue
seen.add(w)
clean.append(w)
clean.sort()
score = bloom.score_words(payload, clean) if payload else 0
out = {"found": clean, "score": score}
if payload and clean and score >= payload.get("max_score", 1):
out["full"] = True # Full Bloom — found the whole designed puzzle
return out
_MATCH_MAX_FACES = 12 # the largest board uses 8 faces; cap generously
_MATCH_FACES = {"gentle": 6, "standard": 8, "expert": 8} # faces per tier = completion target
# Valid face keys — MIRRORS the frontend (icons.js ICON_KEYS + palette.js COLOR_KEYS).
# Matched keys are validated against this so bogus/junk keys can't inflate the
# completion count. Adding a face on the frontend? Add it here too; a missing key only
# under-counts (benign, self-heals once synced), never crashes.
_MATCH_FACE_KEYS = frozenset({
"sun", "moon", "star", "cloud", "raindrop", "wave", "leaf", "flower", "seedling",
"tree", "mountain", "shell", "feather", "acorn", "butterfly", "rainbow", "heart",
"sparkle", "home", "book", "teacup", "candle", "lantern", "compass", "kite", "note",
"boat", "fish", "bird", "mushroom", "bell", "snowflake", "clover",
"color-rose", "color-coral", "color-amber", "color-gold", "color-lime", "color-green",
"color-teal", "color-cyan", "color-sky", "color-blue", "color-indigo", "color-violet",
"color-plum", "color-brown", "color-sand", "color-slate", "color-charcoal", "color-cream",
})
def _match_faces(variant: str) -> int:
return _MATCH_FACES.get((variant or "").split("-", 1)[0], 8)
def _sanitize_match(variant: str, state: dict) -> dict:
"""Light, durability-only sanitize. Memory Match has nothing to cheat — the
board is deterministic and fully visible, with no score/leaderboard so we
just drop malformed junk: matched FACE KEYS (icon name / color key, never raw
indices, so progress survives layout tweaks), validated against the real face set
(junk can't count), deduped, with a clamped move count. `done` is DERIVED from the
matched count vs the tier's face target — never trusted from the client, so a
stale/bogus flag can't mark a board cleared (matters once the ritual reads it)."""
seen: set[str] = set()
matched: list[str] = []
for k in (state.get("matched") or []):
if isinstance(k, str) and k in _MATCH_FACE_KEYS and k not in seen:
seen.add(k)
matched.append(k)
if len(matched) >= _MATCH_MAX_FACES:
break
return {"matched": matched, "moves": max(0, min(_int(state.get("moves")), 100_000)),
"done": len(matched) >= _match_faces(variant)}
def _merge_match(a: dict, b: dict) -> dict:
"""Union matched faces across devices, keep the larger move count. `done` is not
carried here the post-merge sanitize re-derives it from the matched count."""
matched = list(dict.fromkeys([*(a.get("matched") or []), *(b.get("matched") or [])]))[:_MATCH_MAX_FACES]
return {"matched": matched, "moves": max(_int(a.get("moves")), _int(b.get("moves")))}
def sanitize_game_state(conn: sqlite3.Connection, game: str, variant: str, date: str, state: dict) -> dict:
"""Never trust client JSON at the storage layer — normalize before merge/store."""
if game == "wordsearch":
return _sanitize_wordsearch(conn, variant, date, state or {})
if game == "bloom":
return _sanitize_bloom(conn, date, state or {})
if game == "match":
return _sanitize_match(variant, state or {})
return _sanitize_word(variant, state or {})
def save_game_state(conn: sqlite3.Connection, user_id: int, game: str, variant: str,
date: str, incoming: dict) -> dict:
"""Sanitize → merge with the stored state (server-authoritative) → sanitize → persist."""
clean_in = sanitize_game_state(conn, game, variant, date, incoming or {})
stored = load_game_state(conn, user_id, game, variant, date)
merged = sanitize_game_state(conn, game, variant, date, merge_game_state(game, stored, clean_in))
conn.execute(
"INSERT INTO game_state (user_id, game, variant, puzzle_date, state_json, updated_at) "
"VALUES (?,?,?,?,?,CURRENT_TIMESTAMP) "
"ON CONFLICT(user_id, game, variant, puzzle_date) DO UPDATE SET "
"state_json=excluded.state_json, updated_at=CURRENT_TIMESTAMP",
(user_id, game, variant, date, json.dumps(merged)),
)
conn.commit()
return merged
def game_stats(conn: sqlite3.Connection, user_id: int, game: str, variant: str) -> dict:
"""Derive the player's record for a variant from their synced states, so
streak / distribution / best are consistent across devices (not per-device counters)."""
rows = conn.execute(
"SELECT puzzle_date, state_json FROM game_state "
"WHERE user_id=? AND game=? AND variant=? ORDER BY puzzle_date DESC",
(user_id, game, variant),
).fetchall()
states = []
for r in rows:
try:
states.append(json.loads(r["state_json"]))
except (ValueError, TypeError):
pass
if game == "wordsearch":
times = [s.get("ms") for s in states if s.get("ms")]
return {"completed": sum(1 for s in states if s.get("ms")), "best": min(times) if times else 0}
if game == "bloom":
# Calm, no-pressure record: days played, lifetime words, Full Blooms, and
# the best tier ever reached (computed per day from that wheel's tiers).
tier_names = [t[0] for t in bloom.TIER_PCTS]
played = words = full = 0
best_idx = -1
for r in rows:
try:
s = json.loads(r["state_json"])
except (ValueError, TypeError):
continue
found = s.get("found") or []
if not found:
continue
played += 1
words += len(found)
if s.get("full"):
full += 1
p = bloom.stored_payload(conn, r["puzzle_date"])
if p:
sc = s.get("score") or 0
idx = max((i for i, t in enumerate(p["tiers"]) if sc >= t["score"]), default=0)
best_idx = max(best_idx, idx)
return {"played": played, "words": words, "full_blooms": full,
"best_tier": tier_names[best_idx] if best_idx >= 0 else None}
played = won = 0
dist: dict[int, int] = {}
streak = 0
streak_open = True # consecutive wins counting back from newest (matches the old local counter)
for s in states:
st = s.get("status")
if st in ("won", "lost"):
played += 1
if st == "won":
won += 1
g = len(s.get("guesses") or [])
dist[g] = dist.get(g, 0) + 1
if streak_open:
streak += 1
elif st == "lost":
streak_open = False
# 'playing' (e.g. today unfinished) neither counts nor breaks the streak
return {"played": played, "won": won, "streak": streak, "dist": dist}
def wordsearch_response(conn: sqlite3.Connection, date: str, size: str = "med") -> dict:
@@ -564,4 +952,9 @@ def generate_daily_puzzles(conn: sqlite3.Connection, date: str, client=None) ->
).fetchone():
generate_wordsearch_puzzle(conn, date, client=client)
made += 1
if not conn.execute(
"SELECT 1 FROM daily_puzzles WHERE puzzle_date=? AND game='bloom' AND variant=''", (date,)
).fetchone():
bloom.generate_bloom_puzzle(conn, date) # pure code, no LLM
made += 1
return made
+93 -6
View File
@@ -49,6 +49,7 @@ CLASSIFICATION_SCHEMA = {
"tags",
"reason_code",
"reason_text",
"language",
],
"properties": {
"constructive_score": _SCORE_FIELD,
@@ -64,6 +65,7 @@ CLASSIFICATION_SCHEMA = {
"tags": {"type": "array", "items": {"type": "string", "enum": list(ALLOWED_TAGS)}, "maxItems": MAX_TAGS},
"reason_code": {"type": "string"},
"reason_text": {"type": "string"},
"language": {"type": "string"}, # ISO 639-1 of the article's own text (en, de, es…)
},
}
@@ -104,6 +106,11 @@ Grouping tags — choose ONLY from this controlled vocabulary:
Tag discipline: assign 1-4 tags; prefer fewer, stronger ones; never tag by weak
association; pick tags a reader would reasonably use to find this story later.
Also report `language`: the ISO 639-1 code of the article's OWN text (the title and
description), e.g. "en", "de", "es", "fr". Judge the language of the words, not the
subject. This is detection only score and accept the story on its merits as usual;
the site decides separately what to do with non-English items.
Return only JSON with this exact shape:
{{
"constructive_score": 0,
@@ -118,7 +125,8 @@ Return only JSON with this exact shape:
"flavor": "one_of_the_allowed_flavors",
"tags": ["one_to_four_allowed_tags"],
"reason_code": "short_snake_case",
"reason_text": "one concise sentence"
"reason_text": "one concise sentence",
"language": "en"
}}
""".format(topics=topics_prompt_block(), flavors=flavors_prompt_block(), tags=tags_prompt_block())
@@ -222,6 +230,60 @@ class LocalModelClient:
"""
return self._raw_content(self._build_payload(messages, None))
def rank_for_social(self, candidates: list[dict]) -> list[dict]:
"""ONE bounded COMPARATIVE pass over a small candidate set (not N calls).
Returns a best-first list of {id, social_score 0-10, why, talking_points,
angle, entities}. Bounded by self.timeout; callers fall back to deterministic
ranking on ANY failure, so the Publishing Desk always works."""
if not candidates:
return []
lines = []
for c in candidates:
summ = " ".join((c.get("summary") or "").split())[:280]
lines.append(f'- id={int(c["id"])} | topic={c.get("topic")} | {c["title"]} :: {summ}')
user = (
"These are constructive-news articles. Compare them as candidates for a SHORT X "
"(Twitter) post from a calm good-news account, and rank best-first by SOCIAL "
"share-worthiness — would someone stop scrolling? That differs from how 'good' the "
"article is.\n\n" + "\n".join(lines) + "\n\n"
'Reply with JSON only, exactly this shape:\n'
'{"ranked": [{"id": <one of the ids above>, "social_score": <0-10>, '
'"why": "one sentence: why it stops the scroll", '
'"talking_points": ["3 short factual points a writer could use"], '
'"angle": "a possible conversational angle", '
'"entities": ["real org/person names mentioned, for tagging"]}]}\n'
"Only use ids from the list above. Order best-first."
)
messages = [
{"role": "system", "content": "You rank constructive news for social sharing. Reply with JSON only."},
{"role": "user", "content": user},
]
data = parse_classifier_json(self.chat_text(messages))
ranked = data.get("ranked") if isinstance(data, dict) else None
if not isinstance(ranked, list):
raise RuntimeError("rank_for_social: missing 'ranked' list")
out = []
for r in ranked:
if not isinstance(r, dict):
continue
try:
rid = int(r.get("id"))
except (TypeError, ValueError):
continue
# Require ACTUAL lists — a model that returns a bare string must not be
# iterated into characters ("fact" → ["f","a","c","t"]).
tp = r.get("talking_points")
ents = r.get("entities")
out.append({
"id": rid,
"social_score": _bounded_int(r.get("social_score")),
"why": str(r.get("why") or "")[:300],
"talking_points": [str(p)[:200] for p in tp][:4] if isinstance(tp, list) else [],
"angle": str(r.get("angle") or "")[:300],
"entities": [str(e)[:80] for e in ents][:8] if isinstance(ents, list) else [],
})
return out
def _raw_content(self, payload: dict) -> str:
body = json.dumps(payload).encode("utf-8")
headers = {"Content-Type": "application/json"}
@@ -304,7 +366,29 @@ def parse_classifier_json(content: str) -> dict:
return json.loads(content[start : end + 1])
def _is_english(language: str) -> bool:
"""Conservative: HOLD only when the model clearly reports a non-English language.
Missing/blank/undetermined treated as English, so a model hiccup never silently
drops genuine English content (the corpus is ~all English today)."""
lang = (language or "").strip().lower()
if not lang or lang in ("und", "unknown", "mul", "zxx"):
return True
return lang == "en" or lang.startswith("en-") or lang.startswith("en_")
def normalize_scores(data: dict, model_name: str) -> dict:
language = str(data.get("language") or "").strip().lower()[:16]
accepted = 1 if bool(data.get("accepted")) else 0
reason_code = str(data.get("reason_code") or "model_no_reason")[:120]
reason_text = str(data.get("reason_text") or "")[:1000]
# Language gate (code disposes): the public feed is English-only for now. A
# non-English article is HELD — never shown — but PRESERVED with a distinct
# reason so it isn't counted as a calm-filter rejection or a source failure, and
# can be revisited when translation support lands (Phase 4 / GDELT).
if not _is_english(language):
accepted = 0
reason_code = "non_english"
reason_text = f"Held — non-English ({language}); awaiting translation support."
return {
"constructive_score": _bounded_int(data.get("constructive_score")),
"cortisol_score": _bounded_int(data.get("cortisol_score")),
@@ -313,12 +397,13 @@ def normalize_scores(data: dict, model_name: str) -> dict:
"human_benefit_score": _bounded_int(data.get("human_benefit_score")),
"novelty_score": _bounded_int(data.get("novelty_score")),
"pr_risk_score": _bounded_int(data.get("pr_risk_score")),
"accepted": 1 if bool(data.get("accepted")) else 0,
"accepted": accepted,
"topic": coerce_topic(data.get("topic")),
"flavor": coerce_flavor(data.get("flavor")),
"tags": coerce_tags(data.get("tags")),
"reason_code": str(data.get("reason_code") or "model_no_reason")[:120],
"reason_text": str(data.get("reason_text") or "")[:1000],
"reason_code": reason_code,
"reason_text": reason_text,
"language": language,
"model_name": model_name,
}
@@ -329,9 +414,9 @@ def upsert_article_score(conn: sqlite3.Connection, article_id: int, scores: dict
INSERT INTO article_scores (
article_id, constructive_score, cortisol_score, ragebait_score,
agency_score, human_benefit_score, novelty_score, pr_risk_score,
accepted, topic, flavor, reason_code, reason_text, model_name, scored_at
accepted, topic, flavor, reason_code, reason_text, language, model_name, scored_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(article_id) DO UPDATE SET
constructive_score = excluded.constructive_score,
cortisol_score = excluded.cortisol_score,
@@ -345,6 +430,7 @@ def upsert_article_score(conn: sqlite3.Connection, article_id: int, scores: dict
flavor = excluded.flavor,
reason_code = excluded.reason_code,
reason_text = excluded.reason_text,
language = excluded.language,
model_name = excluded.model_name,
scored_at = CURRENT_TIMESTAMP
""",
@@ -362,6 +448,7 @@ def upsert_article_score(conn: sqlite3.Connection, article_id: int, scores: dict
scores["flavor"],
scores["reason_code"],
scores["reason_text"],
scores.get("language"),
scores["model_name"],
),
)
+66
View File
@@ -8,6 +8,7 @@ and for replacements. It will never be perfect; it's an honest hint, not a gate.
from __future__ import annotations
import re
from urllib.parse import urlsplit
# Host suffixes considered paywalled. Subdomains match (news.nature.com → nature.com).
@@ -35,7 +36,72 @@ PAYWALL_DOMAINS = {
def is_paywalled(url: str | None) -> bool:
"""Low-level DOMAIN rule. Keep this distinct from the source-aware decision so
callers can tell 'domain says paywalled' from 'this source is overridden'."""
host = urlsplit(url or "").netloc.lower()
if host.startswith("www."):
host = host[4:]
return any(host == d or host.endswith("." + d) for d in PAYWALL_DOMAINS)
def is_paywalled_for_source(url: str | None, override: str | None = None) -> bool:
"""The EFFECTIVE paywall decision used for ranking/lead/badges: a per-source
override (set in admin after inspecting the articles) wins over the domain
rule 'free' clears a false positive (e.g. NY Times Learning), 'paywalled'
flags a false negative. NULL falls back to the domain rule."""
if override == "free":
return False
if override == "paywalled":
return True
return is_paywalled(url)
# --- Content-level accessibility (deep-preview only; the live pipeline still never
# fetches article pages) -----------------------------------------------------
# Wall phrases that appear in the rendered, walled state. Kept specific so a footer
# "subscribe to our newsletter" doesn't read as a paywall.
_WALL_MARKERS = (
"subscribe to continue", "subscribe to keep reading", "subscribe to read",
"to continue reading", "already a subscriber", "subscribers only",
"this article is for subscribers", "this content is for subscribers",
"create a free account to continue", "create an account to keep reading",
"unlock this article", "register to continue reading",
)
_ACCESS_FALSE = re.compile(r'"isaccessibleforfree"\s*:\s*("?)(false)\1', re.I)
_ACCESS_TRUE = re.compile(r'"isaccessibleforfree"\s*:\s*("?)(true)\1', re.I)
_CONTENT_LOCKED = re.compile(r'content[_-]tier"[^>]*content="locked', re.I)
_STRIP_BLOCKS = re.compile(r"(?is)<(script|style|noscript|template)[^>]*>.*?</\1>")
_STRIP_TAGS = re.compile(r"(?s)<[^>]+>")
_WS = re.compile(r"\s+")
def check_article_access(url: str, fetcher, timeout: int = 8) -> str:
"""Best-effort readability of ONE article URL, for the deep-preview accessibility
sample. Returns 'readable' | 'paywalled' | 'blocked' | 'unknown'.
Conservative + evidence-led: an explicit signal (schema.org isAccessibleForFree,
content-tier=locked, or a clear wall phrase) marks 'paywalled'; otherwise a page
with substantial body text reads as 'readable'; thin/ambiguous pages stay
'unknown'. A fetch error is 'blocked'. Heuristic by nature it informs the
verdict, it never auto-rejects (domain rules already proved they can lie)."""
try:
raw = fetcher(url, timeout=timeout)
except Exception: # noqa: BLE001 — any fetch failure = can't read it right now
return "blocked"
try:
html = raw.decode("utf-8", "ignore")
except Exception: # noqa: BLE001
return "unknown"
if _ACCESS_FALSE.search(html) or _CONTENT_LOCKED.search(html):
return "paywalled"
low = html.lower()
if any(m in low for m in _WALL_MARKERS):
return "paywalled"
# No wall signal — judge by how much real article text is present.
text = _WS.sub(" ", _STRIP_TAGS.sub(" ", _STRIP_BLOCKS.sub(" ", html))).strip()
if _ACCESS_TRUE.search(html) and len(text) >= 600:
return "readable"
if len(text) >= 1500:
return "readable"
return "unknown"
+400
View File
@@ -0,0 +1,400 @@
"""Publishing Desk — the platform-neutral outbound-share queue (X first).
Pattern (Claude + Codex): code reduces the corpus to a small set of strong,
*eligible* candidates; ONE bounded comparative LLM call ranks them together and
returns talking points / angle / entities; code validates, applies diversity, and
tops the queue up to a target. If the model is down or returns junk, a deterministic
ranking is the fallback the Desk always works.
The human writes every blurb; the LLM never writes the post and never invents a
@handle (handles come only from the verified `entity_handles` table or a source's
own `x_handle`).
"""
from __future__ import annotations
import json
import re
import sqlite3
from datetime import datetime, timezone
from .paywall import is_paywalled_for_source
PLATFORM_X = "x"
QUEUE_TARGET = 8 # how many active items the Desk tries to keep ready
_LLM_POOL = 15 # most candidates handed to the one comparative LLM call
_RECENT = "-3 days" # "timely" window for share candidates
# Active = occupying a slot in the working queue (so we don't re-add or duplicate).
_ACTIVE = ("queued", "drafting", "opened")
# Legal suffixes are dropped ("Apple Inc" ≡ "Apple") but ONLY from the END, and "the"
# is NEVER dropped. Removing them anywhere collapsed "The Who"→"who" (collides with
# WHO) and "Inc. Magazine"→"magazine". Identity words (university, institute, lab…) are
# preserved; short forms/abbreviations need explicit alias rows.
_LEGAL_SUFFIXES = {"inc", "llc", "ltd", "corp", "corporation", "plc", "gmbh", "co"}
def normalize_entity(name: str) -> str:
toks = re.sub(r"[^a-z0-9 ]", " ", (name or "").lower()).split()
while toks and toks[-1] in _LEGAL_SUFFIXES: # trailing only
toks.pop()
return " ".join(toks)
_HANDLE_RE = re.compile(r"^[A-Za-z0-9_]{1,15}$") # X: 1-15 chars, letters/digits/underscore
def valid_handle(handle: str | None) -> str | None:
"""Canonical handle WITHOUT the @, or None. Tolerates one optional leading @;
rejects empty, spaces, URLs, and punctuation so '@', '@not a handle',
'@https://x.com/NASA', '@NASA!' never get stored or suggested."""
h = (handle or "").strip()
if h.startswith("@"):
h = h[1:]
return h if _HANDLE_RE.match(h) else None
# --- verified handle resolution -------------------------------------------------
def resolve_handles(conn: sqlite3.Connection, entities: list[str], source_handle: str | None = None,
platform: str = PLATFORM_X, cap: int = 2) -> list[dict]:
"""Verified handles ONLY: the source's own handle first, then LLM-named entities
matched against the curated table. Deduped, capped. Unmatched entities are NOT
guessed the UI offers a 'Find on X' search for those instead."""
out: list[dict] = []
seen: set[str] = set()
def add(handle: str | None, profile_url: str | None, via: str) -> None:
canon = valid_handle(handle) # validate even verified/source handles before display
if not canon:
return
key = canon.lower()
if key in seen:
return
seen.add(key)
out.append({"handle": "@" + canon, "profile_url": profile_url or f"https://x.com/{canon}", "via": via})
if source_handle:
add(source_handle, None, "source")
for name in entities or []:
if len(out) >= cap:
break
norm = normalize_entity(name)
if not norm:
continue
row = conn.execute(
"SELECT handle, profile_url FROM entity_handles WHERE normalized_name=? AND platform=?",
(norm, platform),
).fetchone()
if row:
add(row["handle"], row["profile_url"], "entity")
return out[:cap]
def add_entity_handle(conn: sqlite3.Connection, entity_name: str, handle: str,
profile_url: str | None = None, platform: str = PLATFORM_X) -> bool:
"""Save a verified handle (e.g. after you confirm one via 'Find on X'), so it's
automatic next time. Idempotent on (normalized_name, platform)."""
norm = normalize_entity(entity_name)
canon = valid_handle(handle)
if not norm or not canon: # reject junk handles before they're ever stored
return False
conn.execute(
"""INSERT INTO entity_handles (entity_name, normalized_name, platform, handle, profile_url)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(normalized_name, platform) DO UPDATE SET
handle=excluded.handle, profile_url=excluded.profile_url,
entity_name=excluded.entity_name, verified_at=CURRENT_TIMESTAMP""",
(entity_name.strip(), norm, platform, canon, profile_url or f"https://x.com/{canon}"),
)
conn.commit()
return True
# --- candidate eligibility + ranking --------------------------------------------
def eligible_candidates(conn: sqlite3.Connection, platform: str = PLATFORM_X, limit: int = _LLM_POOL) -> list[dict]:
"""Hard filters (code disposes): accepted · visible · non-duplicate · timely ·
complete share page · not already queued/posted/skipped/snoozed. Readable
(paywall) is checked in Python. Returns the deterministically pre-ranked top
`limit` to hand to the comparative LLM call."""
rows = conn.execute(
f"""
SELECT a.id, a.title, a.canonical_url, a.image_url, a.published_at, a.discovered_at,
a.source_id, src.name AS source_name, src.x_handle AS source_handle,
src.default_category AS category, src.paywall_override,
s.constructive_score, s.novelty_score, s.topic,
m.summary, m.what_happened, m.why_matters, m.why_belongs
FROM articles a
JOIN article_scores s ON s.article_id = a.id
JOIN sources src ON src.id = a.source_id
JOIN article_summaries m ON m.article_id = a.id
WHERE s.accepted = 1
AND a.duplicate_of IS NULL
AND src.content_visible = 1
AND m.summary IS NOT NULL AND m.what_happened IS NOT NULL
AND m.why_matters IS NOT NULL AND m.why_belongs IS NOT NULL
AND COALESCE(a.published_at, a.discovered_at) >= datetime('now', ?)
AND a.id NOT IN (
SELECT article_id FROM outbound_shares WHERE platform = ? AND (
status IN ('queued','drafting','opened','posted','skipped')
OR (status = 'snoozed' AND (snooze_until IS NULL OR snooze_until > datetime('now')))
)
)
ORDER BY COALESCE(a.published_at, a.discovered_at) DESC
LIMIT 200
""",
(_RECENT, platform),
).fetchall()
cands = [dict(r) for r in rows
if not is_paywalled_for_source(r["canonical_url"], r["paywall_override"])]
cands.sort(key=_det_score, reverse=True)
return cands[:limit]
def _det_score(c: dict) -> float:
"""Deterministic shareability score — the pre-rank and the LLM-failure fallback.
'Good article' and 'good post' differ, so this favors novelty + a usable image
+ freshness, not just the constructive score."""
score = 1.5 * (c.get("novelty_score") or 0) + 1.0 * (c.get("constructive_score") or 0)
if c.get("image_url"):
score += 2.0
return score
def _diverse_pick(cands: list[dict], need: int, per_source: int = 1, per_topic: int = 2) -> list[dict]:
"""Pick `need` items spreading across sources/topics (cands already ranked)."""
out, src_n, top_n = [], {}, {}
for c in cands:
if len(out) >= need:
break
sid, top = c.get("source_id"), c.get("topic")
if src_n.get(sid, 0) >= per_source or (top and top_n.get(top, 0) >= per_topic):
continue
out.append(c)
src_n[sid] = src_n.get(sid, 0) + 1
if top:
top_n[top] = top_n.get(top, 0) + 1
# If diversity caps left us short (small pool), fill from the remainder in rank order.
if len(out) < need:
chosen = {c["id"] for c in out}
out.extend(c for c in cands if c["id"] not in chosen)
return out[:need]
# --- queue build (background job) -----------------------------------------------
def _share_url(base_url: str, article_id: int, platform: str = PLATFORM_X) -> str:
base = (base_url or "").rstrip("/")
return f"{base}/a/{article_id}?utm_source={platform}&utm_medium=social&utm_campaign=publishing_desk"
def build_queue(conn: sqlite3.Connection, base_url: str, client=None,
platform: str = PLATFORM_X, target: int = QUEUE_TARGET) -> dict:
"""Top the active queue up to `target`. Comparative LLM ranks the eligible pool;
deterministic fallback if the model is unavailable or returns junk. Never
overwrites saved draft/final text on a re-queue."""
active = conn.execute(
"SELECT COUNT(*) FROM outbound_shares WHERE platform=? AND status IN (?,?,?)",
(platform, *_ACTIVE),
).fetchone()[0]
need = target - active
if need <= 0:
return {"added": 0, "active": active, "ranked_by": "none"}
cands = eligible_candidates(conn, platform=platform, limit=_LLM_POOL)
if not cands:
return {"added": 0, "active": active, "ranked_by": "none"}
by_id = {c["id"]: c for c in cands}
ranked_by = "deterministic"
llm = None
if client is not None:
try:
llm = client.rank_for_social(
[{"id": c["id"], "title": c["title"], "summary": c.get("summary") or "",
"topic": c.get("topic")} for c in cands]
)
except Exception: # noqa: BLE001 — model down/slow/garbage → deterministic fallback
llm = None
if llm:
# validate ids against the eligible pool AND dedupe (a model that repeats an id
# must not inflate the chosen set); attach LLM fields; rank by social score.
seen_ids, ordered = set(), []
for r in llm:
rid = r.get("id")
if rid in by_id and rid not in seen_ids:
seen_ids.add(rid)
by_id[rid]["_llm"] = r
ordered.append(by_id[rid])
if ordered:
ranked_by = "llm"
ordered.sort(key=lambda c: c["_llm"].get("social_score", 0), reverse=True)
rest = sorted((c for c in cands if "_llm" not in c), key=_det_score, reverse=True)
cands = ordered + rest
chosen = _diverse_pick(cands, need)
before = conn.total_changes
for c in chosen:
m = c.get("_llm")
if m:
social, angle = m.get("social_score"), m.get("angle")
rationale = m.get("why") or m.get("rationale")
points = m.get("talking_points") if isinstance(m.get("talking_points"), list) else []
entities = m.get("entities") if isinstance(m.get("entities"), list) else []
else:
# Deterministic fallback (model down): seed the writing aids from the
# already-generated summary/explanation so the card is still useful.
# interest score + angle stay None on purpose — they're LLM-only judgments
# the UI hides when absent; we don't manufacture a fake angle/score.
social, angle, entities = None, None, []
rationale = c.get("summary")
points = [p for p in (c.get("what_happened"), c.get("why_matters"), c.get("why_belongs")) if p]
handles = resolve_handles(conn, entities, c.get("source_handle"), platform=platform)
# ON CONFLICT re-queues ONLY an (expired) snoozed row — eligibility already
# excludes active/posted/skipped, and the WHERE guard makes that defense-in-depth
# so a re-build can never clobber an active draft or a terminal status. draft_text
# / final_text are never in the SET, so saved work survives a re-queue.
conn.execute(
"""INSERT INTO outbound_shares
(article_id, platform, status, social_score, rationale, talking_points,
angle, entities, suggested_handles, share_url)
VALUES (?, ?, 'queued', ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(article_id, platform) DO UPDATE SET
status='queued', social_score=excluded.social_score,
rationale=excluded.rationale, talking_points=excluded.talking_points,
angle=excluded.angle, entities=excluded.entities,
suggested_handles=excluded.suggested_handles, share_url=excluded.share_url,
snooze_until=NULL, updated_at=CURRENT_TIMESTAMP
WHERE outbound_shares.status = 'snoozed'
AND outbound_shares.snooze_until IS NOT NULL
AND outbound_shares.snooze_until <= datetime('now')""",
(c["id"], platform, social, rationale,
json.dumps(points), angle,
json.dumps(entities), json.dumps(handles), _share_url(base_url, c["id"], platform)),
)
conn.commit()
# Counts come from ACTUAL persisted rows, not loop iterations (a skipped conflict
# changes nothing, so it can't falsely report a fuller queue).
added = conn.total_changes - before
active_now = conn.execute(
"SELECT COUNT(*) FROM outbound_shares WHERE platform=? AND status IN (?,?,?)",
(platform, *_ACTIVE),
).fetchone()[0]
return {"added": added, "active": active_now, "ranked_by": ranked_by}
# --- queue read + status transitions --------------------------------------------
def _row_to_item(r: sqlite3.Row) -> dict:
d = dict(r)
for k in ("talking_points", "entities", "suggested_handles"):
try:
d[k] = json.loads(d[k]) if d.get(k) else []
except (ValueError, TypeError):
d[k] = []
return d
def list_queue(conn: sqlite3.Connection, platform: str = PLATFORM_X, include_archived: bool = False) -> list[dict]:
"""The working queue (queued/drafting/opened), newest-interest first. With
include_archived, also returns skipped/snoozed (the recoverable tray). Posted is
NEVER returned here it's done, and including it would grow the payload forever
(a dedicated paginated history can come later if wanted)."""
statuses = list(_ACTIVE) + (["skipped", "snoozed"] if include_archived else [])
qs = ",".join("?" for _ in statuses)
rows = conn.execute(
f"""
SELECT o.id, o.article_id, o.platform, o.status, o.social_score, o.rationale,
o.talking_points, o.angle, o.entities, o.suggested_handles, o.draft_text,
o.final_text, o.share_url, o.post_url, o.snooze_until, o.opened_at, o.posted_at,
a.title, a.canonical_url, a.image_url, src.name AS source_name
FROM outbound_shares o
JOIN articles a ON a.id = o.article_id
JOIN sources src ON src.id = a.source_id
WHERE o.platform = ? AND o.status IN ({qs})
ORDER BY CASE o.status WHEN 'opened' THEN 0 WHEN 'drafting' THEN 1 ELSE 2 END,
o.social_score DESC, o.created_at DESC
""",
(platform, *statuses),
).fetchall()
return [_row_to_item(r) for r in rows]
_ACTIVE_SET = {"queued", "drafting", "opened"}
_VALID_STATUS = {"queued", "drafting", "opened", "posted", "skipped", "snoozed"}
def _is_future(ts: str | None) -> bool:
if not ts:
return False
try:
dt = datetime.fromisoformat(str(ts).strip().replace("Z", "").replace("T", " "))
except (ValueError, TypeError):
return False
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt > datetime.now(timezone.utc)
def set_status(conn: sqlite3.Connection, share_id: int, status: str, *,
draft_text: str | None = None, final_text: str | None = None,
post_url: str | None = None, snooze_until: str | None = None) -> bool:
"""Transition an ACTIVE share. Enforces the lifecycle: only queued/drafting/opened
items transition here `posted` is permanently terminal and skipped/snoozed recover
via restore() (so dedup can't be undone and an item can't be reposted). `snoozed`
requires a valid FUTURE timestamp (a null/past date would exclude it forever);
leaving snooze otherwise clears snooze_until. opened/posted stamp their times."""
if status not in _VALID_STATUS:
return False
if status == "snoozed" and not _is_future(snooze_until):
return False
row = conn.execute("SELECT status FROM outbound_shares WHERE id = ?", (share_id,)).fetchone()
if not row or row["status"] not in _ACTIVE_SET: # terminal/archived → use restore()
return False
# snooze_until is set only when snoozing; cleared on every other transition.
sets = ["status = ?", "updated_at = CURRENT_TIMESTAMP", "snooze_until = ?"]
params: list = [status, snooze_until if status == "snoozed" else None]
if status == "opened":
sets.append("opened_at = CURRENT_TIMESTAMP")
if status == "posted":
sets.append("posted_at = CURRENT_TIMESTAMP")
if draft_text is not None:
sets.append("draft_text = ?")
params.append(draft_text)
if final_text is not None:
sets.append("final_text = ?")
params.append(final_text)
if post_url is not None:
sets.append("post_url = ?")
params.append(post_url)
params.append(share_id)
cur = conn.execute(
f"UPDATE outbound_shares SET {', '.join(sets)} WHERE id = ? "
"AND status IN ('queued','drafting','opened')", # atomic: don't transition a row that just changed
params,
)
conn.commit()
return cur.rowcount > 0
def save_draft(conn: sqlite3.Connection, share_id: int, draft_text: str) -> bool:
# Only ACTIVE rows accept a draft — a late debounced autosave that lands after
# Posted/Skip/Snooze must be a no-op (never write to a terminal/archived row).
cur = conn.execute(
"UPDATE outbound_shares SET draft_text = ?, status = CASE status WHEN 'queued' THEN 'drafting' ELSE status END, "
"updated_at = CURRENT_TIMESTAMP WHERE id = ? AND status IN ('queued','drafting','opened')",
(draft_text, share_id),
)
conn.commit()
return cur.rowcount > 0
def restore(conn: sqlite3.Connection, share_id: int) -> bool:
"""Bring a skipped/snoozed item back to the working queue (mistaken-click safety)."""
cur = conn.execute(
"UPDATE outbound_shares SET status='queued', snooze_until=NULL, updated_at=CURRENT_TIMESTAMP "
"WHERE id = ? AND status IN ('skipped','snoozed')",
(share_id,),
)
conn.commit()
return cur.rowcount > 0
+154 -11
View File
@@ -11,7 +11,8 @@ import sqlite3
from datetime import UTC, datetime, timedelta
from .feeds import MAX_BACKOFF_MINUTES
from .paywall import is_paywalled
from .localtime import local_now
from .paywall import is_paywalled, is_paywalled_for_source
# UA substrings that mark automated clients. Crawlers run JS on a throttled
# budget and trip the boot-failure beacon routinely — without this filter they
@@ -53,6 +54,7 @@ _ARTICLE_COLUMNS = f"""
s.reason_code,
s.reason_text,
s.model_name,
src.paywall_override AS paywall_override,
(SELECT group_concat(t.tag) FROM article_tags t WHERE t.article_id = a.id) AS tags,
{RANK_SCORE_SQL} AS rank_score
"""
@@ -77,6 +79,7 @@ def feed(
follow_sources: list[int] | None = None,
follow_tags: list[str] | None = None,
since: str | None = None,
match: str | None = None,
) -> list[dict]:
"""Return articles with categorical filters applied in SQL.
@@ -91,6 +94,14 @@ def feed(
"""
clauses = ["a.duplicate_of IS NULL", "src.content_visible = 1"]
params: list = []
# Full-text search: join the FTS index and MATCH first, so its bound param
# leads and relevance can drive the ordering. All the boundary clauses below
# still apply, so search mirrors exactly what the visitor feed would show.
fts_join = ""
if match:
fts_join = "JOIN article_search ON article_search.article_id = a.id"
clauses.append("article_search MATCH ?")
params.append(match)
if accepted_only:
clauses.append("s.accepted = 1")
if topic:
@@ -154,17 +165,19 @@ def feed(
where = "WHERE " + " AND ".join(clauses)
params.extend([limit, offset])
order_by = (
"COALESCE(a.published_at, a.discovered_at) DESC, rank_score DESC"
if sort == "latest"
else "rank_score DESC, COALESCE(a.published_at, a.discovered_at) DESC"
)
if match:
order_by = "bm25(article_search), COALESCE(a.published_at, a.discovered_at) DESC" # relevance, then recency
elif sort == "latest":
order_by = "COALESCE(a.published_at, a.discovered_at) DESC, rank_score DESC"
else:
order_by = "rank_score DESC, COALESCE(a.published_at, a.discovered_at) DESC"
rows = conn.execute(
f"""
SELECT {_ARTICLE_COLUMNS}
FROM articles a
JOIN sources src ON src.id = a.source_id
JOIN article_scores s ON s.article_id = a.id
{fts_join}
{where}
ORDER BY {order_by}
LIMIT ? OFFSET ?
@@ -174,6 +187,27 @@ def feed(
return [dict(row) for row in rows]
def reindex_search(conn: sqlite3.Connection) -> int:
"""Rebuild the article_search FTS index from the accepted, non-duplicate corpus
(title/description/source name/tags). A cheap full rebuild (a few thousand
rows); run on each ingest cycle and lazily on first search. Live visibility /
boundary filtering is applied at query time, so it doesn't need reindexing."""
conn.execute("DELETE FROM article_search")
conn.execute(
"""
INSERT INTO article_search (article_id, title, body, source_name, tags)
SELECT a.id, a.title, COALESCE(a.description, ''), src.name,
COALESCE((SELECT group_concat(t.tag, ' ') FROM article_tags t WHERE t.article_id = a.id), '')
FROM articles a
JOIN sources src ON src.id = a.source_id
JOIN article_scores s ON s.article_id = a.id
WHERE s.accepted = 1 AND a.duplicate_of IS NULL
"""
)
conn.commit()
return conn.execute("SELECT COUNT(*) FROM article_search").fetchone()[0]
def brief(conn: sqlite3.Connection, brief_date: str | None = None, limit: int = 10) -> dict:
"""Return a stored daily brief (latest if no date) with its ranked items."""
target_date = brief_date or _latest_brief_date(conn)
@@ -335,7 +369,7 @@ def source_health(conn: sqlite3.Connection) -> list[dict]:
SELECT
s.id, s.name, s.feed_url, s.homepage_url, s.default_category AS category, s.active,
s.status, s.content_visible, s.retry_after_at,
s.consecutive_failures AS failures, s.review_flag, s.review_reason,
s.consecutive_failures AS failures, s.review_flag, s.review_reason, s.paywall_override,
s.poll_interval_minutes AS interval_minutes,
s.last_success_at, s.last_error_at, substr(s.last_error, 1, 160) AS last_error,
(SELECT MAX(r.finished_at) FROM ingest_runs r
@@ -343,6 +377,8 @@ def source_health(conn: sqlite3.Connection) -> list[dict]:
(SELECT COUNT(*) FROM articles a WHERE a.source_id = s.id) AS total_articles,
(SELECT COUNT(*) FROM articles a JOIN article_scores sc ON sc.article_id = a.id
WHERE a.source_id = s.id AND sc.accepted = 1) AS accepted_total,
(SELECT COUNT(*) FROM articles a JOIN article_scores sc ON sc.article_id = a.id
WHERE a.source_id = s.id AND sc.reason_code = 'non_english') AS non_english,
(SELECT COUNT(*) FROM articles a WHERE a.source_id = s.id AND a.duplicate_of IS NOT NULL) AS duplicates,
(SELECT COUNT(*) FROM articles a JOIN article_scores sc ON sc.article_id = a.id
WHERE a.source_id = s.id AND sc.accepted = 1 AND a.duplicate_of IS NULL) AS served,
@@ -364,14 +400,21 @@ def source_health(conn: sqlite3.Connection) -> list[dict]:
d = dict(r)
total = d["total_articles"] or 0
accepted = d["accepted_total"] or 0
d["acceptance_rate"] = round(100 * accepted / total) if total else None
non_english = d.get("non_english") or 0
# Acceptance is judged over articles actually scored in English — non-English
# items are HELD (awaiting translation), not calm-filter rejections, so they
# don't drag a multilingual source's rate down.
judged = total - non_english
d["acceptance_rate"] = round(100 * accepted / judged) if judged else None
d["non_english"] = non_english
d["non_english_rate"] = round(100 * non_english / total) if total else None
d["duplicate_rate"] = round(100 * d["duplicates"] / total) if total else None
# Curation quality: of what this source got ACCEPTED, how much was a
# duplicate of content already served (accepted_total served = accepted dupes).
d["accepted_dup_rate"] = round(100 * (accepted - d["served"]) / accepted) if accepted else None
d["image_coverage"] = round(100 * (d["images"] or 0) / d["served"]) if d["served"] else None
# Paywall is a domain-level hint, so it's a per-source flag (not a rate).
d["paywalled"] = is_paywalled(d.get("homepage_url") or d.get("feed_url"))
# Paywall is a domain-level hint + a per-source override; show the EFFECTIVE flag.
d["paywalled"] = is_paywalled_for_source(d.get("homepage_url") or d.get("feed_url"), d.get("paywall_override"))
# Match the REAL scheduler gate: due = the later of the streak-backoff time
# and any retry_after_at rest (UTC strings sort chronologically).
due_times = [t for t in (d["next_due_at"], d["retry_after_at"]) if t]
@@ -454,9 +497,94 @@ def _attention(content: dict, sources: list[dict], feedback_unread: int, now: da
return items
# --- Source article inspector: the real articles behind the source metrics -----
_SRC_ART_FILTERS = {
"accepted": "AND s.accepted = 1",
# 'rejected' = calm-filter rejections only; non-English is HELD, its own bucket.
"rejected": "AND s.accepted = 0 AND COALESCE(s.reason_code,'') != 'non_english'",
"held": "AND s.reason_code = 'non_english'",
"no_image": "AND (a.image_url IS NULL OR a.image_url = '')",
"duplicates": "AND a.duplicate_of IS NOT NULL",
}
def source_articles(conn: sqlite3.Connection, source_id: int, filter: str = "all",
limit: int = 25, offset: int = 0) -> list[dict]:
"""The actual ingested articles for a source, newest first — so admins can
verify the metric (paywall/image/acceptance) against real evidence."""
ov = conn.execute("SELECT paywall_override FROM sources WHERE id = ?", (source_id,)).fetchone()
override = ov["paywall_override"] if ov else None
where = _SRC_ART_FILTERS.get(filter, "")
rows = conn.execute(
f"""
SELECT a.id, a.title, a.canonical_url, a.published_at, a.discovered_at,
a.image_url, a.duplicate_of,
s.accepted, s.reason_code, s.reason_text, s.topic, s.flavor
FROM articles a
LEFT JOIN article_scores s ON s.article_id = a.id
WHERE a.source_id = ? {where}
ORDER BY COALESCE(a.published_at, a.discovered_at) DESC
LIMIT ? OFFSET ?
""",
(source_id, limit, offset),
).fetchall()
return [
{
"id": r["id"],
"title": r["title"],
"url": r["canonical_url"],
"published_at": r["published_at"] or r["discovered_at"],
"accepted": r["accepted"],
"reason": r["reason_text"] or r["reason_code"], # the "why" behind accept/reject
"held": r["reason_code"] == "non_english", # held for language, not rejected
"topic": r["topic"],
"flavor": r["flavor"],
"paywalled": is_paywalled_for_source(r["canonical_url"], override), # effective (domain rule + override)
"has_image": bool(r["image_url"]),
"duplicate": r["duplicate_of"] is not None,
}
for r in rows
]
def source_articles_summary(conn: sqlite3.Connection, source_id: int) -> dict:
"""Counts behind the table metrics + the source-level paywall rule, so the
panel header reads e.g. '120 · 96 accepted · 24 rejected · 3 no image · paywall: ON'."""
agg = conn.execute(
"""
SELECT COUNT(*) total,
COALESCE(SUM(s.accepted = 1), 0) accepted,
COALESCE(SUM(s.accepted = 0 AND COALESCE(s.reason_code,'') != 'non_english'), 0) rejected,
COALESCE(SUM(s.reason_code = 'non_english'), 0) non_english,
COALESCE(SUM(a.image_url IS NULL OR a.image_url = ''), 0) no_image,
COALESCE(SUM(a.duplicate_of IS NOT NULL), 0) duplicates
FROM articles a LEFT JOIN article_scores s ON s.article_id = a.id
WHERE a.source_id = ?
""",
(source_id,),
).fetchone()
srow = conn.execute("SELECT homepage_url, feed_url, paywall_override FROM sources WHERE id = ?", (source_id,)).fetchone()
override = srow["paywall_override"] if srow else None
url = (srow["homepage_url"] or srow["feed_url"]) if srow else None
return {
"total": agg["total"], "accepted": agg["accepted"], "rejected": agg["rejected"],
"non_english": agg["non_english"], # held for language (not a calm-filter rejection)
"no_image": agg["no_image"], "duplicates": agg["duplicates"],
"paywalled": is_paywalled_for_source(url, override), # effective
"paywall_domain": is_paywalled(url), # what the domain rule alone says
"paywall_override": override, # null | 'free' | 'paywalled' — the basis
}
def admin_stats(conn: sqlite3.Connection, days: int = 30) -> dict:
"""Aggregate, non-personal usage stats for the admin dashboard."""
since = f"-{days} days"
# "Today" for timestamp-based counters is the SITE-LOCAL day (GOODNEWS_TZ), not
# UTC: otherwise an evening error (e.g. 22:53 local) lands on the next UTC day and
# reads as a fresh "today" the following morning — the exact false-alarm we hit.
local_day_start = (local_now().replace(hour=0, minute=0, second=0, microsecond=0)
.astimezone(UTC).strftime("%Y-%m-%d %H:%M:%S"))
def scalar(sql, params=()):
return conn.execute(sql, params).fetchone()[0] or 0
@@ -527,6 +655,19 @@ def admin_stats(conn: sqlite3.Connection, days: int = 30) -> dict:
}
replace = {"used": kc.get("replace_used", 0), "none": kc.get("replace_none", 0)}
# Game funnel — the growth loop we're instrumenting. Each count is distinct
# visitor-days (events dedupe per kind/day), so it reads as "people", not actions.
_GAME_NAMES = ("word", "wordsearch", "bloom", "match")
_GAME_EVENTS = ("arrival", "started", "completed", "shared") # arrival = share-loop acquisition
games_by = {
g: {e: kc.get(f"{g}_{e}", 0) for e in _GAME_EVENTS}
for g in _GAME_NAMES
}
games = {
"by_game": games_by,
"totals": {e: sum(games_by[g][e] for g in _GAME_NAMES) for e in _GAME_EVENTS},
}
# Accounts — aggregate counts only (no emails, no per-user listing).
accounts = {
"total": scalar("SELECT COUNT(*) FROM users"),
@@ -572,6 +713,7 @@ def admin_stats(conn: sqlite3.Connection, days: int = 30) -> dict:
"emotional_mix": emotional_mix,
"paywall": paywall,
"replace": replace,
"games": games,
"top_articles": top_articles,
"top_groupings": top_groupings,
"top_topics": top_topics,
@@ -582,7 +724,8 @@ def admin_stats(conn: sqlite3.Connection, days: int = 30) -> dict:
# check routinely and would read as real users seeing blank screens.
"client_errors": {
"today": scalar(
f"SELECT COUNT(*) FROM client_errors WHERE date(created_at)=date('now') AND {_NOT_BOT_SQL}"
f"SELECT COUNT(*) FROM client_errors WHERE created_at >= ? AND {_NOT_BOT_SQL}",
(local_day_start,),
),
"window": scalar(
f"SELECT COUNT(*) FROM client_errors WHERE created_at>=date('now',?) AND {_NOT_BOT_SQL}",
+8 -3
View File
@@ -216,9 +216,14 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None,
<script>
(function(){{
try{{
var v=localStorage.getItem('goodnews:visitor')||'';
var b=JSON.stringify({{kind:'summary_viewed',article_id:{aid},visitor:v}});
if(navigator.sendBeacon) navigator.sendBeacon('/api/events', new Blob([b],{{type:'application/json'}}));
var v=localStorage.getItem('goodnews:visitor');
if(!v){{v=crypto.randomUUID?crypto.randomUUID():String(Math.random()).slice(2)+Date.now();localStorage.setItem('goodnews:visitor',v);}}
function beacon(o){{var b=JSON.stringify(o);if(navigator.sendBeacon)navigator.sendBeacon('/api/events',new Blob([b],{{type:'application/json'}}));}}
beacon({{kind:'summary_viewed',article_id:{aid},visitor:v}});
// This page is server-rendered (outside the Svelte app), so the SPA's daily
// visit isn't recorded for a /a/ landing — count it here, once per day per device.
var t=new Date().toISOString().slice(0,10);
if(localStorage.getItem('goodnews:visitday')!==t){{localStorage.setItem('goodnews:visitday',t);beacon({{kind:'visit',article_id:0,visitor:v}});}}
}}catch(e){{}}
}})();
</script>
+15 -3
View File
@@ -7,7 +7,7 @@ from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlsplit
from .paywall import is_paywalled
from .paywall import is_paywalled, is_paywalled_for_source
def load_sources(path: Path | str) -> list[dict]:
@@ -175,6 +175,18 @@ def reject_candidate(conn: sqlite3.Connection, candidate_id: int) -> bool:
return cur.rowcount > 0
def restore_candidate(conn: sqlite3.Connection, candidate_id: int) -> bool:
"""Send a REJECTED candidate back to staging ('suggested') so it re-enters the
queue for another look. Only un-rejects a promoted candidate is untouched."""
cur = conn.execute(
"UPDATE source_candidates SET status = 'suggested', updated_at = CURRENT_TIMESTAMP "
"WHERE id = ? AND status = 'rejected'",
(candidate_id,),
)
conn.commit()
return cur.rowcount > 0
def promote_candidate(
conn: sqlite3.Connection,
candidate_id: int,
@@ -244,7 +256,7 @@ def review_sources(
now = datetime.now(timezone.utc)
flagged = []
sources = conn.execute(
"SELECT id, name, consecutive_failures FROM sources WHERE active = 1"
"SELECT id, name, consecutive_failures, paywall_override FROM sources WHERE active = 1"
).fetchall()
for s in sources:
@@ -292,7 +304,7 @@ def review_sources(
avg_rage = sum(r["ragebait_score"] or 0 for r in recent) / n
if avg_rage > 3:
reasons.append(f"high ragebait (avg {avg_rage:.1f})")
paywalled = sum(1 for r in recent if is_paywalled(r["canonical_url"])) / n
paywalled = sum(1 for r in recent if is_paywalled_for_source(r["canonical_url"], s["paywall_override"])) / n
if paywalled > 0.5:
reasons.append(f"paywall-heavy ({paywalled * 100:.0f}%)")
+3 -1
View File
@@ -419,7 +419,9 @@
box.append(d);
};
stat("Mode:", p.classified ? "model (accurate)" : "heuristic (quick, conservative)");
stat("Acceptance:", `${Math.round(p.acceptance_rate * 100)}% (${p.accepted}/${p.sampled})`);
stat("Acceptance:", p.acceptance_rate == null
? `— (all held · ${p.accepted}/${p.sampled})`
: `${Math.round(p.acceptance_rate * 100)}% (${p.accepted}/${p.sampled})`);
stat("Freshness:", `${p.recent_7d}/${p.sampled} in last 7 days · newest ${(p.newest_published||"unknown").slice(0,10)}`);
stat("Calm averages:", `cortisol ${p.avg_cortisol} · ragebait ${p.avg_ragebait} · PR ${p.avg_pr_risk}`);
const mix = (m) => Object.entries(m).map(([k, v]) => `${k} ${v}`).join(" · ") || "—";
+2
View File
@@ -16,6 +16,8 @@ $ = informational
- Date showed 6/2/2026 while it was still 6/1/2026 at 10:32pm
- For account-based usage, we should have a thumbs up button that shows up to track the articles the user likes the most. We can then curate a special feed of articles that match the categories the user likes the most. Not social-based, just for seeing news that means the most to you.
- Feasibility of allowing users to add their own custom feeds for news sources
- Joke corner: a curated, clean, non-offensive daily/rotating joke spot. On-brand "escape the grind" — light, professional-but-fun. Curation bar same as the rest of UB (nothing mean or edgy).
- Text adventure that SAVES YOUR SPOT in time (resume where you left off — a reason to come back). Start single-player/choose-your-path; dream stretch goal = broaden to co-op/multiplayer where people work through it together. Theme TBD. Fits "UB isn't just news — it's somewhere between professional and fun, a place to escape." (Would live under /play.)
+48
View File
@@ -0,0 +1,48 @@
Status Bucket Source Feed_URL Homepage Country/Region Scope Lane Notes
VERIFIED universities/research MIT News Research https://news.mit.edu/rss/research https://news.mit.edu US national science/tech topic feed; primary research
VERIFIED universities/research MIT News Environment https://news.mit.edu/rss/topic/environment https://news.mit.edu US national environment topic feed (climate/energy)
VERIFIED universities/research UC Berkeley News https://news.berkeley.edu/feed/ https://news.berkeley.edu US national research broad research; geo-taggable US
VERIFIED universities/research UW News https://www.washington.edu/news/feed/ https://www.washington.edu/news/ US (PNW) national/regional research Univ of Washington; PNW flavor
VERIFIED universities/research Harvard Gazette https://news.harvard.edu/gazette/feed/ https://news.harvard.edu/gazette/ US national research/health strong health/medicine + science
VERIFIED universities/research Johns Hopkins Hub https://hub.jhu.edu/feed/ https://hub.jhu.edu US national research/health medical + research breadth
VERIFIED gov labs/agencies NIST News https://www.nist.gov/news-events/news/rss.xml https://www.nist.gov US (gov) national science/tech clean RSS (rare for gov)
VERIFIED gov labs/agencies NSF News https://www.nsf.gov/rss/rss_www_news.xml https://www.nsf.gov US (gov) national science funded discoveries, many fields
VERIFIED science Knowable Magazine https://knowablemagazine.org/rss https://knowablemagazine.org US global science explanatory, low-hype
VERIFIED science Nautilus https://nautil.us/feed/ https://nautil.us US global science/culture thoughtful long-form science
VERIFIED science Undark https://undark.org/feed/ https://undark.org US global science nonprofit (MIT KSJ)
VERIFIED conservation/env Inside Climate News https://insideclimatenews.org/feed/ https://insideclimatenews.org US national environment/climate Pulitzer nonprofit; solutions-leaning
VERIFIED conservation/env Canary Media https://www.canarymedia.com/articles.rss https://www.canarymedia.com US national energy/clean-tech clean-energy transition wins
VERIFIED conservation/env Cool Green Science (TNC) https://blog.nature.org/feed/ https://blog.nature.org US/global global conservation TAG ORG/advocacy (Nature Conservancy)
VERIFIED conservation/env Yale Climate Connections https://yaleclimateconnections.org/feed/ https://yaleclimateconnections.org US national environment/climate solutions + adaptation
VERIFIED constructive/solutions YES! Magazine https://www.yesmagazine.org/feed https://www.yesmagazine.org US national solutions core solutions journalism
VERIFIED constructive/solutions Christian Science Monitor Science https://rss.csmonitor.com/feeds/science https://www.csmonitor.com US global science TOPIC feed (avoids CSM politics)
VERIFIED regional High Country News https://www.hcn.org/feed https://www.hcn.org US West regional environment/community regional flavor for Closer To Home
VERIFIED regional Sightline Institute https://www.sightline.org/feed/ https://www.sightline.org US Pacific NW regional policy/sustainability TAG ORG; PNW solutions
VERIFIED community Strong Towns https://www.strongtowns.org/journal?format=rss https://www.strongtowns.org US national community/urbanism TAG ORG; local-repair framing
VERIFIED constructive/global The Better India https://www.thebetterindia.com/feed/ https://www.thebetterindia.com India global (non-US) constructive non-US breadth; good-news native
VERIFIED health NPR Goats and Soda https://feeds.npr.org/1039/rss.xml https://www.npr.org/sections/goatsandsoda/ US->global global health/development global health & development
VERIFIED health NPR Shots https://feeds.npr.org/1128/rss.xml https://www.npr.org/sections/health-shots/ US national health CHECK overlap w/ existing 'NPR Health'
VERIFIED education Chalkbeat https://www.chalkbeat.org/arc/outboundfeeds/rss/ https://www.chalkbeat.org US national education education reform/wins
VERIFIED education Hechinger Report https://hechingerreport.org/feed/ https://hechingerreport.org US national education nonprofit education coverage
VERIFIED education EdSurge https://www.edsurge.com/articles_rss https://www.edsurge.com US national education/ed-tech ed-tech + learning
BOT-BLOCKED(403) health institutions Mayo Clinic News Network https://newsnetwork.mayoclinic.org/feed/ https://newsnetwork.mayoclinic.org US national health feed exists; 403 to bots
BOT-BLOCKED(403) universities/research Stanford News https://news.stanford.edu/feed/ https://news.stanford.edu US national research feed exists; 403 to bots
BOT-BLOCKED(403) food/community Civil Eats https://civileats.com/feed/ https://civileats.com US national food-systems/community feed exists; 403 to bots
DISCOVERY-PHASE health institutions Cleveland Clinic Newsroom (unresolved) https://newsroom.clevelandclinic.org US national health RSS path unresolved
DISCOVERY-PHASE gov labs/agencies NIH (no clean RSS) https://www.nih.gov/news-events US (gov) national health gov dropped clean RSS
DISCOVERY-PHASE gov labs/agencies NOAA (no clean RSS) https://www.noaa.gov US (gov) national environment/climate gov dropped clean RSS
DISCOVERY-PHASE gov labs/agencies NOAA Climate.gov (no clean RSS) https://www.climate.gov US (gov) national climate gov dropped clean RSS
DISCOVERY-PHASE gov labs/agencies DOE Office of Science (no clean RSS) https://www.energy.gov/science US (gov) national energy/science gov dropped clean RSS
DISCOVERY-PHASE gov labs/agencies NREL (timeout/none) https://www.nrel.gov/news US (gov) national energy no reachable RSS
DISCOVERY-PHASE gov labs/agencies USGS (no clean RSS) https://www.usgs.gov/news US (gov) national science/environment gov dropped clean RSS
DISCOVERY-PHASE gov labs/agencies EPA (no clean RSS) https://www.epa.gov/newsreleases US (gov) national environment gov dropped clean RSS
DISCOVERY-PHASE research wire EurekAlert (RSS gated) https://www.eurekalert.org US/global global science/health RSS gated; needs API/discovery
DISCOVERY-PHASE community Next City (rss not a feed) https://nextcity.org US national urban-solutions feed endpoint returns non-feed
DISCOVERY-PHASE education Edutopia (rss not a feed) https://www.edutopia.org US national education feed endpoint returns non-feed
DISCOVERY-PHASE constructive Fix The News (404) https://fixthenews.com global global constructive likely Substack feed; find URL
DEMOTE-WATCH existing (paywall) Nature News (existing source) https://www.nature.com/news UK/global global science 100% paywall; prefer-accessible/demote-if-persistent
DEMOTE-WATCH existing (paywall) New Scientist (existing source) https://www.newscientist.com UK/global global science 100% paywall
DEMOTE-WATCH existing (paywall) MIT Technology Review (existing source) https://www.technologyreview.com US global technology 100% paywall
DEMOTE-WATCH existing (paywall) NY Times Learning (existing source) https://www.nytimes.com/section/learning US national education 100% paywall
DEMOTE-WATCH existing (0% access) Guardian Science (existing source) https://www.theguardian.com/science UK/global global science 0% accessible in metrics
DEMOTE-WATCH existing (0% access) Guardian Environment (existing source) https://www.theguardian.com/environment UK/global global environment 0% accessible in metrics
1 Status Bucket Source Feed_URL Homepage Country/Region Scope Lane Notes
2 VERIFIED universities/research MIT News – Research https://news.mit.edu/rss/research https://news.mit.edu US national science/tech topic feed; primary research
3 VERIFIED universities/research MIT News – Environment https://news.mit.edu/rss/topic/environment https://news.mit.edu US national environment topic feed (climate/energy)
4 VERIFIED universities/research UC Berkeley News https://news.berkeley.edu/feed/ https://news.berkeley.edu US national research broad research; geo-taggable US
5 VERIFIED universities/research UW News https://www.washington.edu/news/feed/ https://www.washington.edu/news/ US (PNW) national/regional research Univ of Washington; PNW flavor
6 VERIFIED universities/research Harvard Gazette https://news.harvard.edu/gazette/feed/ https://news.harvard.edu/gazette/ US national research/health strong health/medicine + science
7 VERIFIED universities/research Johns Hopkins Hub https://hub.jhu.edu/feed/ https://hub.jhu.edu US national research/health medical + research breadth
8 VERIFIED gov labs/agencies NIST News https://www.nist.gov/news-events/news/rss.xml https://www.nist.gov US (gov) national science/tech clean RSS (rare for gov)
9 VERIFIED gov labs/agencies NSF News https://www.nsf.gov/rss/rss_www_news.xml https://www.nsf.gov US (gov) national science funded discoveries, many fields
10 VERIFIED science Knowable Magazine https://knowablemagazine.org/rss https://knowablemagazine.org US global science explanatory, low-hype
11 VERIFIED science Nautilus https://nautil.us/feed/ https://nautil.us US global science/culture thoughtful long-form science
12 VERIFIED science Undark https://undark.org/feed/ https://undark.org US global science nonprofit (MIT KSJ)
13 VERIFIED conservation/env Inside Climate News https://insideclimatenews.org/feed/ https://insideclimatenews.org US national environment/climate Pulitzer nonprofit; solutions-leaning
14 VERIFIED conservation/env Canary Media https://www.canarymedia.com/articles.rss https://www.canarymedia.com US national energy/clean-tech clean-energy transition wins
15 VERIFIED conservation/env Cool Green Science (TNC) https://blog.nature.org/feed/ https://blog.nature.org US/global global conservation TAG ORG/advocacy (Nature Conservancy)
16 VERIFIED conservation/env Yale Climate Connections https://yaleclimateconnections.org/feed/ https://yaleclimateconnections.org US national environment/climate solutions + adaptation
17 VERIFIED constructive/solutions YES! Magazine https://www.yesmagazine.org/feed https://www.yesmagazine.org US national solutions core solutions journalism
18 VERIFIED constructive/solutions Christian Science Monitor – Science https://rss.csmonitor.com/feeds/science https://www.csmonitor.com US global science TOPIC feed (avoids CSM politics)
19 VERIFIED regional High Country News https://www.hcn.org/feed https://www.hcn.org US – West regional environment/community regional flavor for Closer To Home
20 VERIFIED regional Sightline Institute https://www.sightline.org/feed/ https://www.sightline.org US – Pacific NW regional policy/sustainability TAG ORG; PNW solutions
21 VERIFIED community Strong Towns https://www.strongtowns.org/journal?format=rss https://www.strongtowns.org US national community/urbanism TAG ORG; local-repair framing
22 VERIFIED constructive/global The Better India https://www.thebetterindia.com/feed/ https://www.thebetterindia.com India global (non-US) constructive non-US breadth; good-news native
23 VERIFIED health NPR Goats and Soda https://feeds.npr.org/1039/rss.xml https://www.npr.org/sections/goatsandsoda/ US->global global health/development global health & development
24 VERIFIED health NPR Shots https://feeds.npr.org/1128/rss.xml https://www.npr.org/sections/health-shots/ US national health CHECK overlap w/ existing 'NPR Health'
25 VERIFIED education Chalkbeat https://www.chalkbeat.org/arc/outboundfeeds/rss/ https://www.chalkbeat.org US national education education reform/wins
26 VERIFIED education Hechinger Report https://hechingerreport.org/feed/ https://hechingerreport.org US national education nonprofit education coverage
27 VERIFIED education EdSurge https://www.edsurge.com/articles_rss https://www.edsurge.com US national education/ed-tech ed-tech + learning
28 BOT-BLOCKED(403) health institutions Mayo Clinic News Network https://newsnetwork.mayoclinic.org/feed/ https://newsnetwork.mayoclinic.org US national health feed exists; 403 to bots
29 BOT-BLOCKED(403) universities/research Stanford News https://news.stanford.edu/feed/ https://news.stanford.edu US national research feed exists; 403 to bots
30 BOT-BLOCKED(403) food/community Civil Eats https://civileats.com/feed/ https://civileats.com US national food-systems/community feed exists; 403 to bots
31 DISCOVERY-PHASE health institutions Cleveland Clinic Newsroom (unresolved) https://newsroom.clevelandclinic.org US national health RSS path unresolved
32 DISCOVERY-PHASE gov labs/agencies NIH (no clean RSS) https://www.nih.gov/news-events US (gov) national health gov dropped clean RSS
33 DISCOVERY-PHASE gov labs/agencies NOAA (no clean RSS) https://www.noaa.gov US (gov) national environment/climate gov dropped clean RSS
34 DISCOVERY-PHASE gov labs/agencies NOAA Climate.gov (no clean RSS) https://www.climate.gov US (gov) national climate gov dropped clean RSS
35 DISCOVERY-PHASE gov labs/agencies DOE Office of Science (no clean RSS) https://www.energy.gov/science US (gov) national energy/science gov dropped clean RSS
36 DISCOVERY-PHASE gov labs/agencies NREL (timeout/none) https://www.nrel.gov/news US (gov) national energy no reachable RSS
37 DISCOVERY-PHASE gov labs/agencies USGS (no clean RSS) https://www.usgs.gov/news US (gov) national science/environment gov dropped clean RSS
38 DISCOVERY-PHASE gov labs/agencies EPA (no clean RSS) https://www.epa.gov/newsreleases US (gov) national environment gov dropped clean RSS
39 DISCOVERY-PHASE research wire EurekAlert (RSS gated) https://www.eurekalert.org US/global global science/health RSS gated; needs API/discovery
40 DISCOVERY-PHASE community Next City (rss not a feed) https://nextcity.org US national urban-solutions feed endpoint returns non-feed
41 DISCOVERY-PHASE education Edutopia (rss not a feed) https://www.edutopia.org US national education feed endpoint returns non-feed
42 DISCOVERY-PHASE constructive Fix The News (404) https://fixthenews.com global global constructive likely Substack feed; find URL
43 DEMOTE-WATCH existing (paywall) Nature News (existing source) https://www.nature.com/news UK/global global science 100% paywall; prefer-accessible/demote-if-persistent
44 DEMOTE-WATCH existing (paywall) New Scientist (existing source) https://www.newscientist.com UK/global global science 100% paywall
45 DEMOTE-WATCH existing (paywall) MIT Technology Review (existing source) https://www.technologyreview.com US global technology 100% paywall
46 DEMOTE-WATCH existing (paywall) NY Times Learning (existing source) https://www.nytimes.com/section/learning US national education 100% paywall
47 DEMOTE-WATCH existing (0% access) Guardian Science (existing source) https://www.theguardian.com/science UK/global global science 0% accessible in metrics
48 DEMOTE-WATCH existing (0% access) Guardian Environment (existing source) https://www.theguardian.com/environment UK/global global environment 0% accessible in metrics
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""Prototype Bloom (Center Circle) generator — prints real sample wheels so we
can feel the quality before building any UI. The validated logic here becomes
goodnews/bloom.py."""
import hashlib
import json
import random
from pathlib import Path
_DATA = Path(__file__).resolve().parents[1] / "goodnews" / "data"
d = json.loads((_DATA / "bloom_words.json").read_text())
ACCEPT = d["accept"]
COMMON = set(d["common"])
ACCEPT_LS = [(w, frozenset(w)) for w in ACCEPT]
# Off-brand words we never CELEBRATE as the day's pangram (accept-list unaffected).
AVOID = set(json.loads((_DATA / "bloom_avoid.json").read_text()))
# Candidate wheels = letter-sets of COMMON 7-distinct-letter words (so the day's
# pangram is always a recognizable word). No 'S' already guaranteed by the list.
PANGRAM_SETS: dict[frozenset, list[str]] = {}
for w in COMMON:
s = frozenset(w)
if len(s) == 7:
PANGRAM_SETS.setdefault(s, []).append(w)
MIN_WORDS, MAX_WORDS, MIN_COMMON, TOP_TIER = 24, 60, 14, 0.70
def score(w: str) -> int:
return 1 if len(w) == 4 else len(w)
def build(letters: frozenset, center: str):
words = [w for w, s in ACCEPT_LS if center in w and s <= letters]
pangrams = [w for w in words if frozenset(w) == letters]
commons = [w for w in words if w in COMMON]
max_score = sum(score(w) for w in words) + 7 * len(pangrams)
common_score = sum(score(w) for w in commons) + 7 * len([w for w in pangrams if w in COMMON])
return words, pangrams, commons, max_score, common_score
def valid(letters, center):
words, pangrams, commons, max_score, common_score = build(letters, center)
if not (MIN_WORDS <= len(words) <= MAX_WORDS):
return None
# The DISPLAY pangram must be calm + recognizable: common, not on the avoid
# list. (Off-brand pangrams like LUCIFER/VOMITING are still accepted if typed,
# just never the day's celebrated word.)
display = [p for p in pangrams if p in COMMON and p not in AVOID]
if not display or len(commons) < MIN_COMMON:
return None
if common_score < TOP_TIER * max_score: # top tier reachable from common vocab
return None
return words, sorted(display, key=len), commons, max_score, common_score
def generate(date: str):
rng = random.Random(int(hashlib.sha256(f"bloom:{date}".encode()).hexdigest(), 16))
sets = list(PANGRAM_SETS)
rng.shuffle(sets)
for s in sets:
centers = sorted(s)
rng.shuffle(centers)
for c in centers:
res = valid(s, c)
if res:
return s, c, res
return None
print(f"loaded accept={len(ACCEPT)} common={len(COMMON)} | candidate wheels={len(PANGRAM_SETS)}\n")
for date in ("2026-06-15", "2026-06-16", "2026-06-17", "2026-06-18", "2026-06-19"):
s, c, (words, pangrams, commons, max_score, common_score) = generate(date)
outer = sorted(s - {c})
tiers = {"Sprouting": 0, "Budding": int(0.08 * max_score),
"Blooming": int(0.30 * max_score), "Flourishing": int(0.70 * max_score)}
longest = sorted(words, key=len, reverse=True)[:3]
sample = sorted(random.Random(1).sample(words, min(16, len(words))))
print(f"── {date} ── center [{c.upper()}] outer {[x.upper() for x in outer]}")
print(f" words={len(words)} (common={len(commons)}) pangram(s)={[p.upper() for p in pangrams]}")
print(f" max_score={max_score} tiers={tiers}")
print(f" longest={longest} sample={sample}\n")
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Build Bloom's accepted-word dictionary (one-time / regenerable build step).
The make-or-break of Bloom is the accepted-word list: large and natural enough
that a normal word is never rejected, but free of obscure crossword-ese and of
anything offensive (so a shared board can't be made abusive).
Recipe:
base = ENABLE (~173k word-game words, NO proper nouns) "is it a real word"
keep words with wordfreq zipf >= ZIPF_MIN "is it natural/common"
profanity/slur blocklist (LDNOOBW en) "is it safe to share"
any word containing 's' (the wheel never has S, so an S-word can never be
formed it can never be accepted drop it)
words < 4 letters
Two tiers are vendored to goodnews/data/bloom_words.json:
"accept" (zipf >= ACCEPT_MIN) the generous set that COUNTS when typed
"common" (zipf >= COMMON_MIN) a tighter subset used only to DESIGN puzzles
(pangram is always recognizable; top tier is
reachable with everyday vocabulary)
Pre-filtered + vendored so the game needs no wordfreq at runtime.
Usage:
python scripts/build_bloom_words.py preview # show sizes+samples per threshold
python scripts/build_bloom_words.py write # vendor at the chosen thresholds
"""
from __future__ import annotations
import json
import random
import sys
from pathlib import Path
import wordfreq
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "goodnews" / "data" / "bloom_words.json"
BASE = Path("/tmp/enable1.txt")
BAD = Path("/tmp/ldnoobw_en.txt")
MIN_LEN = 4
# Accept is VERY generous so a normal word (incl. inflected forms like "beefed",
# "aced") is never rejected — a frequency cut splits inflections, so we keep the
# floor low and only trim the genuinely obscure/archaic tail. Tiers are based on
# `common` (below), NOT on accept, so generosity never makes the game harder.
ACCEPT_MIN = 2.0
COMMON_MIN = 3.3 # the DESIGNED puzzle: recognizable words; drives tiers + pangram
def _load_candidates() -> list[str]:
base = {w.strip().lower() for w in BASE.read_text().splitlines() if w.strip()}
bad = {w.strip().lower() for w in BAD.read_text().splitlines() if w.strip()}
# LDNOOBW conflates clinical anatomy/biology with profanity — "block abuse,
# not biology": allow legitimate medical/anatomical/normal words back in.
allow = set(json.loads((ROOT / "goodnews" / "data" / "bloom_allow.json").read_text()))
bad -= allow
out = []
for w in base:
if len(w) < MIN_LEN or not w.isalpha():
continue
if "s" in w: # wheel never contains S → an S-word is never makeable
continue
if w in bad:
continue
out.append(w)
return out, bad
def _filter(cands: list[str], zipf_min: float) -> list[str]:
return sorted(w for w in cands if wordfreq.zipf_frequency(w, "en") >= zipf_min)
def main() -> None:
cmd = sys.argv[1] if len(sys.argv) > 1 else "preview"
cands, bad = _load_candidates()
print(f"candidates (real, alpha, >=4, no-S, not-blocked): {len(cands)} | blocklist {len(bad)}")
if cmd == "preview":
rng = random.Random(7)
for z in (2.5, 2.8, 3.0, 3.3):
words = _filter(cands, z)
sample = rng.sample(words, 18)
print(f"\nzipf>={z}: {len(words)} words")
print(" sample:", ", ".join(sorted(sample)))
elif cmd == "write":
# ACCEPT is now BROAD: every valid dictionary word (real ENABLE word, ≥4,
# no-S, not profane). No frequency floor — tiers are decoupled (common-based),
# so obscure-but-real words like "arraign" count automatically as bonus finds
# without ever becoming a pangram or making the game harder. Runtime curation
# (allow/block individual words) is DB-backed (bloom_word_overrides), no deploy.
accept = sorted(cands)
common = _filter(cands, COMMON_MIN)
OUT.write_text(json.dumps({"accept": accept, "common": common}))
print(f"\nwrote accept={len(accept)} (ALL valid words), "
f"common={len(common)} (zipf>={COMMON_MIN}) → {OUT}")
else:
print(f"unknown command: {cmd}")
if __name__ == "__main__":
main()
+134
View File
@@ -0,0 +1,134 @@
"""Deep-preview accessibility check — content-level readable/paywalled/blocked/unknown,
and the layered verdict (domain rule + sampled access, evidence over domain alone)."""
import time
from goodnews import feeds
from goodnews.paywall import check_article_access
READABLE = b"<html><body><article>" + (b"<p>Real article text here. </p>" * 80) + b"</article></body></html>"
WALLED_SCHEMA = b'<html><head><script type="application/ld+json">{"isAccessibleForFree": false}</script></head><body><p>teaser</p></body></html>'
WALLED_PHRASE = b"<html><body><p>Subscribe to continue reading this story.</p></body></html>"
THIN = b"<html><body><p>hi</p></body></html>"
def _fetcher(mapping):
def f(url, timeout=8):
if mapping.get(url) == "ERR":
raise RuntimeError("boom")
return mapping[url]
return f
def test_classifies_each_access_state():
f = _fetcher({"r": READABLE, "s": WALLED_SCHEMA, "p": WALLED_PHRASE, "t": THIN, "b": "ERR"})
assert check_article_access("r", f) == "readable"
assert check_article_access("s", f) == "paywalled" # schema.org isAccessibleForFree:false
assert check_article_access("p", f) == "paywalled" # explicit wall phrase
assert check_article_access("t", f) == "unknown" # too thin to tell
assert check_article_access("b", f) == "blocked" # fetch failed
def test_does_not_falseflag_a_readable_page():
# a long article that merely links "subscribe to our newsletter" in the footer
html = b"<html><body><article>" + (b"<p>Lots of real content. </p>" * 100) + \
b"<footer>Subscribe to our newsletter</footer></article></body></html>"
assert check_article_access("x", _fetcher({"x": html})) == "readable"
def _items(urls):
return [feeds.FeedItem(title=f"T{i}", url=u, description="d", published_at=None)
for i, u in enumerate(urls)]
def test_preview_verdict_layers_domain_and_sample(monkeypatch):
# a non-paywall-domain feed whose sampled articles mostly read fine -> "fine"
urls = ["https://good.example/a1", "https://good.example/a2", "https://good.example/a3"]
monkeypatch.setattr(feeds, "parse_feed", lambda raw: _items(urls))
class FakeClient:
model = "test"
def classify(self, art):
return {"accepted": True, "topic": "science", "flavor": "discovery",
"cortisol_score": 1, "ragebait_score": 1, "pr_risk_score": 2}
def fetcher(url, timeout=10):
return READABLE # every sampled article reads fine
out = feeds.preview_feed("https://good.example/feed", sample=8, client=FakeClient(), fetcher=fetcher)
assert out["paywall_rule"] is False
assert out["access"]["readable"] >= 1 and out["access"]["paywalled"] == 0
assert out["access_verdict"] == "fine"
def test_mostly_blocked_is_review_not_fine(monkeypatch):
# bot-blocked sites (readable in a browser, blocked to our fetcher) must NOT read
# as 'fine' off one sample, nor as 'reject-ready' — they land in 'review'.
urls = [f"https://blocky.example/a{i}" for i in range(6)]
monkeypatch.setattr(feeds, "parse_feed", lambda raw: _items(urls))
class FakeClient:
model = "test"
def classify(self, art):
return {"accepted": True, "topic": "science", "flavor": "discovery",
"cortisol_score": 1, "ragebait_score": 1, "pr_risk_score": 2}
def fetcher(url, timeout=10):
if url.endswith("/feed") or url.endswith("a0"):
return READABLE # the feed fetch + one readable article
raise RuntimeError("403 blocked") # the rest block (bot-blocked)
out = feeds.preview_feed("https://blocky.example/feed", sample=8, client=FakeClient(), fetcher=fetcher)
assert out["access"]["blocked"] >= 4 and out["access"]["readable"] == 1
assert out["access_verdict"] == "review" # thin assessable evidence → not 'fine', not 'reject-ready'
def test_source_preview_endpoint_handles_null_rate(tmp_path, monkeypatch):
# All-held (non-English) sample → acceptance_rate is None; the legacy
# /api/source-preview must not 500 on it (SourcePreview.acceptance_rate is nullable).
db = tmp_path / "t.sqlite3"
monkeypatch.setenv("GOODNEWS_DB", str(db))
monkeypatch.setenv("GOODNEWS_PUBLIC_BASE_URL", "http://testserver")
import importlib
import goodnews.api as api
importlib.reload(api)
from goodnews.db import connect, init_db
c = connect(str(db)); init_db(c); c.commit(); c.close()
all_held = {
"url": "http://x/feed", "sampled": 4, "classified": True, "accepted": 0,
"non_english": 4, "acceptance_rate": None, "avg_cortisol": 0.0, "avg_ragebait": 0.0,
"avg_pr_risk": 0.0, "newest_published": None, "recent_7d": 0,
"topic_mix": {}, "flavor_mix": {}, "examples_accepted": [], "examples_rejected": [],
}
monkeypatch.setattr(feeds, "preview_feed", lambda *a, **k: all_held)
from fastapi.testclient import TestClient
r = TestClient(api.create_app()).get("/api/source-preview?url=http://x/feed")
assert r.status_code == 200 # was 500: None rejected by float field
assert r.json()["acceptance_rate"] is None
def test_one_hung_fetch_does_not_stall_the_preview(monkeypatch):
# Codex's wall-clock audit: one article that sleeps WAY past the deadline must
# not pin Deep Preview — it returns at the cap, with the slow one left 'unknown'.
monkeypatch.setattr(feeds, "_ACCESS_DEADLINE_S", 0.5) # shrink the cap for the test
urls = [f"https://mixed.example/a{i}" for i in range(6)]
monkeypatch.setattr(feeds, "parse_feed", lambda raw: _items(urls))
class FakeClient:
model = "test"
def classify(self, art):
return {"accepted": True, "topic": "science", "flavor": "discovery",
"cortisol_score": 1, "ragebait_score": 1, "pr_risk_score": 2}
def fetcher(url, timeout=10):
if url.endswith("a0"):
time.sleep(5) # one ugly site hangs far past the 0.5s cap
return READABLE
start = time.monotonic()
out = feeds.preview_feed("https://mixed.example/feed", sample=8, client=FakeClient(), fetcher=fetcher)
elapsed = time.monotonic() - start
assert elapsed < 2.5 # returned at the cap (~0.5s), NOT after the 5s sleep
# the hung one is 'unknown' (unverified), the rest read fine
slow = next(e for e in out["access"]["examples"] if e["url"].endswith("a0"))
assert slow["access"] == "unknown"
assert out["access"]["readable"] >= 4
+48
View File
@@ -503,3 +503,51 @@ def test_wordsearch_theme_admin(tmp_path, monkeypatch):
# remove
left = tc.delete(f"/api/admin/wordsearch/themes/{tid}").json()
assert not any(t["id"] == tid for t in left)
def test_source_articles_inspector(tmp_path, monkeypatch):
app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com")
assert TestClient(app).get("/api/admin/sources/1/articles").status_code == 401 # gated
tc = _signin(app, api, "boss@x.com")
r = tc.get("/api/admin/sources/1/articles").json()
assert r["summary"]["total"] == 1 and r["summary"]["accepted"] == 1 and r["summary"]["no_image"] == 1
assert len(r["articles"]) == 1
a = r["articles"][0]
assert a["title"] == "t1" and a["accepted"] == 1 and a["has_image"] is False and a["paywalled"] is False
# filters resolve in SQL; rejected → none (the seeded article is accepted)
assert tc.get("/api/admin/sources/1/articles?filter=rejected").json()["articles"] == []
assert len(tc.get("/api/admin/sources/1/articles?filter=no_image").json()["articles"]) == 1
assert tc.get("/api/admin/sources/999/articles").status_code == 404 # unknown source
def test_source_paywall_override(tmp_path, monkeypatch):
import sqlite3, os
app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com")
c = sqlite3.connect(os.environ["GOODNEWS_DB"])
c.execute("INSERT INTO sources (id,name,feed_url,homepage_url,trust_score,content_visible) "
"VALUES (2,'NYT Learning','http://x/f','https://www.nytimes.com/section/learning',5,1)")
c.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash) "
"VALUES (2,2,'https://www.nytimes.com/learning/word-of-the-day','WOTD','h2')")
c.execute("INSERT INTO article_scores (article_id,accepted,topic) VALUES (2,1,'culture')")
c.commit(); c.close()
tc = _signin(app, api, "boss@x.com")
def feed_badge():
return next(a for a in tc.get("/api/feed?source_id=2").json()["items"] if a["id"] == 2)["paywalled"]
# domain rule: nytimes.com → paywalled in table, inspector, AND feed badge (all agree)
assert _src(tc, 2)["paywalled"] is True
assert tc.get("/api/admin/sources/2/articles").json()["summary"]["paywalled"] is True
assert feed_badge() is True
# override 'free' (the NYT Learning fix) → effective OFF everywhere
assert tc.post("/api/admin/sources/2/paywall", json={"override": "free"}).json()["override"] == "free"
assert _src(tc, 2)["paywalled"] is False
summ = tc.get("/api/admin/sources/2/articles").json()["summary"]
assert summ["paywalled"] is False and summ["paywall_domain"] is True and summ["paywall_override"] == "free"
assert feed_badge() is False # ranking/badge now agree it's free
# back to domain rule, and the 'paywalled' override
assert tc.post("/api/admin/sources/2/paywall", json={"override": None}).json()["override"] is None
assert _src(tc, 2)["paywalled"] is True
# validation + 404
assert tc.post("/api/admin/sources/2/paywall", json={"override": "bogus"}).status_code == 422
assert tc.post("/api/admin/sources/999/paywall", json={"override": "free"}).status_code == 404
+24
View File
@@ -114,3 +114,27 @@ def test_brief_cache_boundary(client):
assert "public" in client.get("/api/brief").headers.get("cache-control", "")
assert client.get("/api/brief", params={"prefs": json.dumps({"mute_topics": ["health"]})}).headers.get("cache-control") == "private, no-store"
assert client.get("/api/brief", params={"exclude": "3"}).headers.get("cache-control") == "private, no-store"
def test_search_relevance_source_and_boundaries(client):
import os, sqlite3, json as _j
# A distinctively-named source proves source-name matching (the NYT use case).
c = sqlite3.connect(os.environ["GOODNEWS_DB"])
c.execute("INSERT INTO sources (id,name,feed_url,trust_score) VALUES (2,'Nature Digest','http://n/f',7)")
c.execute("INSERT INTO articles (id,source_id,canonical_url,title,published_at,url_hash) "
"VALUES (3,2,'http://n/3','Coral reefs rebound','2026-05-30T10:00:00+00:00','h3')")
c.execute("INSERT INTO article_scores (article_id,accepted,topic,flavor) VALUES (3,1,'environment','hopeful')")
c.commit(); c.close()
# title match (index builds lazily on first search)
assert client.get("/api/search?q=coral").json()["items"][0]["id"] == 3
# SOURCE-NAME match — searching the publication finds its articles (Codex's requirement)
assert 3 in [it["id"] for it in client.get("/api/search?q=nature").json()["items"]]
# empty / junk query → empty, no error
assert client.get("/api/search?q=").json()["count"] == 0
assert client.get("/api/search?q=%20%21%21").json()["count"] == 0
# boundary: a muted topic is excluded from search too (mirrors the visitor view)
muted = client.get("/api/search", params={"q": "coral", "prefs": _j.dumps({"mute_topics": ["environment"]})}).json()
assert muted["count"] == 0
# boundary: a hard avoid-term filters a textual match
avoided = client.get("/api/search", params={"q": "election", "prefs": _j.dumps({"avoid_terms": ["election"]})}).json()
assert all(it["id"] != 2 for it in avoided["items"])
+255
View File
@@ -0,0 +1,255 @@
"""Bloom — the daily word wheel. Locks the design/acceptance split:
DESIGN (deterministic, stored): wheel + tiers + pangram + Full-Bloom target,
from the COMMON list. The PERMANENT guardrail Flourishing reachable with
common words still holds.
ACCEPTANCE (broad + dynamic): every valid word buildable from the wheel,
computed live as broad dict {allow} {block}; runtime admin overrides +
player reports drive curation with no deploy.
"""
import os
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from goodnews import bloom, games
from goodnews.db import connect, init_db
DATES = [f"2026-06-{d:02d}" for d in range(10, 25)] # 15 sample days
@pytest.fixture(scope="module")
def designs():
return {d: bloom.build_puzzle(d) for d in DATES}
@pytest.fixture
def conn(tmp_path):
c = connect(str(tmp_path / "t.db"))
init_db(c)
c.execute("INSERT INTO users (email) VALUES ('a@b.c')")
c.commit()
return c
def _letters(p):
return frozenset(p["center"]) | frozenset(p["outer"])
def _commons_for(p):
"""COMMON words for a center-mode wheel (the designed puzzle)."""
L = _letters(p)
return [w for w in bloom._COMMON if p["center"] in w and frozenset(w) <= L]
def _assert_no_answer_leak(resp):
assert "words" not in resp
assert resp["accepted"] and all(
isinstance(h, str) and len(h) == 64 and set(h) <= set("0123456789abcdef")
for h in resp["accepted"])
# --- DESIGN (deterministic, common-based) --------------------------------------
def test_build_is_deterministic():
assert bloom.build_puzzle("2026-06-15") == bloom.build_puzzle("2026-06-15")
@pytest.mark.parametrize("date", DATES)
def test_design_shape(designs, date):
p = designs[date]
L = _letters(p)
assert len(L) == 7 and "s" not in L
assert p["center"] in L and len(p["outer"]) == 6
assert bloom.MIN_COMMON_WORDS <= len(_commons_for(p)) <= bloom.MAX_COMMON_WORDS
assert frozenset(p["pangram"]) == L # display pangram uses all 7
assert p["pangram"] in bloom._COMMON and p["pangram"] not in bloom._AVOID
@pytest.mark.parametrize("date", DATES)
def test_PERMANENT_top_tier_reachable_with_common_words(designs, date):
"""Flourishing reachable from COMMON words alone — never obscure-word hunting."""
p = designs[date]
flourishing = next(t["score"] for t in p["tiers"] if t["name"] == "Flourishing")
assert bloom.score_words(p, _commons_for(p)) >= flourishing
def test_tiers_are_8_30_70_of_common_and_max_is_common_total():
p = bloom.build_puzzle("2026-06-15")
assert [t["name"] for t in p["tiers"]] == ["Sprouting", "Budding", "Blooming", "Flourishing"]
common_total = bloom.score_words(p, _commons_for(p))
assert p["max_score"] == common_total # Full Bloom = the designed puzzle
flour = next(t["score"] for t in p["tiers"] if t["name"] == "Flourishing")
assert flour == int(0.70 * common_total) and flour <= p["max_score"]
# --- ACCEPTANCE (broad + dynamic) ----------------------------------------------
def test_accept_is_broad_and_obeys_center_rule(conn):
p = bloom.build_puzzle("2026-06-15")
acc = bloom.accepted_words(conn, p["center"], p["outer"], require_center=True)
L = _letters(p)
for w in acc:
assert len(w) >= 4 and "s" not in w and frozenset(w) <= L and p["center"] in w
# broad accept is a SUPERSET of the common puzzle (bonus words beyond design)
assert set(_commons_for(p)) <= set(acc)
assert len(acc) > len(_commons_for(p))
def test_arraign_class_words_auto_accepted():
# broad dict includes real-but-rare words without any include-list
for w in ("arraign", "feign", "crwth"):
assert w in set(bloom.ACCEPT)
def test_overrides_block_and_allow(conn):
p = bloom.build_puzzle("2026-06-15")
acc0 = set(bloom.accepted_words(conn, p["center"], p["outer"], True))
victim = sorted(acc0)[0]
bloom.set_override(conn, victim, "block", by="t")
assert victim not in set(bloom.accepted_words(conn, p["center"], p["outer"], True))
# allow a made-up letter-combo that fits the wheel + center
fake = (p["center"] + "".join(p["outer"][:3]))[:5]
if "s" not in fake and len(fake) >= 4:
bloom.set_override(conn, fake, "allow", by="t")
assert fake in set(bloom.accepted_words(conn, p["center"], p["outer"], True))
bloom.clear_override(conn, victim)
assert victim in set(bloom.accepted_words(conn, p["center"], p["outer"], True))
def test_allow_override_rejects_inert_hard_rule_words(conn):
# an allow that could never count (too short / has 's') is rejected, not stored
assert bloom.set_override(conn, "cat", "allow") is False # < 4 letters
assert bloom.set_override(conn, "roses", "allow") is False # contains 's'
assert bloom.set_override(conn, "bloom", "allow") is True # valid → stored
allow, _ = bloom.overrides(conn)
assert allow == {"bloom"}
# block stays permissive (can block anything)
assert bloom.set_override(conn, "roses", "block") is True
def test_wild_accepts_words_without_center(conn):
p = bloom.build_free("seed-w", "wild")
acc = bloom.accepted_words(conn, p["center"], p["outer"], require_center=False)
assert any(p["center"] not in w for w in acc) # Wild's defining trait
assert all(frozenset(w) <= _letters(p) for w in acc)
# --- responses + storage -------------------------------------------------------
def test_generate_is_idempotent_and_stored(conn):
a = bloom.generate_bloom_puzzle(conn, "2026-06-15")
assert a == bloom.generate_bloom_puzzle(conn, "2026-06-15") == bloom.stored_payload(conn, "2026-06-15")
assert "words" not in a # design payload holds no answers
def test_response_no_leak_and_hash_roundtrip(conn):
r = bloom.bloom_response(conn, "2026-06-15")
_assert_no_answer_leak(r)
p = bloom.stored_payload(conn, "2026-06-15")
real = bloom.accepted_words(conn, p["center"], p["outer"], True)[0]
assert bloom.word_hash("2026-06-15", real) in set(r["accepted"])
assert bloom.word_hash("2026-06-15", "zzzzq") not in set(r["accepted"])
assert r["max_score"] == p["max_score"]
def test_free_endpoint_resumes_and_leaks_nothing(api_app):
tc = TestClient(api_app)
r1 = tc.get("/api/puzzle/bloom/free?format=wild").json()
seed = r1["seed"]
assert r1["mode"] == "free" and r1["format"] == "wild" and seed
r2 = tc.get(f"/api/puzzle/bloom/free?format=wild&seed={seed}").json()
assert r2["center"] == r1["center"] and r2["outer"] == r1["outer"]
_assert_no_answer_leak(r1)
# --- server-side state ---------------------------------------------------------
def test_sanitize_drops_junk_recomputes_score_and_full(conn):
p = bloom.generate_bloom_puzzle(conn, "2026-06-15")
acc = bloom.accepted_words(conn, p["center"], p["outer"], True)
good = acc[:3]
clean = games.sanitize_game_state(conn, "bloom", "", "2026-06-15",
{"found": good + ["zzzz", "ab", good[0], 9], "score": 9999})
assert sorted(clean["found"]) == sorted(set(good))
assert clean["score"] == bloom.score_words(p, good)
assert "full" not in clean
# finding the whole common puzzle ⇒ Full Bloom (score ≥ max_score)
full = games.sanitize_game_state(conn, "bloom", "", "2026-06-15", {"found": _commons_for(p)})
assert full.get("full") is True
def test_merge_unions_found():
m = games.merge_game_state("bloom", {"found": ["able", "bake"]}, {"found": ["bake", "tale"]})
assert sorted(m["found"]) == ["able", "bake", "tale"]
def test_block_override_takes_effect_without_regen(conn):
# the live response reflects an override with no puzzle regeneration
p = bloom.generate_bloom_puzzle(conn, "2026-06-15")
victim = bloom.accepted_words(conn, p["center"], p["outer"], True)[0]
before = set(bloom.bloom_response(conn, "2026-06-15")["accepted"])
bloom.set_override(conn, victim, "block", by="t")
after = set(bloom.bloom_response(conn, "2026-06-15")["accepted"])
assert bloom.word_hash("2026-06-15", victim) in before
assert bloom.word_hash("2026-06-15", victim) not in after
# --- reports → admin queue → overrides -----------------------------------------
def test_report_then_approve_creates_allow_override(conn):
assert bloom.add_report(conn, "arraign", "2026-06-15", "daily", "center", "aceglnr", "not in the word list")
assert bloom.add_report(conn, "arraign", "2026-06-15", "daily", "center", "aceglnr", "x") # dedup pending
pending = bloom.list_reports(conn, "pending")
assert len(pending) == 1 and pending[0]["word"] == "arraign"
assert bloom.resolve_report(conn, pending[0]["id"], "approve", by="admin")
allow, _ = bloom.overrides(conn)
assert "arraign" in allow
assert not bloom.list_reports(conn, "pending")
assert bloom.list_reports(conn, "approved")
def test_report_block_creates_block_override(conn):
bloom.add_report(conn, "uglyword", None, "free", "wild", "abcdefg", "x")
rid = bloom.list_reports(conn, "pending")[0]["id"]
bloom.resolve_report(conn, rid, "block", by="admin")
_, block = bloom.overrides(conn)
assert "uglyword" in block
# --- API: public report + admin endpoints --------------------------------------
@pytest.fixture
def api_app(tmp_path, monkeypatch):
db = tmp_path / "t.sqlite3"
monkeypatch.setenv("GOODNEWS_DB", str(db))
monkeypatch.setenv("GOODNEWS_PUBLIC_BASE_URL", "http://testserver")
monkeypatch.setenv("GOODNEWS_ADMIN_EMAILS", "admin@b.com")
import importlib
import goodnews.api as api
importlib.reload(api)
c = connect(str(db)); init_db(c); c.commit(); c.close()
return api.create_app()
def _admin(app):
tc = TestClient(app)
sent = {}
import goodnews.email_send as es
orig = es.send_magic_link
es.send_magic_link = lambda to, link: sent.update(link=link)
try:
tc.post("/api/auth/email/start", json={"email": "admin@b.com"})
tc.post("/api/auth/email/verify", json={"token": sent["link"].split("token=")[1]})
finally:
es.send_magic_link = orig
return tc
def test_public_report_then_admin_queue_flow(api_app):
pub = TestClient(api_app)
assert pub.post("/api/bloom/report", json={"word": "arraign", "date": "2026-06-15",
"mode": "daily", "format": "center", "letters": "aceglnr",
"reason": "not in the word list"}).json()["ok"]
# admin-only queue
assert TestClient(api_app).get("/api/admin/bloom/reports").status_code == 401
tc = _admin(api_app)
q = tc.get("/api/admin/bloom/reports").json()
assert len(q["reports"]) == 1
rid = q["reports"][0]["id"]
assert tc.post(f"/api/admin/bloom/reports/{rid}", json={"action": "approve"}).json()["ok"]
ovr = tc.get("/api/admin/bloom/reports").json()["overrides"]
assert any(o["word"] == "arraign" and o["action"] == "allow" for o in ovr)
+13
View File
@@ -5,6 +5,7 @@ from goodnews.sources import (
list_candidates,
promote_candidate,
reject_candidate,
restore_candidate,
save_candidate,
)
@@ -32,6 +33,18 @@ def test_re_preview_preserves_curator_status(conn):
assert list_candidates(conn)[0]["status"] == "rejected"
def test_restore_sends_rejected_back_to_staging(conn):
save_candidate(conn, "http://x/feed")
cid = list_candidates(conn)[0]["id"]
reject_candidate(conn, cid)
assert list_candidates(conn)[0]["status"] == "rejected"
# restore → back to staging ('suggested'), re-enters the pending queue
assert restore_candidate(conn, cid) is True
assert list_candidates(conn)[0]["status"] == "suggested"
# restoring a non-rejected candidate is a no-op (only un-rejects)
assert restore_candidate(conn, cid) is False
def test_promote_creates_inactive_source_and_marks_promoted(conn):
cand = save_candidate(conn, "http://x/feed", name="Lovely Feed")
source_id = promote_candidate(conn, cand["id"]) # inactive by default
+15
View File
@@ -0,0 +1,15 @@
"""CLI honors GOODNEWS_DB for its default --db, matching db.connect. Without this, a
copy-DB maintenance run (e.g. `dedup --force-recluster`) silently targets production."""
from pathlib import Path
from goodnews.cli import DEFAULT_DB, _default_db
def test_default_db_honors_env(monkeypatch):
monkeypatch.setenv("GOODNEWS_DB", "/tmp/some-copy.sqlite3")
assert _default_db() == Path("/tmp/some-copy.sqlite3")
def test_default_db_falls_back_to_bundled(monkeypatch):
monkeypatch.delenv("GOODNEWS_DB", raising=False)
assert _default_db() == DEFAULT_DB
+31 -3
View File
@@ -35,7 +35,7 @@ def conn():
c.close()
def _add(conn, article_id, vector, constructive, when="2026-05-30T10:00:00+00:00"):
def _add(conn, article_id, vector, constructive, when="2026-05-30T10:00:00+00:00", accepted=1):
conn.execute(
"INSERT INTO articles (id, source_id, canonical_url, title, published_at, url_hash) "
"VALUES (?, 1, ?, ?, ?, ?)",
@@ -44,8 +44,8 @@ def _add(conn, article_id, vector, constructive, when="2026-05-30T10:00:00+00:00
conn.execute(
"INSERT INTO article_scores (article_id, constructive_score, agency_score, "
"human_benefit_score, cortisol_score, ragebait_score, pr_risk_score, accepted) "
"VALUES (?, ?, 0, 0, 0, 0, 0, 1)",
(article_id, constructive),
"VALUES (?, ?, 0, 0, 0, 0, 0, ?)",
(article_id, constructive, accepted),
)
conn.execute(
"INSERT INTO article_embeddings (article_id, vector, dim, model) VALUES (?, ?, ?, 'test')",
@@ -69,6 +69,34 @@ def test_near_duplicates_collapse_to_highest_ranked(conn):
assert dup_of[3] is None # C stands alone
def test_accepted_member_beats_a_higher_quality_rejected_one(conn):
# The rep must be SERVEABLE: an accepted page may never be retired to a rejected
# representative (that page would 404 with nothing to 301 to). Accepted wins even
# though the rejected twin scores higher on quality.
_add(conn, 1, [1.0, 0.0, 0.0, 0.0], constructive=9, accepted=0) # higher quality, REJECTED
_add(conn, 2, [0.99, 0.02, 0.0, 0.0], constructive=3, accepted=1) # lower quality, accepted
cluster_duplicates(conn, threshold=0.86, window_days=3)
dup_of = {r["id"]: r["duplicate_of"] for r in conn.execute("SELECT id, duplicate_of FROM articles")}
assert dup_of[2] is None # the accepted article is the representative (serves 200)
assert dup_of[1] == 2 # the rejected one points at it
def test_established_rep_stays_stable_when_a_better_twin_arrives(conn):
# An already-indexed canonical shouldn't churn just because a higher-quality near
# duplicate shows up later. Establish 1 as rep (with follower 3), then a stronger 2
# arrives — 1 must remain the representative for URL stability.
_add(conn, 1, [1.0, 0.0, 0.0, 0.0], constructive=5)
_add(conn, 3, [0.99, 0.01, 0.0, 0.0], constructive=1)
cluster_duplicates(conn, threshold=0.86, window_days=3) # run 1: 1 is rep (score 5 > 1)
assert conn.execute("SELECT duplicate_of FROM articles WHERE id=1").fetchone()[0] is None
_add(conn, 2, [0.995, 0.01, 0.0, 0.0], constructive=9) # higher quality newcomer
cluster_duplicates(conn, threshold=0.86, window_days=3) # run 2
dup_of = {r["id"]: r["duplicate_of"] for r in conn.execute("SELECT id, duplicate_of FROM articles")}
assert dup_of[1] is None # incumbent stays canonical despite 2's higher score
assert dup_of[2] == 1 and dup_of[3] == 1
def test_distinct_articles_are_not_clustered(conn):
_add(conn, 1, [1.0, 0.0, 0.0, 0.0], constructive=5)
_add(conn, 2, [0.0, 1.0, 0.0, 0.0], constructive=5)
+31
View File
@@ -51,3 +51,34 @@ def test_unknown_kind_is_ignored(app_db):
app, db = app_db
assert TestClient(app).post("/api/events", json={"kind": "evil", "visitor": "x"}).json() == {"ok": True}
assert _count(db) == 0
def test_game_event_kinds_are_allowed(app_db):
app, db = app_db
tc = TestClient(app)
# the per-game funnel kinds (incl. the share-loop arrival) pass the allowlist
for kind in ("word_started", "word_completed", "word_shared", "word_arrival", "match_arrival"):
assert tc.post("/api/events", json={"kind": kind, "article_id": 0, "visitor": "t"}).json() == {"ok": True}
assert _count(db, kind=kind) == 1
# a bogus game kind is still rejected
tc.post("/api/events", json={"kind": "chess_started", "visitor": "t"})
assert _count(db, kind="chess_started") == 0
def test_admin_stats_games_funnel_aggregates(app_db):
app, db = app_db
tc = TestClient(app)
# two visitors arrive at Daily Word via a shared link; one engages + shares; a Match completes
for v in ("a", "b"):
tc.post("/api/events", json={"kind": "word_arrival", "article_id": 0, "visitor": v})
tc.post("/api/events", json={"kind": "word_started", "article_id": 0, "visitor": "a"})
tc.post("/api/events", json={"kind": "word_shared", "article_id": 0, "visitor": "a"})
tc.post("/api/events", json={"kind": "match_completed", "article_id": 0, "visitor": "a"})
from goodnews.db import connect
from goodnews import queries
c = connect(str(db))
games = queries.admin_stats(c, days=30)["games"]
c.close()
assert games["by_game"]["word"] == {"arrival": 2, "started": 1, "completed": 0, "shared": 1}
assert games["by_game"]["match"]["completed"] == 1
assert games["totals"]["arrival"] == 2 and games["totals"]["shared"] == 1
+133
View File
@@ -0,0 +1,133 @@
"""Cross-device game-state sync: server-side merge so two devices converge."""
import pytest
from goodnews import games
from goodnews.db import connect, init_db
@pytest.fixture
def conn(tmp_path):
c = connect(str(tmp_path / "t.db"))
init_db(c)
c.execute("INSERT INTO users (email) VALUES ('a@b.c')")
c.commit()
return c
# --- merge logic (the audited core) ---
def test_merge_wordsearch_unions_finds():
a = {"foundWords": [{"word": "CAT", "cells": [[0, 0]], "ci": 0}], "played": 9000, "ms": 0}
b = {"foundWords": [{"word": "DOG", "cells": [[1, 1]], "ci": 1}], "played": 4000, "ms": 0}
m = games.merge_game_state("wordsearch", a, b)
assert {f["word"] for f in m["foundWords"]} == {"CAT", "DOG"} # union of finds
# active-time clock: the device that banked the most play time is the truth —
# wall-clock gaps between sittings must never inflate the timer
assert m["played"] == 9000
def test_merge_wordsearch_dedupes_and_keeps_best_time():
a = {"foundWords": [{"word": "CAT", "cells": [[0, 0]], "ci": 0}], "ms": 5000}
b = {"foundWords": [{"word": "CAT", "cells": [[0, 0]], "ci": 0}], "ms": 3000}
m = games.merge_game_state("wordsearch", a, b)
assert len(m["foundWords"]) == 1 and m["ms"] == 3000 # same word once, best time
def test_merge_word_furthest_progress_wins():
playing = {"status": "playing", "guesses": ["AAAAA", "BBBBB"]}
won = {"status": "won", "guesses": ["AAAAA", "CCCCC", "DDDDD"]}
assert games.merge_game_state("word", playing, won) == won # terminal beats in-progress
less = {"status": "playing", "guesses": ["A"]}
more = {"status": "playing", "guesses": ["A", "B", "C"]}
assert games.merge_game_state("word", less, more) == more # more guesses beats fewer
def test_merge_handles_missing_sides():
a = {"status": "won", "guesses": ["x"]}
assert games.merge_game_state("word", None, a) == a
assert games.merge_game_state("word", a, None) == a
# --- persistence convergence ---
def _find(word, row): # a shape-valid find: cells spelling the word along a row
return {"word": word, "cells": [[row, i] for i in range(len(word))], "ci": 0}
def test_save_converges_across_devices(conn):
# No stored puzzle for this date → shape-only sanitize (words 4-12, cells match).
games.save_game_state(conn, 1, "wordsearch", "small", "2026-06-12",
{"foundWords": [_find("BEACH", 0)], "played": 100})
merged = games.save_game_state(conn, 1, "wordsearch", "small", "2026-06-12",
{"foundWords": [_find("OCEAN", 1)], "played": 50})
assert {f["word"] for f in merged["foundWords"]} == {"BEACH", "OCEAN"}
# stored state reflects the merge (order-independent): most banked time wins
assert games.load_game_state(conn, 1, "wordsearch", "small", "2026-06-12")["played"] == 100
# --- derived stats ---
def test_word_stats_streak_and_distribution(conn):
games.save_game_state(conn, 1, "word", "5", "2026-06-12", {"status": "won", "guesses": ["aaaaa", "bbbbb", "ccccc"]})
games.save_game_state(conn, 1, "word", "5", "2026-06-11", {"status": "won", "guesses": ["aaaaa", "bbbbb", "ccccc", "ddddd"]})
games.save_game_state(conn, 1, "word", "5", "2026-06-10", {"status": "lost", "guesses": ["aaaaa"] * 6})
st = games.game_stats(conn, 1, "word", "5")
assert st["played"] == 3 and st["won"] == 2
assert st["streak"] == 2 # two most-recent wins, then the loss stops it
assert st["dist"] == {3: 1, 4: 1}
def test_wordsearch_stats_best_time(conn):
import json
# Store completed states directly — game_stats just reads what's persisted
# (the sanitizer that gates `ms` on real completion is covered separately).
for d, ms in (("2026-06-12", 4000), ("2026-06-11", 6000)):
conn.execute("INSERT INTO game_state (user_id, game, variant, puzzle_date, state_json) "
"VALUES (1,'wordsearch','med',?,?)", (d, json.dumps({"foundWords": [], "ms": ms})))
conn.commit()
st = games.game_stats(conn, 1, "wordsearch", "med")
assert st["completed"] == 2 and st["best"] == 4000
# --- API round-trip (signed-in only; needs the test helpers) ---
def test_game_state_api_roundtrip(tmp_path, monkeypatch):
from test_admin import _make, _signin
from fastapi.testclient import TestClient
app, api = _make(tmp_path, monkeypatch)
# signed out → no sync, echoes the posted state and GET sees nothing stored
anon = TestClient(app)
body = {"game": "wordsearch", "variant": "small", "date": "2026-06-12",
"state": {"foundWords": [_find("BEACH", 0)], "played": 9}}
assert anon.put("/api/games/state", json=body).json()["state"]["foundWords"][0]["word"] == "BEACH"
assert anon.get("/api/games/state?game=wordsearch&variant=small&date=2026-06-12").json()["state"] is None
# signed in: push from "device A", then "device B" → server returns the union
tc = _signin(app, api, "p@x.com")
tc.put("/api/games/state", json=body)
bodyB = {**body, "state": {"foundWords": [_find("OCEAN", 1)], "played": 4}}
merged = tc.put("/api/games/state", json=bodyB).json()["state"]
assert {f["word"] for f in merged["foundWords"]} == {"BEACH", "OCEAN"} and merged["played"] == 9
# GET returns the stored merge; unknown game → 404; bad date → 400
got = tc.get("/api/games/state?game=wordsearch&variant=small&date=2026-06-12").json()["state"]
assert {f["word"] for f in got["foundWords"]} == {"BEACH", "OCEAN"}
assert tc.get("/api/games/state?game=nope&variant=x&date=2026-06-12").status_code == 404
assert tc.get("/api/games/state?game=wordsearch&variant=small&date=notadate").status_code == 400
def test_sanitizers_reject_junk(conn):
# Word: bad status → playing; wrong-length/non-alpha guesses dropped; cols capped to kept guesses
w = games._sanitize_word("5", {"status": "hacked", "guesses": ["abcde", "no", "12345", "fghij"],
"cols": [["correct", "absent", "bogus", "x", "y"], ["absent"] * 5, ["x"], ["y"]]})
assert w["status"] == "playing" and w["guesses"] == ["abcde", "fghij"]
assert len(w["cols"]) == 2 and w["cols"][0] == ["correct", "absent"]
# Word Search (no stored puzzle → shape-only): bad shapes dropped, no completion without word count
ws = games._sanitize_wordsearch(conn, "small", "2026-06-12", {
"foundWords": [_find("BEACH", 0), # ok
{"word": "CAT", "cells": [[0, 0], [0, 1], [0, 2]]}, # too short (<4)
{"word": "OCEAN", "cells": [[1, 0], [1, 1]]}], # cells != len
"ms": 12345, "played": -5})
assert [f["word"] for f in ws["foundWords"]] == ["BEACH"] and ws["ms"] == 0
assert ws["played"] == 0 # negative junk clamped
# absurd active-time claims are capped at a day
capped = games._sanitize_wordsearch(conn, "small", "2026-06-12", {"foundWords": [], "played": 10**12})
assert capped["played"] == 86_400_000
+63
View File
@@ -0,0 +1,63 @@
"""English-only gate: non-English articles are HELD (reason_code='non_english'),
preserved (not deleted) and distinct from calm-filter rejections, so they don't
penalize a multilingual source and can be revisited when translation lands."""
from goodnews import queries
from goodnews.db import connect, init_db
from goodnews.llm import normalize_scores, upsert_article_score
def _data(**kw):
base = {
"constructive_score": 7, "cortisol_score": 1, "ragebait_score": 1, "agency_score": 5,
"human_benefit_score": 6, "novelty_score": 4, "pr_risk_score": 2, "accepted": True,
"topic": "science", "flavor": "discovery", "tags": [],
"reason_code": "ok", "reason_text": "good",
}
base.update(kw)
return base
def test_english_passes_through():
s = normalize_scores(_data(language="en"), "m")
assert s["accepted"] == 1 and s["reason_code"] == "ok" and s["language"] == "en"
def test_en_variants_count_as_english():
for lang in ("en-US", "EN", "en_us", "en-GB"):
assert normalize_scores(_data(language=lang), "m")["accepted"] == 1
def test_non_english_is_held_not_a_rejection():
s = normalize_scores(_data(language="de"), "m")
assert s["accepted"] == 0
assert s["reason_code"] == "non_english" # distinct bucket, not a calm-filter reject
assert s["language"] == "de"
assert "non-English" in s["reason_text"]
def test_missing_or_unknown_language_defaults_to_english():
# a model hiccup must never silently drop genuine English content
assert normalize_scores(_data(language=""), "m")["accepted"] == 1
assert normalize_scores(_data(language="und"), "m")["accepted"] == 1
assert normalize_scores(_data(), "m")["accepted"] == 1 # no language key at all
def test_non_english_buckets_even_a_content_reject():
# a non-English item that was also content-rejected is still 'held', so source
# metrics can separate language-holds from calm rejections cleanly
s = normalize_scores(_data(language="es", accepted=False, reason_code="ragebait"), "m")
assert s["accepted"] == 0 and s["reason_code"] == "non_english"
def test_language_persisted_structurally_and_inspector_marks_held():
c = connect(":memory:"); init_db(c)
c.execute("INSERT INTO sources (id,name,feed_url,trust_score) VALUES (1,'S','http://s/f',5)")
c.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash) VALUES (1,1,'http://x','T','h1')")
c.commit()
upsert_article_score(c, 1, normalize_scores(_data(language="de"), "m"))
row = c.execute("SELECT accepted, reason_code, language FROM article_scores WHERE article_id=1").fetchone()
assert row["language"] == "de" and row["reason_code"] == "non_english" and row["accepted"] == 0 # structured, not parsed
# inspector: shows under 'held', flagged held=True, and NOT under 'rejected'
held = queries.source_articles(c, 1, filter="held")
assert len(held) == 1 and held[0]["held"] is True
assert queries.source_articles(c, 1, filter="rejected") == []
+134
View File
@@ -0,0 +1,134 @@
"""Memory Match server state — light, durability-only (no anti-cheat; the board is
deterministic and fully visible). Locks: malformed keys dropped, matched stored as
deduped face KEYS, `done` DERIVED from the matched count vs the tier's target (never
trusted from the client), cross-device merge unions matched, and the sync endpoint
accepts only valid match variants."""
import pytest
from fastapi.testclient import TestClient
from goodnews import games
from goodnews.db import connect, init_db
def _san(variant, state):
return games.sanitize_game_state(None, "match", variant, "2026-06-16", state)
def test_sanitize_drops_junk_and_dedupes():
s = _san("standard-icons", {
"matched": ["leaf", "leaf", "color-rose", "banana", "BAD KEY!", 42, "x" * 40, "sun"],
"moves": -5, "done": "yes",
})
# deduped + validated against the real face set ("banana"/junk dropped), order kept
assert s["matched"] == ["leaf", "color-rose", "sun"]
assert s["moves"] == 0 # clamped ≥ 0
assert s["done"] is False # 3 < 8 faces — client's "yes" ignored
def test_done_is_derived_from_matched_count_not_client_flag():
real8 = ["leaf", "sun", "star", "moon", "cloud", "wave", "tree", "heart"] # real faces
# client lies that it's done with no progress → server says not done
assert _san("standard-icons", {"matched": [], "done": True})["done"] is False
# reaching the tier's face target (standard = 8) → done
assert _san("standard-icons", {"matched": real8, "done": False})["done"] is True
# gentle target is only 6
assert _san("gentle-icons", {"matched": real8[:6]})["done"] is True
assert _san("standard-icons", {"matched": real8[:6]})["done"] is False
def test_sanitize_caps_face_count():
many = ["color-rose", "color-coral", "color-amber", "color-gold", "color-lime",
"color-green", "color-teal", "color-cyan", "color-sky", "color-blue",
"color-indigo", "color-violet", "color-plum", "color-brown", "color-sand"] # 15 real
s = _san("expert-colors", {"matched": many})
assert len(s["matched"]) == 12 # _MATCH_MAX_FACES
def test_merge_unions_matched_and_keeps_moves_without_trusting_done():
a = {"matched": ["leaf", "sun"], "moves": 7, "done": False}
b = {"matched": ["sun", "star"], "moves": 4, "done": True}
m = games.merge_game_state("match", a, b)
assert sorted(m["matched"]) == ["leaf", "star", "sun"] # union
assert m["moves"] == 7 # larger move count
assert "done" not in m # merge doesn't carry done; sanitize derives it
@pytest.fixture
def api_app(tmp_path, monkeypatch):
db = tmp_path / "t.sqlite3"
monkeypatch.setenv("GOODNEWS_DB", str(db))
monkeypatch.setenv("GOODNEWS_PUBLIC_BASE_URL", "http://testserver")
monkeypatch.setenv("GOODNEWS_ADMIN_EMAILS", "admin@b.com")
import importlib
import goodnews.api as api
importlib.reload(api)
c = connect(str(db)); init_db(c); c.commit(); c.close()
return api.create_app()
def _signin(app, email="p@b.com"):
tc = TestClient(app)
sent = {}
import goodnews.email_send as es
orig = es.send_magic_link
es.send_magic_link = lambda to, link: sent.update(link=link)
try:
tc.post("/api/auth/email/start", json={"email": email})
tc.post("/api/auth/email/verify", json={"token": sent["link"].split("token=")[1]})
finally:
es.send_magic_link = orig
return tc
def _put(tc, variant, state):
return tc.put("/api/games/state", json={
"game": "match", "variant": variant, "date": "2026-06-16", "state": state})
def test_sync_endpoint_flow(api_app):
tc = _signin(api_app)
r1 = _put(tc, "standard-icons", {"matched": ["leaf", "sun"], "moves": 3, "done": False})
assert r1.status_code == 200
assert sorted(r1.json()["state"]["matched"]) == ["leaf", "sun"]
assert r1.json()["state"]["done"] is False
# a second device merges in (still partial → not done)
r2 = _put(tc, "standard-icons", {"matched": ["star"], "moves": 1, "done": True})
assert sorted(r2.json()["state"]["matched"]) == ["leaf", "star", "sun"]
assert r2.json()["state"]["done"] is False # 3 < 8, client's done ignored
# completing the board (8 real faces) → done
r3 = _put(tc, "standard-icons",
{"matched": ["leaf", "sun", "star", "moon", "cloud", "wave", "tree", "heart"], "moves": 12})
assert r3.json()["state"]["done"] is True
# unknown variant rejected
assert _put(tc, "huge-icons", {}).status_code == 404
def test_batch_endpoint_reconciles_many_and_drops_bad(api_app):
tc = _signin(api_app)
body = {"date": "2026-06-16", "items": [
{"game": "match", "variant": "standard-icons", "state": {"matched": ["leaf", "sun"], "moves": 2}},
{"game": "bloom", "variant": "", "state": {"found": []}},
{"game": "match", "variant": "bogus-xyz", "state": {}}, # unknown variant → dropped
]}
r = tc.put("/api/games/state/batch", json=body)
assert r.status_code == 200
states = r.json()["states"]
variants = {(s["game"], s["variant"]) for s in states}
assert ("match", "standard-icons") in variants
assert ("bloom", "") in variants
assert ("match", "bogus-xyz") not in variants # invalid item dropped, not fatal
m = next(s for s in states if s["variant"] == "standard-icons")
assert sorted(m["state"]["matched"]) == ["leaf", "sun"] # merged + sanitized
# a second device merges via the same batch path
r2 = tc.put("/api/games/state/batch", json={"date": "2026-06-16", "items": [
{"game": "match", "variant": "standard-icons", "state": {"matched": ["star"], "moves": 5}}]})
m2 = r2.json()["states"][0]["state"]
assert sorted(m2["matched"]) == ["leaf", "star", "sun"] and m2["moves"] == 5
def test_batch_endpoint_signed_out_echoes(api_app):
from fastapi.testclient import TestClient
r = TestClient(api_app).put("/api/games/state/batch", json={"date": "2026-06-16", "items": [
{"game": "match", "variant": "gentle-colors", "state": {"matched": ["color-rose"]}}]})
assert r.status_code == 200
assert r.json()["states"][0]["state"] == {"matched": ["color-rose"]} # echo, no sync
+329
View File
@@ -0,0 +1,329 @@
"""Publishing Desk Phase 1 — queue logic, top-up/dedup semantics, comparative LLM
ranking with deterministic fallback, verified handle resolution, status transitions."""
from datetime import datetime, timedelta, timezone
import pytest
from goodnews import publishing
from goodnews.db import connect, init_db
BASE = "https://ub.test"
def _ts(hours_ago: float) -> str:
return (datetime.now(timezone.utc) - timedelta(hours=hours_ago)).strftime("%Y-%m-%d %H:%M:%S")
@pytest.fixture
def conn():
c = connect(":memory:"); init_db(c)
yield c
c.close()
def _src(c, sid, x_handle=None, paywall_override=None, content_visible=1):
c.execute(
"INSERT INTO sources (id,name,feed_url,trust_score,content_visible,x_handle,paywall_override) "
"VALUES (?,?,?,?,?,?,?)",
(sid, f"Source {sid}", f"http://s{sid}/feed", 5, content_visible, x_handle, paywall_override),
)
def _article(c, aid, sid, *, accepted=1, dup=None, novelty=5, constructive=5, topic="science",
url=None, image="http://img/x.jpg", hours_ago=1.0, complete=True):
c.execute(
"INSERT INTO articles (id,source_id,canonical_url,title,url_hash,image_url,published_at) "
"VALUES (?,?,?,?,?,?,?)",
(aid, sid, url or f"https://ex{aid}.com/a", f"Title {aid}", f"h{aid}", image, _ts(hours_ago)),
)
if dup is not None:
c.execute("UPDATE articles SET duplicate_of=? WHERE id=?", (dup, aid))
c.execute(
"INSERT INTO article_scores (article_id,accepted,novelty_score,constructive_score,topic,reason_code) "
"VALUES (?,?,?,?,?, 'ok')", (aid, accepted, novelty, constructive, topic),
)
if complete:
c.execute(
"INSERT INTO article_summaries (article_id,summary,what_happened,why_matters,why_belongs) "
"VALUES (?,?,?,?,?)", (aid, f"Summary {aid}", "wh", "wm", "wb"),
)
def _seed_n(c, n):
"""n eligible articles, each from its own source (so diversity caps don't interfere)."""
for i in range(1, n + 1):
_src(c, i)
_article(c, i, i, novelty=10 - i, topic=f"t{i}")
c.commit()
class FakeClient:
def __init__(self, ranked):
self._ranked = ranked
def rank_for_social(self, candidates):
return self._ranked
class BoomClient:
def rank_for_social(self, candidates):
raise RuntimeError("model down")
# --- handle resolution ----------------------------------------------------------
def test_handles_source_first_then_entities_deduped_capped(conn):
publishing.add_entity_handle(conn, "Anthropic", "AnthropicAI", "https://x.com/AnthropicAI")
publishing.add_entity_handle(conn, "NASA", "NASA")
out = publishing.resolve_handles(conn, ["Anthropic", "NASA", "Unknown Org"], source_handle="Phys_org")
assert out[0]["via"] == "source" and out[0]["handle"] == "@Phys_org"
assert len(out) == 2 # capped at 2
assert out[1]["handle"] == "@AnthropicAI" # first matched entity; NASA dropped by cap
assert all(h["handle"].startswith("@") for h in out)
def test_handles_aliases_resolve_consistently(conn):
publishing.add_entity_handle(conn, "Johns Hopkins University", "HopkinsMedicine")
publishing.add_entity_handle(conn, "Johns Hopkins", "HopkinsMedicine") # alias row, same handle
a = publishing.resolve_handles(conn, ["Johns Hopkins University"])
b = publishing.resolve_handles(conn, ["johns hopkins"])
assert a and b and a[0]["handle"] == b[0]["handle"] == "@HopkinsMedicine"
def test_handles_unknown_entity_is_not_guessed(conn):
assert publishing.resolve_handles(conn, ["Some Random Startup"]) == []
def test_normalization_does_not_collide_identity_words(conn):
# a handle stored for the SCHOOL must not get suggested for the STATE
publishing.add_entity_handle(conn, "University of California", "UCBerkeley")
assert publishing.resolve_handles(conn, ["California"]) == [] # no false match
got = publishing.resolve_handles(conn, ["University of California"])
assert got and got[0]["handle"] == "@UCBerkeley" # exact still resolves
def test_normalization_preserves_the_and_strips_only_trailing_legal(conn):
# "the" is never dropped, and legal suffixes only strip from the END
assert publishing.normalize_entity("The Who") == "the who" # not "who"
assert publishing.normalize_entity("Inc. Magazine") == "inc magazine" # leading legal kept
assert publishing.normalize_entity("Apple Inc") == "apple" # trailing legal stripped
# so "The Who" and "WHO" resolve to their OWN handles, no cross-match
publishing.add_entity_handle(conn, "The Who", "TheWho")
publishing.add_entity_handle(conn, "WHO", "WHO")
assert publishing.resolve_handles(conn, ["The Who"])[0]["handle"] == "@TheWho"
assert publishing.resolve_handles(conn, ["WHO"])[0]["handle"] == "@WHO"
def test_invalid_handles_are_rejected_not_stored(conn):
for bad in ("", "@", "not a handle", "https://x.com/NASA", "NASA!", "way_too_long_handle_x"):
assert publishing.valid_handle(bad) is None
assert publishing.add_entity_handle(conn, "Some Org", bad) is False
# good ones: tolerate one leading @, store canonical
assert publishing.valid_handle("@NASA") == "NASA"
assert publishing.add_entity_handle(conn, "NASA", "@NASA") is True
assert publishing.resolve_handles(conn, ["NASA"])[0]["handle"] == "@NASA"
# a junk source handle is never suggested either
assert publishing.resolve_handles(conn, [], source_handle="@bad handle!") == []
# --- eligibility ----------------------------------------------------------------
def test_eligibility_excludes_the_unfit(conn):
_src(c=conn, sid=1)
_article(conn, 1, 1) # eligible
_article(conn, 2, 1, accepted=0) # rejected
_article(conn, 3, 1, dup=1) # duplicate
_article(conn, 4, 1, complete=False) # no complete summary
_article(conn, 5, 1, hours_ago=24 * 10) # too old
_src(conn, 2, content_visible=0)
_article(conn, 6, 2) # source hidden
_src(conn, 3, paywall_override="paywalled")
_article(conn, 7, 3) # paywalled
conn.commit()
ids = {c["id"] for c in publishing.eligible_candidates(conn)}
assert ids == {1}
# --- build: deterministic fallback + top-up/dedup -------------------------------
def test_build_tops_up_to_target_and_dedups(conn):
_seed_n(conn, 6)
r1 = publishing.build_queue(conn, BASE, client=None, target=3)
assert r1["added"] == 3 and r1["ranked_by"] == "deterministic"
q = publishing.list_queue(conn)
assert len(q) == 3 and all(i["share_url"].startswith(BASE + "/a/") for i in q)
assert "utm_source=x" in q[0]["share_url"]
# rebuild at same target → already full → adds nothing (no duplicates)
assert publishing.build_queue(conn, BASE, client=None, target=3)["added"] == 0
# post one → a slot frees → next rebuild tops up with a NEW article, never the posted one
posted_id = q[0]["id"]; posted_article = q[0]["article_id"]
publishing.set_status(conn, posted_id, "posted")
r3 = publishing.build_queue(conn, BASE, client=None, target=3)
assert r3["added"] == 1
active_articles = {i["article_id"] for i in publishing.list_queue(conn)}
assert posted_article not in active_articles # posted never re-queued
def test_build_preserves_saved_draft_on_requeue(conn):
# a snoozed item that becomes eligible again must keep its draft text
_seed_n(conn, 1)
publishing.build_queue(conn, BASE, client=None, target=1)
sid = publishing.list_queue(conn)[0]["id"]
publishing.save_draft(conn, sid, "my carefully written blurb")
# force an EXPIRED snooze directly (set_status rightly refuses a past date)
conn.execute("UPDATE outbound_shares SET status='snoozed', snooze_until=? WHERE id=?", (_ts(1), sid))
conn.commit()
publishing.build_queue(conn, BASE, client=None, target=1) # re-queues it
row = conn.execute("SELECT status, draft_text FROM outbound_shares WHERE id=?", (sid,)).fetchone()
assert row["status"] == "queued" and row["draft_text"] == "my carefully written blurb"
# --- build: comparative LLM ranking + fallback ----------------------------------
def test_build_uses_llm_ranking_and_attaches_fields(conn):
_seed_n(conn, 3)
publishing.add_entity_handle(conn, "NASA", "NASA")
ranked = [
{"id": 3, "social_score": 9, "why": "wow", "talking_points": ["a", "b", "c"],
"angle": "ask a question", "entities": ["NASA"]},
{"id": 1, "social_score": 4, "why": "ok", "talking_points": [], "angle": "", "entities": []},
]
r = publishing.build_queue(conn, BASE, client=FakeClient(ranked), target=2)
assert r["ranked_by"] == "llm" and r["added"] == 2
q = publishing.list_queue(conn)
top = q[0]
assert top["article_id"] == 3 and top["social_score"] == 9 # LLM order wins
assert top["talking_points"] == ["a", "b", "c"] and top["angle"] == "ask a question"
assert any(h["handle"] == "@NASA" for h in top["suggested_handles"])
def test_build_falls_back_when_llm_errors(conn):
_seed_n(conn, 3)
r = publishing.build_queue(conn, BASE, client=BoomClient(), target=2)
assert r["ranked_by"] == "deterministic" and r["added"] == 2 # model down ≠ broken Desk
def test_deterministic_fallback_seeds_aids_but_leaves_score_and_angle_empty(conn):
# Codex Fix-1: with no LLM, the card still carries writing aids (rationale +
# talking points from the already-generated summary), but interest score and
# angle stay None on purpose — those are LLM-only judgments, never manufactured.
_seed_n(conn, 1)
publishing.build_queue(conn, BASE, client=None, target=1)
item = publishing.list_queue(conn)[0]
assert item["rationale"] == "Summary 1" # seeded from the summary
assert item["talking_points"] == ["wh", "wm", "wb"] # seeded from the explanation
assert item["social_score"] is None and item["angle"] is None # LLM-only, left empty
# --- adversarial: malformed LLM output ------------------------------------------
def test_duplicate_llm_ids_do_not_inflate_the_queue(conn):
# the model repeats id 1; only 2 real articles exist. added/active must reflect
# ACTUAL unique rows, never the inflated loop count Codex saw.
_seed_n(conn, 2)
ranked = [{"id": 1, "social_score": 9}, {"id": 1, "social_score": 9},
{"id": 1, "social_score": 9}, {"id": 2, "social_score": 5}]
r = publishing.build_queue(conn, BASE, client=FakeClient(ranked), target=5)
q = publishing.list_queue(conn)
assert r["added"] == len(q) == 2 # not 5, not 3
assert len({i["article_id"] for i in q}) == 2 # unique articles
def test_string_fields_do_not_become_char_arrays(conn):
# model returns strings where lists are expected; build must store [], not ['f','a'..]
_seed_n(conn, 1)
ranked = [{"id": 1, "social_score": 7, "talking_points": "fact", "entities": "NASA"}]
publishing.build_queue(conn, BASE, client=FakeClient(ranked), target=1)
item = publishing.list_queue(conn)[0]
assert item["talking_points"] == [] and item["entities"] == []
# --- lifecycle enforcement ------------------------------------------------------
def test_posted_is_terminal_and_cannot_be_requeued(conn):
_seed_n(conn, 1)
publishing.build_queue(conn, BASE, client=None, target=1)
sid = publishing.list_queue(conn)[0]["id"]
assert publishing.set_status(conn, sid, "posted") is True
assert publishing.set_status(conn, sid, "queued") is False # no resurrection
assert publishing.restore(conn, sid) is False # restore won't revive posted
assert conn.execute("SELECT status FROM outbound_shares WHERE id=?", (sid,)).fetchone()["status"] == "posted"
def test_late_autosave_is_rejected_after_terminal(conn):
# Codex Fix-2: a debounced autosave that lands AFTER the item is posted must
# not write to the terminal row (no clobbering what was actually published).
_seed_n(conn, 1)
publishing.build_queue(conn, BASE, client=None, target=1)
sid = publishing.list_queue(conn)[0]["id"]
assert publishing.save_draft(conn, sid, "draft while active") is True
publishing.set_status(conn, sid, "posted")
assert publishing.save_draft(conn, sid, "late autosave") is False # no-op on terminal
row = conn.execute("SELECT draft_text FROM outbound_shares WHERE id=?", (sid,)).fetchone()
assert row["draft_text"] == "draft while active" # the late write was ignored
def test_posted_rows_never_appear_in_queue_or_archived_tray(conn):
# Codex Fix-4: posted history is terminal and excluded everywhere the UI lists
# rows — neither the working queue nor the archived tray ever grows with it.
_seed_n(conn, 1)
publishing.build_queue(conn, BASE, client=None, target=1)
sid = publishing.list_queue(conn)[0]["id"]
publishing.set_status(conn, sid, "posted")
assert publishing.list_queue(conn) == [] # not in working queue
assert publishing.list_queue(conn, include_archived=True) == [] # not in archived tray
def test_snooze_requires_a_future_date(conn):
_seed_n(conn, 1)
publishing.build_queue(conn, BASE, client=None, target=1)
sid = publishing.list_queue(conn)[0]["id"]
assert publishing.set_status(conn, sid, "snoozed", snooze_until=None) is False # null
assert publishing.set_status(conn, sid, "snoozed", snooze_until=_ts(1)) is False # past
assert publishing.set_status(conn, sid, "snoozed", snooze_until=_ts(-48)) is True # future
# leaving snooze later (via restore) clears the date
publishing.restore(conn, sid)
assert conn.execute("SELECT snooze_until FROM outbound_shares WHERE id=?", (sid,)).fetchone()["snooze_until"] is None
# --- status transitions + restore + snooze --------------------------------------
def test_skip_is_reversible_and_snooze_is_separate(conn):
_seed_n(conn, 2)
publishing.build_queue(conn, BASE, client=None, target=2)
q = publishing.list_queue(conn)
a, b = q[0]["id"], q[1]["id"]
publishing.set_status(conn, a, "skipped")
assert a not in {i["id"] for i in publishing.list_queue(conn)} # gone from working queue
assert a in {i["id"] for i in publishing.list_queue(conn, include_archived=True)} # but in the tray
assert publishing.restore(conn, a) is True
assert a in {i["id"] for i in publishing.list_queue(conn)} # restored
# snooze: not in working queue, holds a snooze_until, restorable
publishing.set_status(conn, b, "snoozed", snooze_until=_ts(-24)) # 24h in the future
row = conn.execute("SELECT status, snooze_until FROM outbound_shares WHERE id=?", (b,)).fetchone()
assert row["status"] == "snoozed" and row["snooze_until"]
assert b not in {i["id"] for i in publishing.list_queue(conn)}
def test_inflight_build_does_not_clobber_a_freshly_extended_snooze(conn):
# Build snapshots eligibility, then the model ranks. If the user RE-SNOOZES to the
# future mid-rank, the finished build must NOT revive it (only EXPIRED snoozes revive).
_seed_n(conn, 1)
publishing.build_queue(conn, BASE, client=None, target=1)
sid = publishing.list_queue(conn)[0]["id"]
conn.execute("UPDATE outbound_shares SET status='snoozed', snooze_until=? WHERE id=?", (_ts(1), sid)) # expired → eligible
conn.commit()
future = _ts(-48) # 48h ahead
class RaceClient:
def rank_for_social(self, candidates):
# mid-build interleave: user extends the snooze into the future
conn.execute("UPDATE outbound_shares SET snooze_until=? WHERE id=?", (future, sid))
conn.commit()
return [{"id": 1, "social_score": 9}]
publishing.build_queue(conn, BASE, client=RaceClient(), target=1)
row = conn.execute("SELECT status, snooze_until FROM outbound_shares WHERE id=?", (sid,)).fetchone()
assert row["status"] == "snoozed" and row["snooze_until"] == future # left alone, not re-queued
+118
View File
@@ -0,0 +1,118 @@
"""Publishing Desk Phase 1 — admin API: gating, background build (deterministic
fallback), lifecycle enforcement, snooze validation, draft preservation, restore."""
from datetime import datetime, timedelta, timezone
import pytest
from fastapi.testclient import TestClient
from goodnews.db import connect, init_db
def _future(hours: int = 24) -> str:
return (datetime.now(timezone.utc) + timedelta(hours=hours)).strftime("%Y-%m-%d %H:%M:%S")
def _recent() -> str:
return (datetime.now(timezone.utc) - timedelta(hours=1)).strftime("%Y-%m-%d %H:%M:%S")
@pytest.fixture
def api_app(tmp_path, monkeypatch):
db = tmp_path / "t.sqlite3"
monkeypatch.setenv("GOODNEWS_DB", str(db))
# http (not https) so the session cookie isn't Secure-only — TestClient runs over http
monkeypatch.setenv("GOODNEWS_PUBLIC_BASE_URL", "http://testserver")
monkeypatch.setenv("GOODNEWS_ADMIN_EMAILS", "admin@b.com")
monkeypatch.setenv("GOODNEWS_LLM_BASE_URL", "http://127.0.0.1:9") # dead → deterministic fallback, fast
import importlib
import goodnews.api as api
importlib.reload(api)
c = connect(str(db)); init_db(c)
# one eligible article (accepted, visible, complete summary, recent, readable)
c.execute("INSERT INTO sources (id,name,feed_url,trust_score,content_visible) VALUES (1,'S','http://s/f',5,1)")
c.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash,image_url,published_at) "
"VALUES (1,1,'https://ex.com/a','Title','h1','http://img/x.jpg',?)", (_recent(),))
c.execute("INSERT INTO article_scores (article_id,accepted,novelty_score,constructive_score,topic,reason_code) "
"VALUES (1,1,7,7,'science','ok')")
c.execute("INSERT INTO article_summaries (article_id,summary,what_happened,why_matters,why_belongs) "
"VALUES (1,'Sum','wh','wm','wb')")
c.commit(); c.close()
return api.create_app()
def _admin(app):
tc = TestClient(app)
sent = {}
import goodnews.email_send as es
orig = es.send_magic_link
es.send_magic_link = lambda to, link: sent.update(link=link)
try:
tc.post("/api/auth/email/start", json={"email": "admin@b.com"})
tc.post("/api/auth/email/verify", json={"token": sent["link"].split("token=")[1]})
finally:
es.send_magic_link = orig
return tc
def test_admin_gating(api_app):
anon = TestClient(api_app)
assert anon.get("/api/admin/publishing/queue").status_code == 401
assert anon.post("/api/admin/publishing/build").status_code == 401
def test_build_then_queue_deterministic(api_app):
tc = _admin(api_app)
assert tc.post("/api/admin/publishing/build").json() == {"building": True}
# TestClient runs the background task before returning; LLM URL is dead → fallback.
q = tc.get("/api/admin/publishing/queue").json()
assert q["building"] is False and q["last"]["ranked_by"] == "deterministic"
assert len(q["items"]) == 1 and q["items"][0]["article_id"] == 1
assert "utm_source=x" in q["items"][0]["share_url"]
# a second build is a no-op (already full) — never duplicates
tc.post("/api/admin/publishing/build")
assert len(tc.get("/api/admin/publishing/queue").json()["items"]) == 1
def _one(tc):
tc.post("/api/admin/publishing/build")
return tc.get("/api/admin/publishing/queue").json()["items"][0]["id"]
def test_invalid_transition_rejected(api_app):
tc = _admin(api_app)
sid = _one(tc)
assert tc.post(f"/api/admin/publishing/{sid}/status", json={"status": "posted"}).status_code == 200
# posted is terminal — resurrection refused
assert tc.post(f"/api/admin/publishing/{sid}/status", json={"status": "queued"}).status_code == 400
def test_snooze_validation(api_app):
tc = _admin(api_app)
sid = _one(tc)
assert tc.post(f"/api/admin/publishing/{sid}/status", json={"status": "snoozed"}).status_code == 400 # null
assert tc.post(f"/api/admin/publishing/{sid}/status",
json={"status": "snoozed", "snooze_until": "2000-01-01 00:00:00"}).status_code == 400 # past
assert tc.post(f"/api/admin/publishing/{sid}/status",
json={"status": "snoozed", "snooze_until": _future()}).status_code == 200
def test_draft_preserved_through_skip_and_restore(api_app):
tc = _admin(api_app)
sid = _one(tc)
assert tc.post(f"/api/admin/publishing/{sid}/draft", json={"draft_text": "my blurb"}).status_code == 200
assert tc.post(f"/api/admin/publishing/{sid}/status", json={"status": "skipped"}).status_code == 200
assert sid not in {i["id"] for i in tc.get("/api/admin/publishing/queue").json()["items"]} # left the queue
assert tc.post(f"/api/admin/publishing/{sid}/restore").status_code == 200
items = tc.get("/api/admin/publishing/queue").json()["items"]
back = next(i for i in items if i["id"] == sid)
assert back["draft_text"] == "my blurb" # work survived skip→restore
def test_save_handle_validates(api_app):
tc = _admin(api_app)
assert tc.post("/api/admin/publishing/handles",
json={"entity_name": "NASA", "handle": "@not a handle"}).status_code == 400
assert tc.post("/api/admin/publishing/handles",
json={"entity_name": "NASA", "handle": "https://x.com/NASA"}).status_code == 400
assert tc.post("/api/admin/publishing/handles",
json={"entity_name": "NASA", "handle": "@NASA"}).status_code == 200
+58 -1
View File
@@ -42,9 +42,66 @@ def test_share_page_missing_and_malformed(client):
assert tc.get("/a/999").status_code == 404 # unknown
assert tc.get("/a/not-a-number").status_code == 404 # malformed → calm 404
assert tc.get("/a/2").status_code == 404 # rejected article
assert tc.get("/a/3").status_code == 404 # duplicate
def test_share_page_duplicate_redirects_to_canonical(client):
# article 3 is a duplicate of the live article 1 — its URL may be indexed, so it
# 301s to the canonical (consolidates) rather than 404ing and dropping from Google.
r = TestClient(client).get("/a/3", follow_redirects=False)
assert r.status_code == 301 and r.headers["location"] == "/a/1"
def test_share_page_creates_visitor_token_for_beacons(client):
# /a/ is server-rendered outside the SPA, so its inline analytics must CREATE the
# visitor token when absent (mirroring visitorId) — else a first-time /a/ landing
# beacons an empty token and is dropped from visitor stats.
html = TestClient(client).get("/a/1").text
assert "localStorage.setItem('goodnews:visitor'" in html
assert "crypto.randomUUID" in html
assert "kind:'visit'" in html # daily visit beacon present
def test_share_page_no_image_uses_summary_card(client, tmp_path, monkeypatch):
# article 1 has an image → large card
assert 'summary_large_image' in TestClient(client).get("/a/1").text
def test_incomplete_page_is_not_cached(client):
# article 1 has no summary/explanation → "generating" page must not be cached,
# and carries no-cache so it re-fetches once the summary lands.
import goodnews.api as api
r = TestClient(client).get("/a/1")
assert r.status_code == 200
assert r.headers.get("cache-control") == "no-cache"
assert 1 not in api._SHARE_CACHE
@pytest.fixture
def app_complete(tmp_path, monkeypatch):
db = tmp_path / "t.sqlite3"
monkeypatch.setenv("GOODNEWS_DB", str(db))
monkeypatch.setenv("GOODNEWS_PUBLIC_BASE_URL", "https://upbeatbytes.com")
import importlib
import goodnews.api as api
importlib.reload(api)
from goodnews.db import connect, init_db
c = connect(str(db)); init_db(c)
c.execute("INSERT INTO sources (id,name,feed_url,trust_score) VALUES (1,'BBC','http://s/f',5)")
c.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash,image_url) "
"VALUES (1,1,'https://bbc.com/x','Water voles return','h1','https://img/v.jpg')")
c.execute("INSERT INTO article_scores (article_id,accepted,reason_text) VALUES (1,1,'Hopeful.')")
c.execute("INSERT INTO article_summaries (article_id,summary,what_happened,why_matters,why_belongs) "
"VALUES (1,'Voles are back.','They returned to the river.','Biodiversity rebound.','Quietly hopeful.')")
c.commit(); c.close()
return api
def test_complete_page_is_cached_and_served_from_cache(app_complete):
api = app_complete
tc = TestClient(api.create_app())
r1 = tc.get("/a/1")
assert r1.status_code == 200
assert r1.headers.get("cache-control") == "public, max-age=300"
assert 1 in api._SHARE_CACHE # finished page cached
r2 = tc.get("/a/1") # second hit served from cache
assert r2.status_code == 200 and r2.text == r1.text
+79
View File
@@ -0,0 +1,79 @@
"""Share page /a/{id}: a duplicate article 301-redirects to its canonical twin
instead of 404ing. A hard 404 silently drops already-indexed URLs from Google and
tanked impressions when a newer duplicate retired an older, indexed page."""
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def client(tmp_path, monkeypatch):
db = tmp_path / "t.sqlite3"
monkeypatch.setenv("GOODNEWS_DB", str(db))
monkeypatch.setenv("GOODNEWS_PUBLIC_BASE_URL", "https://upbeatbytes.com")
import importlib
import goodnews.api as api
importlib.reload(api)
from goodnews.db import connect, init_db
c = connect(str(db)); init_db(c)
c.execute("INSERT INTO sources (id,name,feed_url,trust_score) VALUES (1,'BBC','http://s/f',5)")
def art(aid, *, accepted=1, dup=None, summary=True):
c.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash,published_at) "
"VALUES (?,1,?,?,?,'2026-06-05T08:00:00')",
(aid, f"https://bbc.com/{aid}", f"Story {aid}", f"h{aid}"))
if dup is not None:
c.execute("UPDATE articles SET duplicate_of=? WHERE id=?", (dup, aid))
c.execute("INSERT INTO article_scores (article_id,accepted,reason_text) VALUES (?,?,'x')", (aid, accepted))
if summary:
c.execute("INSERT INTO article_summaries (article_id,summary) VALUES (?,?)", (aid, f"Summary {aid}."))
art(10) # canonical, live -> 200
art(11, dup=10) # duplicate of a live canonical -> 301 /a/10
art(12, accepted=0, dup=10) # REJECTED follower of an accepted rep -> still 301 /a/10
art(20, accepted=0) # rejected canonical
art(21, dup=20) # dup of a REJECTED canonical -> 404 (genuinely gone)
art(30, accepted=0) # rejected, not a duplicate -> 404
c.commit(); c.close()
return api.create_app()
def test_canonical_serves_200(client):
r = TestClient(client).get("/a/10")
assert r.status_code == 200 and "Story 10" in r.text
def test_duplicate_301s_to_canonical(client):
r = TestClient(client).get("/a/11", follow_redirects=False)
assert r.status_code == 301
assert r.headers["location"] == "/a/10" # consolidates onto the survivor
def test_rejected_follower_of_accepted_rep_still_301s(client):
# Policy: the route resolves duplicate_of BEFORE the follower's own acceptance, so a
# rejected article that points at an ACCEPTED representative 301s to it rather than
# 404ing. That's intentional — it sends the visitor/crawler to a serveable equivalent.
r = TestClient(client).get("/a/12", follow_redirects=False)
assert r.status_code == 301 and r.headers["location"] == "/a/10"
def test_duplicate_of_rejected_canonical_404s(client):
r = TestClient(client).get("/a/21", follow_redirects=False)
assert r.status_code == 404 # nothing serveable to redirect to
def test_rejected_article_404s(client):
assert TestClient(client).get("/a/30").status_code == 404
def test_missing_article_404s(client):
assert TestClient(client).get("/a/9999").status_code == 404
def test_head_matches_get_status(client):
# HEAD must return the same status as GET (not fall through to the static mount and
# 404). Some crawlers/link-checkers probe with HEAD.
tc = TestClient(client)
assert tc.head("/a/10").status_code == 200 # canonical
r = tc.head("/a/11", follow_redirects=False)
assert r.status_code == 301 and r.headers["location"] == "/a/10" # duplicate
assert tc.head("/a/9999").status_code == 404 # missing
+78
View File
@@ -0,0 +1,78 @@
"""Locks the word-search placement qualities players actually feel:
1. Every word gets placed (exhaustive candidate search nothing silently dropped).
2. Grids INTERLOCK like a real puzzle (the "clean isolated words" regression).
3. Words SPREAD across the board (the "all clumped in one corner" regression).
4. Same date/seed same grid (cross-device players must see identical puzzles).
Thresholds were calibrated against all curated themes × 12 seeds × 3 tiers
(288 grids/tier): crossing fraction averaged ~0.7 (old algorithm: ~0.3, with a
third of small grids having ZERO crossings), worst quadrant share 0.42, and all
four quadrants always held word cells. Deterministic, so no flake margin needed.
"""
import random
import statistics
from goodnews.games import _WS_FALLBACKS, WS_TIERS, _WS_ORDER, _build_grid, _place_words, _zone
def _tier_grids(tier):
"""Yield (placements, size) for every curated theme × 12 seeds in a tier."""
t = WS_TIERS[tier]
for _, words in _WS_FALLBACKS:
for seed in range(12):
rng = random.Random(seed * 1000 + 7)
ws = list(words)
rng.shuffle(ws)
_, placements = _place_words(ws[: t["count"]], t["grid"], seed)
yield placements, t["grid"]
def _cross_fraction(placements):
"""Fraction of placed words sharing at least one cell with another word."""
owners: dict[tuple[int, int], list[str]] = {}
for w, cells in placements:
for cell in cells:
owners.setdefault(cell, []).append(w)
crossing = set()
for ws in owners.values():
if len(ws) > 1:
crossing.update(ws)
return len(crossing) / len(placements)
def test_all_words_placed():
for tier in _WS_ORDER:
for placements, _ in _tier_grids(tier):
assert len(placements) == WS_TIERS[tier]["count"]
def test_grids_interlock_without_clumping():
for tier in _WS_ORDER:
fracs = []
for placements, size in _tier_grids(tier):
fracs.append(_cross_fraction(placements))
# Spread: word cells must reach all four quadrants, and no quadrant
# may hoard more than half of them (perfectly even would be 0.25).
quad: dict[tuple[int, int], int] = {}
cells = {c for _, cs in placements for c in cs}
for r, c in cells:
quad[_zone(r, c, size)] = quad.get(_zone(r, c, size), 0) + 1
assert len(quad) == 4, f"{tier}: words confined to {len(quad)} quadrant(s)"
assert max(quad.values()) / len(cells) <= 0.5, f"{tier}: clumped in one quadrant"
# Interlock: every grid has some crossings; on average most words connect.
assert min(fracs) >= 0.3, f"{tier}: a grid came out as disconnected clean words"
assert 0.55 <= statistics.mean(fracs) <= 0.9, f"{tier}: avg crossing {statistics.mean(fracs):.2f}"
def test_grid_deterministic_and_honest():
"""Same inputs → byte-identical grid, and every reported word is really in it
(forward or reversed along some line spot-checked via placements)."""
words = _WS_FALLBACKS[0][1][:9]
rows1, placed1 = _build_grid(words, 11, 42)
rows2, placed2 = _build_grid(words, 11, 42)
assert rows1 == rows2 and placed1 == placed2
_, placements = _place_words(words, 11, 42)
for word, cells in placements:
assert "".join(rows1[r][c] for r, c in cells) == word
+47
View File
@@ -0,0 +1,47 @@
// Drill into the head/mouth region. Front = +Z. Looks at body vertices in the
// front quarter and reports the vertical (Y) profile from nose back, plus the
// lowest points near the mouth — to test "lower lip sits below the body".
import { NodeIO } from '@gltf-transform/core';
import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
const doc = await io.read(process.argv[2]);
const root = doc.getRoot();
const joints = root.listSkins()[0].listJoints();
const isBody = joints.map((n) => n.getName().startsWith('Spine'));
const mesh = root.listMeshes()[0];
const prim = mesh.listPrimitives().reduce((a, b) => (b.getIndices().getCount() > a.getIndices().getCount() ? b : a));
const pos = prim.getAttribute('POSITION');
const jA = prim.getAttribute('JOINTS_0'), wA = prim.getAttribute('WEIGHTS_0');
const n = pos.getCount();
const p = [0, 0, 0], j = [0, 0, 0, 0], w = [0, 0, 0, 0];
// bucket body vertices by Z slab from nose (+Z) backward; track Y min/max per slab
const slabs = {};
let bodyMinY = 1e9, bodyMaxY = -1e9;
const front = []; // {x,y,z} of front-most body verts
for (let i = 0; i < n; i++) {
jA.getElement(i, j); wA.getElement(i, w);
let best = 0, bw = -1; for (let k = 0; k < 4; k++) if (w[k] > bw) { bw = w[k]; best = j[k]; }
if (!isBody[best]) continue;
pos.getElement(i, p);
bodyMinY = Math.min(bodyMinY, p[1]); bodyMaxY = Math.max(bodyMaxY, p[1]);
const slab = Math.round(p[2] * 10) / 10;
const e = slabs[slab] || (slabs[slab] = { c: 0, ymin: 1e9, ymax: -1e9 });
e.c++; e.ymin = Math.min(e.ymin, p[1]); e.ymax = Math.max(e.ymax, p[1]);
if (p[2] > 0.30) front.push([p[0], p[1], p[2]]);
}
console.log(`body Y range overall: ${bodyMinY.toFixed(3)} .. ${bodyMaxY.toFixed(3)}\n`);
console.log('Z slab (nose=+Z → tail) bodyY min..max (height)');
for (const z of Object.keys(slabs).map(Number).sort((a, b) => b - a)) {
const e = slabs[z];
console.log(` z=${z.toFixed(1).padStart(5)} n=${String(e.c).padStart(4)} Y ${e.ymin.toFixed(3)} .. ${e.ymax.toFixed(3)} (${(e.ymax - e.ymin).toFixed(3)})`);
}
// the lowest front verts (potential lower lip / jaw)
front.sort((a, b) => a[1] - b[1]);
console.log('\nlowest 6 front (z>0.30) body verts [x, y, z]:');
for (const v of front.slice(0, 6)) console.log(' ', v.map((x) => x.toFixed(3).padStart(7)).join(', '));
console.log('frontmost 6 (max z) body verts [x, y, z]:');
front.sort((a, b) => b[2] - a[2]);
for (const v of front.slice(0, 6)) console.log(' ', v.map((x) => x.toFixed(3).padStart(7)).join(', '));
+60
View File
@@ -0,0 +1,60 @@
// Independent structural audit of UB. Groups every vertex by its dominant bone,
// then reports each anatomical group's vertex count, centroid, and bounding box in
// model space. Reveals off-midline parts (dorsal), mis-located parts (lip), and
// which primitive/material each region landed in after the split.
import { NodeIO } from '@gltf-transform/core';
import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
const doc = await io.read(process.argv[2]);
const root = doc.getRoot();
const joints = root.listSkins()[0].listJoints();
const groupOf = (name) => {
if (name.startsWith('Spine')) return 'body';
if (name.startsWith('Dorsal')) return 'dorsal';
if (name.startsWith('Pec_Fin_Left')) return 'pec_L';
if (name.startsWith('Pec_Fin_Right')) return 'pec_R';
if (name.startsWith('Left_Rear_Belly')) return 'belly_L';
if (name.startsWith('Right_Rear_Belly')) return 'belly_R';
if (name.startsWith('Rear_Center_Fin')) return 'anal';
if (name.startsWith('Tail_Fork')) return 'tail';
if (name.startsWith('Joint')) return 'joint(tail-ray?)';
return `?:${name}`;
};
const jointGroup = joints.map((n) => groupOf(n.getName()));
const mesh = root.listMeshes()[0];
const prim = mesh.listPrimitives().reduce((a, b) => (b.getIndices().getCount() > a.getIndices().getCount() ? b : a));
const pos = prim.getAttribute('POSITION');
const jA = prim.getAttribute('JOINTS_0');
const wA = prim.getAttribute('WEIGHTS_0');
const n = pos.getCount();
const G = {};
const p = [0, 0, 0], j = [0, 0, 0, 0], w = [0, 0, 0, 0];
for (let i = 0; i < n; i++) {
jA.getElement(i, j); wA.getElement(i, w);
let best = 0, bestW = -1;
for (let k = 0; k < 4; k++) if (w[k] > bestW) { bestW = w[k]; best = j[k]; }
const g = jointGroup[best];
pos.getElement(i, p);
const e = G[g] || (G[g] = { c: 0, sum: [0, 0, 0], min: [1e9, 1e9, 1e9], max: [-1e9, -1e9, -1e9] });
e.c++;
for (let d = 0; d < 3; d++) { e.sum[d] += p[d]; e.min[d] = Math.min(e.min[d], p[d]); e.max[d] = Math.max(e.max[d], p[d]); }
}
const f = (x) => x.toFixed(3).padStart(8);
console.log('axes: X Y Z (model space)\n');
for (const [g, e] of Object.entries(G).sort((a, b) => b[1].c - a[1].c)) {
const ctr = e.sum.map((s) => s / e.c);
const sz = e.max.map((m, d) => m - e.min[d]);
console.log(`${g.padEnd(16)} n=${String(e.c).padStart(5)} centroid[${ctr.map(f).join(',')}] size[${sz.map(f).join(',')}]`);
}
// whole-model bbox to know which axis is length/height/depth
const bb = Object.values(G).reduce((acc, e) => {
for (let d = 0; d < 3; d++) { acc.min[d] = Math.min(acc.min[d], e.min[d]); acc.max[d] = Math.max(acc.max[d], e.max[d]); }
return acc;
}, { min: [1e9, 1e9, 1e9], max: [-1e9, -1e9, -1e9] });
console.log(`\nwhole bbox min[${bb.min.map(f).join(',')}] max[${bb.max.map(f).join(',')}] size[${bb.max.map((m, d) => f(m - bb.min[d])).join(',')}]`);
+51
View File
@@ -0,0 +1,51 @@
// Measure the two tail lobes separately. For each of Tail_Fork_Top and
// Tail_Fork_Bottom (subtree), report the vertex centroid + bbox. If the lobes are
// separated in Y → a normal vertical fork (upper/lower). If separated in X →
// a genuine LEFT/RIGHT double tail (two tails side by side), which is what reads
// as "two tails" and is a geometry problem, not a render one.
import { NodeIO } from '@gltf-transform/core';
import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
const doc = await io.read(process.argv[2]);
const root = doc.getRoot();
const joints = root.listSkins()[0].listJoints();
const idxOf = new Map(joints.map((n, i) => [n, i]));
const subtree = (rootName) => {
const set = new Set();
const start = joints.find((n) => n.getName() === rootName);
if (!start) return set;
const mark = (n) => { if (idxOf.has(n)) set.add(idxOf.get(n)); n.listChildren().forEach(mark); };
mark(start);
return set;
};
const topSet = subtree('Tail_Fork_Top');
const botSet = subtree('Tail_Fork_Bottom');
console.log(`Top subtree joints: ${topSet.size}, Bottom subtree joints: ${botSet.size}`);
const mesh = root.listMeshes()[0];
const prim = mesh.listPrimitives().reduce((a, b) => (b.getIndices().getCount() > a.getIndices().getCount() ? b : a));
const pos = prim.getAttribute('POSITION');
const jA = prim.getAttribute('JOINTS_0'), wA = prim.getAttribute('WEIGHTS_0');
const n = pos.getCount();
const acc = { top: mk(), bot: mk() };
function mk() { return { c: 0, sum: [0, 0, 0], min: [1e9, 1e9, 1e9], max: [-1e9, -1e9, -1e9] }; }
function add(e, p) { e.c++; for (let d = 0; d < 3; d++) { e.sum[d] += p[d]; e.min[d] = Math.min(e.min[d], p[d]); e.max[d] = Math.max(e.max[d], p[d]); } }
const p = [0, 0, 0], j = [0, 0, 0, 0], w = [0, 0, 0, 0];
for (let i = 0; i < n; i++) {
jA.getElement(i, j); wA.getElement(i, w);
let top = 0, bot = 0;
for (let k = 0; k < 4; k++) { if (topSet.has(j[k])) top += w[k]; if (botSet.has(j[k])) bot += w[k]; }
if (top < 0.5 && bot < 0.5) continue;
pos.getElement(i, p);
add(top >= bot ? acc.top : acc.bot, p);
}
const f = (x) => x.toFixed(3).padStart(8);
for (const [k, e] of Object.entries(acc)) {
if (!e.c) { console.log(`${k}: (no verts)`); continue; }
const ctr = e.sum.map((s) => s / e.c);
console.log(`${k.padEnd(4)} n=${String(e.c).padStart(4)} centroid[${ctr.map(f).join(',')}] X:[${f(e.min[0])},${f(e.max[0])}] Y:[${f(e.min[1])},${f(e.max[1])}] Z:[${f(e.min[2])},${f(e.max[2])}]`);
}
+33
View File
@@ -0,0 +1,33 @@
import { NodeIO } from '@gltf-transform/core';
import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
const doc = await io.read(process.argv[2]);
const skin = doc.getRoot().listSkins()[0];
const joints = skin.listJoints();
const tailRoots = joints.filter((n) => /^Tail_Fork_(Top|Bottom)$/.test(n.getName()));
const tailSet = new Set();
const mark = (n) => { tailSet.add(n); n.listChildren().forEach(mark); };
tailRoots.forEach(mark);
const tailJoints = joints.filter((n) => tailSet.has(n));
console.log(`tail roots: ${tailRoots.map((n) => n.getName()).join(', ')}`);
console.log(`tail subtree joints (${tailJoints.length}): ${tailJoints.map((n) => n.getName()).join(', ')}`);
// classify vertices into body / tail / otherfin and count triangles per group
const isFin = joints.map((n) => !n.getName().startsWith('Spine'));
const isTail = joints.map((n) => tailSet.has(n));
const mesh = doc.getRoot().listMeshes()[0];
const prim = mesh.listPrimitives().reduce((a, b) => (b.getIndices().getCount() > a.getIndices().getCount() ? b : a));
const jA = prim.getAttribute('JOINTS_0'), wA = prim.getAttribute('WEIGHTS_0');
const n = prim.getAttribute('POSITION').getCount();
const vFin = new Uint8Array(n), vTail = new Uint8Array(n);
const j = [0, 0, 0, 0], w = [0, 0, 0, 0];
for (let i = 0; i < n; i++) {
jA.getElement(i, j); wA.getElement(i, w);
let f = 0, t = 0, tot = 0;
for (let k = 0; k < 4; k++) { tot += w[k]; if (isFin[j[k]]) f += w[k]; if (isTail[j[k]]) t += w[k]; }
vFin[i] = tot > 0 && f / tot >= 0.5 ? 1 : 0;
vTail[i] = tot > 0 && t / tot >= 0.5 ? 1 : 0;
}
console.log(`tail vertices: ${vTail.reduce((a, b) => a + b, 0)} / ${n}`);
+46
View File
@@ -0,0 +1,46 @@
import { NodeIO } from '@gltf-transform/core';
import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
const input = process.argv[2];
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
const doc = await io.read(input);
const root = doc.getRoot();
const skin = root.listSkins()[0];
const joints = skin.listJoints();
const isFinJoint = joints.map((n) => !n.getName().startsWith('Spine'));
console.log(`skin joints: ${joints.length} (fin joints: ${isFinJoint.filter(Boolean).length}, body/Spine: ${isFinJoint.filter((x) => !x).length})`);
const mesh = root.listMeshes()[0];
const prims = mesh.listPrimitives();
console.log(`meshes: ${root.listMeshes().length}, primitives: ${prims.length}`);
const prim = prims[0];
const pos = prim.getAttribute('POSITION');
const jA = prim.getAttribute('JOINTS_0');
const wA = prim.getAttribute('WEIGHTS_0');
const idx = prim.getIndices();
console.log(`semantics: ${prim.listSemantics().join(', ')}`);
console.log(`vertices: ${pos.getCount()}, indices: ${idx.getCount()} (${idx.getCount() / 3} tris)`);
const vcount = pos.getCount();
const vIsFin = new Uint8Array(vcount);
const j = [0, 0, 0, 0], w = [0, 0, 0, 0];
for (let i = 0; i < vcount; i++) {
jA.getElement(i, j);
wA.getElement(i, w);
let fin = 0, tot = 0;
for (let k = 0; k < 4; k++) { tot += w[k]; if (isFinJoint[j[k]]) fin += w[k]; }
vIsFin[i] = tot > 0 && fin / tot >= 0.5 ? 1 : 0;
}
let finV = 0;
for (let i = 0; i < vcount; i++) finV += vIsFin[i];
const arr = idx.getArray();
let bodyT = 0, finT = 0, mixed = 0;
for (let t = 0; t < arr.length; t += 3) {
const votes = vIsFin[arr[t]] + vIsFin[arr[t + 1]] + vIsFin[arr[t + 2]];
if (votes >= 2) finT++; else bodyT++;
if (votes === 1 || votes === 2) mixed++;
}
console.log(`fin vertices: ${finV}/${vcount} (${(100 * finV / vcount).toFixed(1)}%)`);
console.log(`triangles → body: ${bodyT}, fin: ${finT}, (boundary/mixed tris: ${mixed})`);
+47
View File
@@ -0,0 +1,47 @@
{
"name": "glb-split",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "glb-split",
"dependencies": {
"@gltf-transform/core": "^4.4.0",
"@gltf-transform/extensions": "^4.4.0"
}
},
"node_modules/@gltf-transform/core": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/@gltf-transform/core/-/core-4.4.0.tgz",
"integrity": "sha512-cOPxOhHFFz5hwmix+li1+Nnq5qMV/QD3fTCsVlApxxFACtFdjkt2R/juseD4gvZ7D2c/yl6OilKH0pvI735YyQ==",
"dependencies": {
"property-graph": "^4.1.0"
},
"funding": {
"url": "https://github.com/sponsors/donmccurdy"
}
},
"node_modules/@gltf-transform/extensions": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/@gltf-transform/extensions/-/extensions-4.4.0.tgz",
"integrity": "sha512-ZwEgFkkqnUR7d4m6roK9BycxxdoqJNtVyo7w5ShJ9syKBoQiXw2QrTSLwXaUAImSrEIl9Jh/wZTtvSVyviQuXg==",
"dependencies": {
"@gltf-transform/core": "^4.4.0",
"ktx-parse": "^1.1.0"
},
"funding": {
"url": "https://github.com/sponsors/donmccurdy"
}
},
"node_modules/ktx-parse": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/ktx-parse/-/ktx-parse-1.1.0.tgz",
"integrity": "sha512-mKp3y+FaYgR7mXWAbyyzpa/r1zDWeaunH+INJO4fou3hb45XuNSwar+7llrRyvpMWafxSIi99RNFJ05MHedaJQ=="
},
"node_modules/property-graph": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/property-graph/-/property-graph-4.1.0.tgz",
"integrity": "sha512-AvPcP7XECNWy4LGmFQ77k7un4lSKM4eS29PTvW4ck95uYeLxXPWJM7hLuBqK91FaHqCcgJvIUCuNJjjxKE7VKQ=="
}
}
}
+1
View File
@@ -0,0 +1 @@
{"name":"glb-split","private":true,"type":"module","dependencies":{"@gltf-transform/core":"^4.4.0","@gltf-transform/extensions":"^4.4.0"}}
+60
View File
@@ -0,0 +1,60 @@
// Render UB's silhouette as ASCII through the ACTUAL camera + transform, so we can
// literally see the tail's on-screen shape. Body='#', side fins='f', tail='T'.
import { NodeIO } from '@gltf-transform/core';
import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
const yaw = parseFloat(process.argv[3] ?? (Math.PI / 2 + 0.10));
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
const doc = await io.read(process.argv[2]);
const root = doc.getRoot();
const joints = root.listSkins()[0].listJoints();
const tailSet = new Set();
const mark = (n) => { tailSet.add(n); n.listChildren().forEach(mark); };
joints.filter((n) => /^Tail_Fork_(Top|Bottom)$/.test(n.getName())).forEach(mark);
const grp = joints.map((n) => tailSet.has(n) ? 'T' : (n.getName().startsWith('Spine') ? '#' : 'f'));
const mesh = root.listMeshes()[0];
const prim = mesh.listPrimitives().reduce((a, b) => (b.getIndices().getCount() > a.getIndices().getCount() ? b : a));
const pos = prim.getAttribute('POSITION'), jA = prim.getAttribute('JOINTS_0'), wA = prim.getAttribute('WEIGHTS_0');
const N = pos.getCount();
// match aquarium.js: center, scale, rotate yaw about Y, position = -center
const box = { min: [1e9, 1e9, 1e9], max: [-1e9, -1e9, -1e9] };
const p = [0, 0, 0];
for (let i = 0; i < N; i++) { pos.getElement(i, p); for (let d = 0; d < 3; d++) { box.min[d] = Math.min(box.min[d], p[d]); box.max[d] = Math.max(box.max[d], p[d]); } }
const center = box.min.map((m, d) => (m + box.max[d]) / 2);
const s = 2.6 / Math.max(...box.max.map((m, d) => m - box.min[d]));
const cy = Math.cos(yaw), sy = Math.sin(yaw);
// camera lookAt
const cam = [0, 0.25, 4.6], tgt = [0, 0, 0];
const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
const norm = (a) => { const l = Math.hypot(...a); return [a[0] / l, a[1] / l, a[2] / l]; };
const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
const f = norm(sub(tgt, cam)), r = norm(cross(f, [0, 1, 0])), u = cross(r, f);
const aspect = 4 / 3, t = Math.tan((42 * Math.PI / 180) / 2);
const COLS = 96, ROWS = 40;
const grid = Array.from({ length: ROWS }, () => Array(COLS).fill(' '));
const zbuf = Array.from({ length: ROWS }, () => Array(COLS).fill(1e9));
const j = [0, 0, 0, 0], w = [0, 0, 0, 0];
for (let i = 0; i < N; i++) {
jA.getElement(i, j); wA.getElement(i, w);
let best = 0, bw = -1; for (let k = 0; k < 4; k++) if (w[k] > bw) { bw = w[k]; best = j[k]; }
const g = grp[best];
pos.getElement(i, p);
// scale, rotate Y, translate by -center
const x = p[0] * s, y = p[1] * s, z = p[2] * s;
const wx = (cy * x + sy * z) - center[0];
const wy = y - center[1];
const wz = (-sy * x + cy * z) - center[2];
const rel = [wx - cam[0], wy - cam[1], wz - cam[2]];
const vz = dot(rel, f); if (vz <= 0.01) continue;
const ndcX = dot(rel, r) / (vz * t * aspect), ndcY = dot(rel, u) / (vz * t);
const col = Math.round((ndcX + 1) / 2 * (COLS - 1)), row = Math.round((1 - (ndcY + 1) / 2) * (ROWS - 1));
if (col < 0 || col >= COLS || row < 0 || row >= ROWS) continue;
if (vz < zbuf[row][col]) { zbuf[row][col] = vz; grid[row][col] = g; }
}
console.log(`yaw=${yaw.toFixed(3)} (# body, f fins, T tail)`);
console.log(grid.map((r) => r.join('')).join('\n'));
+81
View File
@@ -0,0 +1,81 @@
// Split UB (single skinned primitive, one BLEND/double-sided material) into THREE
// primitives — opaque body+eyes, translucent side fins, and the translucent tail —
// sharing the SAME vertex data, skeleton, and animations. Classification is by
// skin weight: a vertex is "fin" when the majority of its weight rides non-Spine
// bones, and "tail" when the majority rides the Tail_Fork subtree (the forked
// caudal fin + its ray joints). Triangles vote 2-of-3. The tail gets its own
// material so it can render SINGLE-sided — a thin double-sided translucent fan
// shows its front and back faces at once and they bleed through each other while
// it sweeps, which reads as "two tails." All three primitives reference the same
// attribute accessors (no vertex duplication); only index buffer + material differ.
import { NodeIO } from '@gltf-transform/core';
import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
const [input, output] = process.argv.slice(2);
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
const doc = await io.read(input);
const root = doc.getRoot();
const skin = root.listSkins()[0];
const joints = skin.listJoints();
const isFinJoint = joints.map((n) => !n.getName().startsWith('Spine'));
// Tail = the Tail_Fork_Top/Bottom subtree (fork bones + their fin-ray joints).
const tailSet = new Set();
const mark = (n) => { tailSet.add(n); n.listChildren().forEach(mark); };
joints.filter((n) => /^Tail_Fork_(Top|Bottom)$/.test(n.getName())).forEach(mark);
const isTailJoint = joints.map((n) => tailSet.has(n));
const mesh = root.listMeshes()[0];
const prim = mesh.listPrimitives()[0];
const pos = prim.getAttribute('POSITION');
const jA = prim.getAttribute('JOINTS_0');
const wA = prim.getAttribute('WEIGHTS_0');
const idx = prim.getIndices();
// Per-vertex classification: fin (non-Spine majority) and tail (Tail_Fork majority).
const vcount = pos.getCount();
const vIsFin = new Uint8Array(vcount);
const vIsTail = new Uint8Array(vcount);
const j = [0, 0, 0, 0], w = [0, 0, 0, 0];
for (let i = 0; i < vcount; i++) {
jA.getElement(i, j);
wA.getElement(i, w);
let fin = 0, tail = 0, tot = 0;
for (let k = 0; k < 4; k++) { tot += w[k]; if (isFinJoint[j[k]]) fin += w[k]; if (isTailJoint[j[k]]) tail += w[k]; }
vIsFin[i] = tot > 0 && fin / tot >= 0.5 ? 1 : 0;
vIsTail[i] = tot > 0 && tail / tot >= 0.5 ? 1 : 0;
}
// Per-triangle split: body unless ≥2 fin verts; within fin, tail if ≥2 tail verts.
const arr = idx.getArray();
const bodyIdx = [], finIdx = [], tailIdx = [];
for (let t = 0; t < arr.length; t += 3) {
const a = arr[t], b = arr[t + 1], c = arr[t + 2];
if (vIsFin[a] + vIsFin[b] + vIsFin[c] >= 2) {
(vIsTail[a] + vIsTail[b] + vIsTail[c] >= 2 ? tailIdx : finIdx).push(a, b, c);
} else {
bodyIdx.push(a, b, c);
}
}
const IndexArr = vcount > 65535 ? Uint32Array : Uint16Array;
const buffer = root.listBuffers()[0];
const mkIndex = (a) => doc.createAccessor().setType('SCALAR').setArray(new IndexArr(a)).setBuffer(buffer);
// Materials: original → opaque body; clones → translucent fins / tail.
const origMat = prim.getMaterial();
const bodyMat = origMat.setName('UB_Body').setAlphaMode('OPAQUE').setDoubleSided(false);
const finMat = origMat.clone().setName('UB_Fins').setAlphaMode('BLEND').setDoubleSided(true);
const tailMat = origMat.clone().setName('UB_Tail').setAlphaMode('BLEND').setDoubleSided(false);
// Body reuses the existing primitive; fins + tail get new primitives sharing attrs.
const addPrim = (indices, mat) => {
const p = doc.createPrimitive().setIndices(mkIndex(indices)).setMaterial(mat);
for (const sem of prim.listSemantics()) p.setAttribute(sem, prim.getAttribute(sem));
mesh.addPrimitive(p);
};
prim.setIndices(mkIndex(bodyIdx)).setMaterial(bodyMat);
addPrim(finIdx, finMat);
addPrim(tailIdx, tailMat);
await io.write(output, doc);
console.log(`wrote ${output}: body ${bodyIdx.length / 3}, fins ${finIdx.length / 3}, tail ${tailIdx.length / 3} tris`);