Compare commits
10 Commits
39d682f353
...
59ff48ae90
| Author | SHA1 | Date | |
|---|---|---|---|
| 59ff48ae90 | |||
| 89c0fbe1f6 | |||
| 2dbe73430c | |||
| 7279b18fdc | |||
| ddcfab3a11 | |||
| 64339aafb0 | |||
| de59cf49d8 | |||
| 065ab98598 | |||
| dd0df64d76 | |||
| 2ef0efd909 |
@@ -1,8 +1,10 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
.venv/
|
.venv/
|
||||||
|
node_modules/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
data/*.sqlite3
|
data/*.sqlite3
|
||||||
data/*.sqlite3-*
|
data/*.sqlite3-*
|
||||||
|
data/*.db
|
||||||
|
|
||||||
logs/
|
logs/
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+8
@@ -7,6 +7,9 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "goodnews-web",
|
"name": "goodnews-web",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"three": "^0.184.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-static": "^3.0.6",
|
"@sveltejs/adapter-static": "^3.0.6",
|
||||||
"@sveltejs/kit": "^2.8.0",
|
"@sveltejs/kit": "^2.8.0",
|
||||||
@@ -1540,6 +1543,11 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/tinybench": {
|
||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev --host",
|
"dev": "vite dev --host",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
|
"postbuild": "node scripts/patch-play-head.mjs",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
@@ -17,5 +18,8 @@
|
|||||||
"svelte": "^5.1.0",
|
"svelte": "^5.1.0",
|
||||||
"vite": "^6.0.0",
|
"vite": "^6.0.0",
|
||||||
"vitest": "^4.1.8"
|
"vitest": "^4.1.8"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"three": "^0.184.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
@@ -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>
|
||||||
@@ -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.
|
// Count a visit at most once per day per device.
|
||||||
export function trackVisit() {
|
export function trackVisit() {
|
||||||
try {
|
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 today’s 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 today’s 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>
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { getJSON, postJSON } from '$lib/api.js';
|
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();
|
let { variant = '5', onstatus } = $props();
|
||||||
|
|
||||||
@@ -40,6 +42,7 @@
|
|||||||
}
|
}
|
||||||
loading = false;
|
loading = false;
|
||||||
requestAnimationFrame(() => (ready = true));
|
requestAnimationFrame(() => (ready = true));
|
||||||
|
syncNow(); // reconcile with the server in the background (signed-in only)
|
||||||
}
|
}
|
||||||
|
|
||||||
function restore() {
|
function restore() {
|
||||||
@@ -59,6 +62,30 @@
|
|||||||
try { localStorage.setItem(stateKey, JSON.stringify({ guesses, cols, status, answer, why })); } catch { /* ignore */ }
|
try { localStorage.setItem(stateKey, JSON.stringify({ guesses, cols, status, answer, why })); } catch { /* ignore */ }
|
||||||
onstatus?.(summary());
|
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() {
|
function summary() {
|
||||||
return { variant, date, status, tries: guesses.length, max: maxGuesses };
|
return { variant, date, status, tries: guesses.length, max: maxGuesses };
|
||||||
}
|
}
|
||||||
@@ -95,12 +122,14 @@
|
|||||||
submitting = true;
|
submitting = true;
|
||||||
try {
|
try {
|
||||||
const res = await postJSON('/api/puzzle/word/guess', { variant, guess: current, n: guesses.length + 1 });
|
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];
|
guesses = [...guesses, current];
|
||||||
cols = [...cols, res.colors];
|
cols = [...cols, res.colors];
|
||||||
current = '';
|
current = '';
|
||||||
if (res.solved) { status = 'won'; answer = res.answer; why = res.why; recordStat(true); }
|
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); }
|
else if (guesses.length >= maxGuesses) { status = 'lost'; answer = res.answer; why = res.why; recordStat(false); trackGame('word', 'completed'); }
|
||||||
persist();
|
persist();
|
||||||
|
syncSoon(); // push this guess (and any completion) to the server, debounced
|
||||||
} catch {
|
} catch {
|
||||||
flash('Hmm — couldn’t check that. Try again.');
|
flash('Hmm — couldn’t check that. Try again.');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -119,6 +148,7 @@
|
|||||||
}
|
}
|
||||||
let stats = $derived.by(() => {
|
let stats = $derived.by(() => {
|
||||||
if (status === 'playing') return null;
|
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; }
|
try { return JSON.parse(localStorage.getItem(statsKey) || 'null'); } catch { return null; }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -137,9 +167,11 @@
|
|||||||
const label = variant === '6' ? 'Long Word' : 'Daily Word';
|
const label = variant === '6' ? 'Long Word' : 'Daily Word';
|
||||||
const score = status === 'won' ? guesses.length : 'X';
|
const score = status === 'won' ? guesses.length : 'X';
|
||||||
const grid = cols.map((cs) => cs.map((c) => EMOJI[c]).join('')).join('\n');
|
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`;
|
const text = `Upbeat Bytes · ${label} ${date}\n${score}/${maxGuesses}\n${grid}\n${gameShareUrl('word', variant)}`;
|
||||||
if (navigator.share) navigator.share({ text }).catch(() => {});
|
// Count a share only once it actually happens (sheet completed / clipboard wrote),
|
||||||
else navigator.clipboard?.writeText(text).then(() => { copied = true; setTimeout(() => (copied = false), 1500); });
|
// 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
|
// Load on mount and whenever the variant toggles. Tracks ONLY `variant` — it
|
||||||
@@ -165,7 +197,7 @@
|
|||||||
{#each Array(length) as _, c (c)}
|
{#each Array(length) as _, c (c)}
|
||||||
{@const ch = g ? g[c] : (r === guesses.length ? current[c] : '')}
|
{@const ch = g ? g[c] : (r === guesses.length ? current[c] : '')}
|
||||||
<div class="tile {cs ? cs[c] : ''}" class:filled={!!ch}
|
<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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -225,22 +257,53 @@
|
|||||||
aspect-ratio: 1; display: flex; align-items: center; justify-content: center;
|
aspect-ratio: 1; display: flex; align-items: center; justify-content: center;
|
||||||
border: 2px solid var(--line); border-radius: 8px; font-family: var(--label);
|
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;
|
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.filled { border-color: #b7c0cb; }
|
||||||
.tile.correct { background: #4a9d6e; border-color: #4a9d6e; color: #fff; }
|
/* Judged tiles set like glazed enamel: a soft top-light gradient over the
|
||||||
.tile.present { background: #d8b24a; border-color: #d8b24a; color: #fff; }
|
colour, an inner bevel, and a little lift off the board. Pending tiles stay
|
||||||
.tile.absent { background: #9aa6b2; border-color: #9aa6b2; color: #fff; }
|
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
|
/* Juice: a tile pops as you type; the row reveals with a staggered bounce when
|
||||||
you submit; the row shakes on an invalid word. */
|
you submit; the row shakes on an invalid word. */
|
||||||
.tile.filled:not(.correct):not(.present):not(.absent) { animation: pop 0.13s ease; }
|
.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; }
|
.row.shake { animation: shake 0.4s ease; }
|
||||||
@keyframes pop { 0% { transform: scale(1); } 45% { transform: scale(1.09); } 100% { transform: scale(1); } }
|
@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 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); } }
|
@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 {
|
.flash {
|
||||||
text-align: center; background: var(--ink); color: #fff; border-radius: 8px;
|
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;
|
padding: 7px 14px; width: fit-content; margin: 0 auto 12px; font-size: 0.86rem;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script>
|
<script>
|
||||||
import { getJSON } from '$lib/api.js';
|
import { getJSON } from '$lib/api.js';
|
||||||
import { lineFrom, matchWord, cellFromPoint } from '$lib/wordsearch.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();
|
let { size = 'med', onstatus } = $props();
|
||||||
|
|
||||||
@@ -15,7 +17,8 @@
|
|||||||
let foundWords = $state([]); // {word, cells:[[r,c]], ci}
|
let foundWords = $state([]); // {word, cells:[[r,c]], ci}
|
||||||
let sel = $state([]); // current selection cells
|
let sel = $state([]); // current selection cells
|
||||||
let selecting = false;
|
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 resultMs = $state(0);
|
||||||
let best = $state(0);
|
let best = $state(0);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
@@ -32,26 +35,74 @@
|
|||||||
const status = $derived(words.length && foundWords.length === words.length ? 'done' : 'playing');
|
const status = $derived(words.length && foundWords.length === words.length ? 'done' : 'playing');
|
||||||
const selSet = $derived(new Set(sel.map(([r, c]) => r + ',' + c)));
|
const selSet = $derived(new Set(sel.map(([r, c]) => r + ',' + c)));
|
||||||
const cellColor = $derived.by(() => {
|
const cellColor = $derived.by(() => {
|
||||||
const m = new Map();
|
const m = new Map(); // "r,c" -> [colour index, ...] (all words covering it)
|
||||||
for (const w of foundWords) for (const [r, c] of w.cells) m.set(r + ',' + c, w.ci);
|
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;
|
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 wordColor = $derived.by(() => {
|
||||||
const m = new Map();
|
const m = new Map();
|
||||||
for (const w of foundWords) m.set(w.word, w.ci);
|
for (const w of foundWords) m.set(w.word, w.ci);
|
||||||
return m;
|
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() {
|
async function load() {
|
||||||
const seq = ++loadSeq; // stale-load guard for rapid size switches
|
const seq = ++loadSeq; // stale-load guard for rapid size switches
|
||||||
loading = true; ready = false;
|
loading = true; ready = false;
|
||||||
foundWords = []; sel = []; resultMs = 0; startTime = 0;
|
foundWords = []; sel = []; resultMs = 0; playedMs = 0; segStart = 0;
|
||||||
try {
|
try {
|
||||||
const p = await getJSON('/api/puzzle/wordsearch?variant=' + size);
|
const p = await getJSON('/api/puzzle/wordsearch?variant=' + size);
|
||||||
if (seq !== loadSeq) return; // a newer size was selected — abandon
|
if (seq !== loadSeq) return; // a newer size was selected — abandon
|
||||||
theme = p.theme; words = p.words; grid = p.grid; date = p.date;
|
theme = p.theme; words = p.words; grid = p.grid; date = p.date;
|
||||||
restore();
|
restore();
|
||||||
if (!startTime) startTime = Date.now();
|
|
||||||
try { best = JSON.parse(localStorage.getItem(bestKey) || '0'); } catch { best = 0; }
|
try { best = JSON.parse(localStorage.getItem(bestKey) || '0'); } catch { best = 0; }
|
||||||
} catch {
|
} catch {
|
||||||
if (seq !== loadSeq) return;
|
if (seq !== loadSeq) return;
|
||||||
@@ -59,30 +110,66 @@
|
|||||||
}
|
}
|
||||||
if (seq !== loadSeq) return;
|
if (seq !== loadSeq) return;
|
||||||
loading = false;
|
loading = false;
|
||||||
|
resumeClock();
|
||||||
requestAnimationFrame(() => (ready = true));
|
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() {
|
function restore() {
|
||||||
try {
|
try {
|
||||||
const s = JSON.parse(localStorage.getItem(stateKey) || 'null');
|
const s = JSON.parse(localStorage.getItem(stateKey) || 'null');
|
||||||
if (s && Array.isArray(s.foundWords)) {
|
if (s && Array.isArray(s.foundWords)) {
|
||||||
// Keep only finds whose stored cells still spell their word in the CURRENT
|
foundWords = validFinds(s.foundWords);
|
||||||
// grid — guards against stale highlights if the day's puzzle changed.
|
playedMs = s.played || 0; // pre-"active clock" saves restart at 0:00 — the kind direction
|
||||||
const valid = s.foundWords.filter((fw) =>
|
resultMs = foundWords.length === words.length ? (s.ms || 0) : 0;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
onstatus?.(summary());
|
onstatus?.(summary());
|
||||||
}
|
}
|
||||||
function persist() {
|
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 */ }
|
catch { /* ignore */ }
|
||||||
onstatus?.(summary());
|
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 summary() { return { game: 'wordsearch', size, date, status, found: foundWords.length, total: words.length, ms: resultMs }; }
|
||||||
|
|
||||||
function cellAt(e) {
|
function cellAt(e) {
|
||||||
@@ -92,7 +179,7 @@
|
|||||||
function down(e) {
|
function down(e) {
|
||||||
if (status === 'done') return;
|
if (status === 'done') return;
|
||||||
selecting = true;
|
selecting = true;
|
||||||
if (!startTime) startTime = Date.now();
|
resumeClock(); // safety net if a focus event was missed
|
||||||
sel = [cellAt(e)];
|
sel = [cellAt(e)];
|
||||||
gridEl.setPointerCapture?.(e.pointerId);
|
gridEl.setPointerCapture?.(e.pointerId);
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -111,14 +198,18 @@
|
|||||||
function evaluate(cells) {
|
function evaluate(cells) {
|
||||||
const hit = matchWord(cells, grid, words, found);
|
const hit = matchWord(cells, grid, words, found);
|
||||||
if (!hit) return;
|
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 }];
|
foundWords = [...foundWords, { word: hit, cells: cells.map((c) => [...c]), ci: foundWords.length % PALETTE.length }];
|
||||||
okFlash = true; setTimeout(() => (okFlash = false), 500);
|
okFlash = true; setTimeout(() => (okFlash = false), 500);
|
||||||
if (foundWords.length === words.length) finish();
|
if (foundWords.length === words.length) finish();
|
||||||
persist();
|
persist();
|
||||||
|
syncSoon(); // push this find (and completion) to the server, debounced
|
||||||
}
|
}
|
||||||
|
|
||||||
function finish() {
|
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)) {
|
if (resultMs && (!best || resultMs < best)) {
|
||||||
best = resultMs;
|
best = resultMs;
|
||||||
try { localStorage.setItem(bestKey, JSON.stringify(best)); } catch { /* ignore */ }
|
try { localStorage.setItem(bestKey, JSON.stringify(best)); } catch { /* ignore */ }
|
||||||
@@ -132,9 +223,9 @@
|
|||||||
|
|
||||||
function share() {
|
function share() {
|
||||||
const label = { small: 'Small', med: 'Medium', large: 'Large' }[size] || '';
|
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`;
|
const text = `Upbeat Bytes · Word Search (${label}) ${date}\n${theme} — cleared in ${fmt(resultMs)}\n${gameShareUrl('wordsearch', size)}`;
|
||||||
if (navigator.share) navigator.share({ text }).catch(() => {});
|
if (navigator.share) navigator.share({ text }).then(() => trackGame('wordsearch', 'shared')).catch(() => {});
|
||||||
else navigator.clipboard?.writeText(text).then(() => { copied = true; setTimeout(() => (copied = false), 1500); });
|
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`.
|
// Load on mount and whenever the size changes. Tracks ONLY `size`.
|
||||||
@@ -157,7 +248,7 @@
|
|||||||
{#each rowStr.split('') as ch, c (c)}
|
{#each rowStr.split('') as ch, c (c)}
|
||||||
{@const key = r + ',' + c}
|
{@const key = r + ',' + c}
|
||||||
<div class="cell" class:sel={selSet.has(key)}
|
<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}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
@@ -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 1–2 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);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,9 @@ export function blank() {
|
|||||||
// [] means "not customized" — the feed falls back to the default lane set.
|
// [] means "not customized" — the feed falls back to the default lane set.
|
||||||
// The backend filter parser ignores unknown keys, so this rides along safely.
|
// The backend filter parser ignores unknown keys, so this rides along safely.
|
||||||
lanes: [],
|
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.
|
// "" or "prefs=<encoded json>" for a query string.
|
||||||
export function param(prefs) {
|
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 =
|
const empty =
|
||||||
!prefs.include_topics.length && !prefs.include_flavors.length &&
|
!f.include_topics.length && !f.include_flavors.length &&
|
||||||
!prefs.mute_topics.length && !prefs.mute_flavors.length &&
|
!f.mute_topics.length && !f.mute_flavors.length &&
|
||||||
!prefs.avoid_terms.length && !prefs.pauses.length && prefs.max_cortisol == null;
|
!f.avoid_terms.length && !f.pauses.length && f.max_cortisol == null;
|
||||||
return empty ? '' : 'prefs=' + encodeURIComponent(JSON.stringify(prefs));
|
return empty ? '' : 'prefs=' + encodeURIComponent(JSON.stringify(f));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,10 +3,16 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import FeedbackModal from '$lib/components/FeedbackModal.svelte';
|
import FeedbackModal from '$lib/components/FeedbackModal.svelte';
|
||||||
import { fb, openFeedback, closeFeedback } from '$lib/feedback.svelte.js';
|
import { fb, openFeedback, closeFeedback } from '$lib/feedback.svelte.js';
|
||||||
|
import { trackVisit } from '$lib/analytics.js';
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
// Tell the boot-failure seatbelt (app.html) the app mounted — clears the
|
// Tell the boot-failure seatbelt (app.html) the app mounted — clears the
|
||||||
// recovery card + timeout as soon as the shell hydrates.
|
// 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>
|
</script>
|
||||||
|
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
|||||||
@@ -14,8 +14,9 @@
|
|||||||
import { auth, refresh as refreshAuth, isFollowing, toggleFollow, followKeys } from '$lib/auth.svelte.js';
|
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 { prefs, initPrefs, active as prefsActive, applyPrefAction, persistPrefs, syncPrefsOnLogin } from '$lib/prefs.svelte.js';
|
||||||
import { initHistory, deviceIds, record, loadServerHistory } from '$lib/history.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 { pwa, installApp, dismissPwa } from '$lib/pwa.svelte.js';
|
||||||
|
import { ritualState, markBriefSeen } from '$lib/ritual.js';
|
||||||
|
|
||||||
let moods = $state([]);
|
let moods = $state([]);
|
||||||
let topics = $state([]);
|
let topics = $state([]);
|
||||||
@@ -28,6 +29,7 @@
|
|||||||
// /?view=<mood|topic>, or bare / for Highlights.
|
// /?view=<mood|topic>, or bare / for Highlights.
|
||||||
function parseView(url) {
|
function parseView(url) {
|
||||||
const p = url.searchParams;
|
const p = url.searchParams;
|
||||||
|
if ((p.get('q') || '').trim()) return 'search';
|
||||||
if (p.get('source')) return 'source:' + p.get('source');
|
if (p.get('source')) return 'source:' + p.get('source');
|
||||||
if (p.get('tag')) return 'tag:' + p.get('tag');
|
if (p.get('tag')) return 'tag:' + p.get('tag');
|
||||||
return p.get('view') || 'today';
|
return p.get('view') || 'today';
|
||||||
@@ -39,9 +41,25 @@
|
|||||||
return '/?view=' + encodeURIComponent(key);
|
return '/?view=' + encodeURIComponent(key);
|
||||||
}
|
}
|
||||||
let selected = $derived(parseView($page.url));
|
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 sourceNames = $state({}); // source id -> name, for an instant header label
|
||||||
let brief = $state(null);
|
let brief = $state(null);
|
||||||
let heroIdx = $state(0);
|
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 feed = $state([]);
|
||||||
let feedDone = $state(false); // no more pages for the current feed view
|
let feedDone = $state(false); // no more pages for the current feed view
|
||||||
let loadingMore = $state(false);
|
let loadingMore = $state(false);
|
||||||
@@ -136,6 +154,7 @@
|
|||||||
);
|
);
|
||||||
let viewLabel = $derived(
|
let viewLabel = $derived(
|
||||||
selected === 'today' ? 'Highlights from Today'
|
selected === 'today' ? 'Highlights from Today'
|
||||||
|
: selected === 'search' ? `Search: “${searchQuery}”`
|
||||||
: selected.startsWith('source:') ? (sourceNames[selected.slice(7)] ?? feed[0]?.source ?? 'Source')
|
: selected.startsWith('source:') ? (sourceNames[selected.slice(7)] ?? feed[0]?.source ?? 'Source')
|
||||||
: selected === 'latest' ? 'Latest'
|
: selected === 'latest' ? 'Latest'
|
||||||
: selected === 'following' ? 'Following'
|
: selected === 'following' ? 'Following'
|
||||||
@@ -144,6 +163,7 @@
|
|||||||
);
|
);
|
||||||
let viewSubtitle = $derived(
|
let viewSubtitle = $derived(
|
||||||
selected === 'today' ? localDateLabel(brief)
|
selected === 'today' ? localDateLabel(brief)
|
||||||
|
: selected === 'search' ? 'Results across Upbeat Bytes'
|
||||||
: selected.startsWith('source:') ? 'Latest from this source'
|
: selected.startsWith('source:') ? 'Latest from this source'
|
||||||
: selected === 'latest' ? 'Freshest calm reads — newest first'
|
: selected === 'latest' ? 'Freshest calm reads — newest first'
|
||||||
: selected === 'following' ? 'From the sources & topics you follow'
|
: selected === 'following' ? 'From the sources & topics you follow'
|
||||||
@@ -262,6 +282,10 @@
|
|||||||
function feedUrl(key, offset) {
|
function feedUrl(key, offset) {
|
||||||
const ex = Array.from(dismissed).join(',');
|
const ex = Array.from(dismissed).join(',');
|
||||||
const exq = ex ? `&exclude=${ex}` : '';
|
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') {
|
if (key === 'latest') {
|
||||||
const q = P.param(prefs.data);
|
const q = P.param(prefs.data);
|
||||||
return `/api/feed?sort=latest&limit=${PAGE}&offset=${offset}${q ? '&' + q : ''}${exq}`;
|
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.
|
// an accurate count. Clamped at 0, so an out-of-app landing stays app-safe.
|
||||||
if (nav.type === 'goto' || nav.type === 'link') appNavDepth += 1;
|
if (nav.type === 'goto' || nav.type === 'link') appNavDepth += 1;
|
||||||
else if (nav.type === 'popstate') appNavDepth = Math.max(0, appNavDepth + (nav.delta ?? -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);
|
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
|
// "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.
|
// page at the current length and append, de-duping against what's shown.
|
||||||
async function loadMore() {
|
async function loadMore() {
|
||||||
@@ -448,7 +488,8 @@
|
|||||||
seenIds = new Set(P.loadJSON(SEEN_KEY, []));
|
seenIds = new Set(P.loadJSON(SEEN_KEY, []));
|
||||||
dismissed = new Set(P.loadJSON(DISMISSED_KEY, []));
|
dismissed = new Set(P.loadJSON(DISMISSED_KEY, []));
|
||||||
refreshAuth();
|
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
|
// Instant paint: render the last saved Today brief immediately and refresh
|
||||||
// it behind the scenes, so the first view never blocks on a (personalized,
|
// it behind the scenes, so the first view never blocks on a (personalized,
|
||||||
// origin-bound) /api/brief request. "Gathering the good news…" then only
|
// origin-bound) /api/brief request. "Gathering the good news…" then only
|
||||||
@@ -517,6 +558,9 @@
|
|||||||
{#if viewSubtitle}<p class="sub">{viewSubtitle}</p>{/if}
|
{#if viewSubtitle}<p class="sub">{viewSubtitle}</p>{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="vh-actions">
|
<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}
|
{#if auth.user && followTarget}
|
||||||
<button class="followbtn" class:on={isFollowing(followTarget.kind, followTarget.value)}
|
<button class="followbtn" class:on={isFollowing(followTarget.kind, followTarget.value)}
|
||||||
onclick={() => toggleFollow(followTarget.kind, followTarget.value)}>
|
onclick={() => toggleFollow(followTarget.kind, followTarget.value)}>
|
||||||
@@ -532,6 +576,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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 selected === 'today'}
|
||||||
{#if sinceCount > 0 && !sinceDismissed}
|
{#if sinceCount > 0 && !sinceDismissed}
|
||||||
<div class="welcomeback rise">
|
<div class="welcomeback rise">
|
||||||
@@ -563,9 +617,24 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</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="endmark">✦ that's the good news for today ✦</p>
|
||||||
<p class="endsub">You're caught up for now.</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}
|
{#if auth.user?.digest_enabled}
|
||||||
<p class="digestnote">Tomorrow's brief is headed to your inbox ☕</p>
|
<p class="digestnote">Tomorrow's brief is headed to your inbox ☕</p>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -592,6 +661,8 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<p class="endcap rise">✦ you're all caught up ✦</p>
|
<p class="endcap rise">✦ you're all caught up ✦</p>
|
||||||
{/if}
|
{/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'}
|
{:else if selected === 'following'}
|
||||||
<p class="muted center pad">
|
<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.
|
{#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:hover { border-color: var(--accent); }
|
||||||
.viewback svg { width: 16px; height: 16px; display: block; }
|
.viewback svg { width: 16px; height: 16px; display: block; }
|
||||||
.vh-actions { flex-shrink: 0; display: flex; align-items: center; gap: 8px; margin-top: 8px; }
|
.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 {
|
.followbtn {
|
||||||
display: inline-flex; align-items: center; gap: 5px; white-space: nowrap;
|
display: inline-flex; align-items: center; gap: 5px; white-space: nowrap;
|
||||||
background: none; border: 1px solid var(--accent); color: var(--accent-deep);
|
background: none; border: 1px solid var(--accent); color: var(--accent-deep);
|
||||||
@@ -713,6 +799,36 @@
|
|||||||
.endcap .digestcta:hover { background: var(--accent-deep); }
|
.endcap .digestcta:hover { background: var(--accent-deep); }
|
||||||
.endcap .digestcta:disabled { opacity: 0.6; cursor: default; }
|
.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 */
|
/* "Since you last visited" — a calm welcome-back cue on Highlights */
|
||||||
.welcomeback {
|
.welcomeback {
|
||||||
display: flex; align-items: center; gap: 12px; justify-content: space-between;
|
display: flex; align-items: center; gap: 12px; justify-content: space-between;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { getJSON, postJSON } from '$lib/api.js';
|
import { getJSON, postJSON } from '$lib/api.js';
|
||||||
import { auth, savedIds, refresh, toggleFollow } from '$lib/auth.svelte.js';
|
import { auth, savedIds, refresh, toggleFollow } from '$lib/auth.svelte.js';
|
||||||
import { prefs, initPrefs, persistPrefs } from '$lib/prefs.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 { history, initHistory, loadServerHistory, removeOne, clearAll, record } from '$lib/history.svelte.js';
|
||||||
import { track } from '$lib/analytics.js';
|
import { track } from '$lib/analytics.js';
|
||||||
import { openFeedback } from '$lib/feedback.svelte.js';
|
import { openFeedback } from '$lib/feedback.svelte.js';
|
||||||
@@ -25,9 +26,24 @@
|
|||||||
{ key: 'following', label: 'Following' },
|
{ key: 'following', label: 'Following' },
|
||||||
{ key: 'history', label: 'History' },
|
{ key: 'history', label: 'History' },
|
||||||
{ key: 'lanes', label: 'Lanes' },
|
{ key: 'lanes', label: 'Lanes' },
|
||||||
|
{ key: 'calmset', label: 'Calm set' },
|
||||||
{ key: 'boundaries', label: 'Boundaries' },
|
{ 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 follows = $state([]);
|
||||||
let followsReady = $state(false);
|
let followsReady = $state(false);
|
||||||
async function loadFollowsList() {
|
async function loadFollowsList() {
|
||||||
@@ -112,6 +128,25 @@
|
|||||||
<p class="muted">Loading…</p>
|
<p class="muted">Loading…</p>
|
||||||
{/if}
|
{/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'}
|
{:else if section === 'boundaries'}
|
||||||
<BoundariesPanel prefs={prefs.data} onchange={persistPrefs} />
|
<BoundariesPanel prefs={prefs.data} onchange={persistPrefs} />
|
||||||
|
|
||||||
@@ -226,6 +261,22 @@
|
|||||||
.digest { margin-top: 16px; }
|
.digest { margin-top: 16px; }
|
||||||
.digest h2 { font-size: 1.1rem; margin: 0 0 6px; }
|
.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; }
|
.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 {
|
.dtoggle {
|
||||||
font: inherit; font-size: 0.9rem; border-radius: 999px; padding: 9px 18px; cursor: pointer;
|
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);
|
border: 1px solid var(--accent); background: var(--surface); color: var(--accent-deep);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { getJSON, postJSON, delJSON } from '$lib/api.js';
|
import { getJSON, postJSON, delJSON } from '$lib/api.js';
|
||||||
import { auth, refresh } from '$lib/auth.svelte.js';
|
import { auth, refresh } from '$lib/auth.svelte.js';
|
||||||
|
import EmojiPicker from '$lib/EmojiPicker.svelte';
|
||||||
|
|
||||||
let stats = $state(null);
|
let stats = $state(null);
|
||||||
let feedback = $state([]);
|
let feedback = $state([]);
|
||||||
@@ -31,26 +32,192 @@
|
|||||||
// Load all panels in PARALLEL — these were sequential awaits, so the page
|
// 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
|
// paid the (uncached, origin) round-trip six times back-to-back. One batch
|
||||||
// instead of a chain.
|
// instead of a chain.
|
||||||
const [, fb, cand, wp, ce, ws] = await Promise.all([
|
const [, fb, cand, wp, ce, ws, bq] = await Promise.all([
|
||||||
loadStats(),
|
loadStats(),
|
||||||
getJSON('/api/admin/feedback'),
|
getJSON('/api/admin/feedback'),
|
||||||
getJSON('/api/admin/candidates'),
|
getJSON('/api/admin/candidates'),
|
||||||
getJSON('/api/admin/word/pool'),
|
getJSON('/api/admin/word/pool'),
|
||||||
getJSON('/api/admin/client-errors'),
|
getJSON('/api/admin/client-errors'),
|
||||||
getJSON('/api/admin/wordsearch/themes'),
|
getJSON('/api/admin/wordsearch/themes'),
|
||||||
|
getJSON('/api/admin/bloom/reports').catch(() => null),
|
||||||
]);
|
]);
|
||||||
feedback = fb;
|
feedback = fb;
|
||||||
candidates = cand;
|
candidates = cand;
|
||||||
wpPool = wp;
|
wpPool = wp;
|
||||||
clientErrors = ce;
|
clientErrors = ce;
|
||||||
wsThemes = ws;
|
wsThemes = ws;
|
||||||
|
if (bq) { bloomReports = bq.reports || []; bloomOverrides = bq.overrides || []; }
|
||||||
} catch {
|
} catch {
|
||||||
error = "Couldn't load stats.";
|
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([]);
|
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 ---
|
// --- Games: Daily Word pool ---
|
||||||
let wpWord = $state('');
|
let wpWord = $state('');
|
||||||
let wpResult = $state(null); // lookup result for the current input
|
let wpResult = $state(null); // lookup result for the current input
|
||||||
@@ -168,6 +335,7 @@
|
|||||||
{ key: 'audience', label: 'Audience' },
|
{ key: 'audience', label: 'Audience' },
|
||||||
{ key: 'feedback', label: 'Feedback' },
|
{ key: 'feedback', label: 'Feedback' },
|
||||||
{ key: 'games', label: 'Games' },
|
{ key: 'games', label: 'Games' },
|
||||||
|
{ key: 'publish', label: 'Publishing' },
|
||||||
];
|
];
|
||||||
const VALID_SECTIONS = new Set(TABS.map((t) => t.key));
|
const VALID_SECTIONS = new Set(TABS.map((t) => t.key));
|
||||||
// Unknown ?section= values fall back to Overview so the page never renders blank.
|
// 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 = ''; }
|
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 ---
|
// --- Source candidates: supervised "add a source" pipeline ---
|
||||||
let candidates = $state([]);
|
let candidates = $state([]);
|
||||||
let newFeedUrl = $state('');
|
let newFeedUrl = $state('');
|
||||||
@@ -270,6 +477,7 @@
|
|||||||
let addBusy = $state(false);
|
let addBusy = $state(false);
|
||||||
let addErr = $state('');
|
let addErr = $state('');
|
||||||
let pendingCandidates = $derived(candidates.filter((c) => c.status !== 'promoted' && c.status !== 'rejected'));
|
let pendingCandidates = $derived(candidates.filter((c) => c.status !== 'promoted' && c.status !== 'rejected'));
|
||||||
|
let rejectedCandidates = $derived(candidates.filter((c) => c.status === 'rejected'));
|
||||||
|
|
||||||
async function addCandidate() {
|
async function addCandidate() {
|
||||||
const url = newFeedUrl.trim();
|
const url = newFeedUrl.trim();
|
||||||
@@ -299,7 +507,7 @@
|
|||||||
try {
|
try {
|
||||||
// ~5-7s/item on the LAN model; bound the wait so a model stall can't pin
|
// ~5-7s/item on the LAN model; bound the wait so a model stall can't pin
|
||||||
// the button on "Deep-checking…" forever.
|
// 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 });
|
Object.assign(c, res, { _deep: false });
|
||||||
} catch (e) { c._err = e?.message || 'Deep preview failed.'; c._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`)); }
|
try { Object.assign(c, await postJSON(`/api/admin/candidates/${c.id}/reject`)); }
|
||||||
catch { /* leave as-is */ }
|
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 2–3 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 it’s walled — usually reject, unless it’s 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.
|
// Feedback inbox: filter + read/unread + delete.
|
||||||
let fbCat = $state('all'); // 'all' | 'unread' | a category
|
let fbCat = $state('all'); // 'all' | 'unread' | a category
|
||||||
@@ -488,12 +709,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if clientErrors.length}
|
{#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">
|
<ul class="cerrs">
|
||||||
{#each clientErrors as e (e.created_at + e.reason)}
|
{#each clientErrors as e (e.created_at + e.reason)}
|
||||||
|
{@const t = errType(e)}
|
||||||
<li class:bot={e.bot}>
|
<li class:bot={e.bot}>
|
||||||
<span class="ce-when">{fdate(e.created_at)}</span>
|
<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-path">{e.path || '/'}</span>
|
||||||
<span class="ce-ua">{e.user_agent}{#if e.app_version} · build {e.app_version}{/if}</span>
|
<span class="ce-ua">{e.user_agent}{#if e.app_version} · build {e.app_version}{/if}</span>
|
||||||
</li>
|
</li>
|
||||||
@@ -600,9 +822,19 @@
|
|||||||
<div class="curl">{c.feed_url}</div>
|
<div class="curl">{c.feed_url}</div>
|
||||||
{#if c.preview}
|
{#if c.preview}
|
||||||
<div class="cprev">
|
<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.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.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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if c._err}<p class="cerr">{c._err}</p>{/if}
|
{#if c._err}<p class="cerr">{c._err}</p>{/if}
|
||||||
@@ -620,6 +852,29 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/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>
|
<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>
|
<p class="sub2">{healthy} healthy · {resting} resting · {flagged} flagged · {paused} paused · {retired} retired · {sources.length} total</p>
|
||||||
<div class="srctools">
|
<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={() => (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" 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="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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{#if s._checking || s._check || s._checkErr}
|
{#if s._checking || s._check || s._checkErr}
|
||||||
@@ -698,6 +954,57 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/if}
|
{/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}
|
{:else}
|
||||||
<tr><td colspan="10" class="srcempty">{srcSearch.trim() ? `No sources match “${srcSearch.trim()}”.` : 'No sources in this view.'}</td></tr>
|
<tr><td colspan="10" class="srcempty">{srcSearch.trim() ? `No sources match “${srcSearch.trim()}”.` : 'No sources in this view.'}</td></tr>
|
||||||
{/each}
|
{/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 class="stat"><span class="n">{stats.funnel.full_story}</span><span class="l">Straight to source</span></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
<section>
|
||||||
<h2>Emotional mix & friction</h2>
|
<h2>Emotional mix & friction</h2>
|
||||||
<div class="cards">
|
<div class="cards">
|
||||||
@@ -867,7 +1194,46 @@
|
|||||||
{:else}<p class="muted">No feedback yet.</p>{/if}
|
{:else}<p class="muted">No feedback yet.</p>{/if}
|
||||||
|
|
||||||
{:else if section === 'games'}
|
{: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
|
<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
|
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>
|
effect for future puzzles and can be restored any time.</p>
|
||||||
@@ -991,6 +1357,109 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{:else}<p class="muted small">No custom themes yet — the daily rotation uses the built-in ones.</p>{/if}
|
{: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 & 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}
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
@@ -1057,6 +1526,12 @@
|
|||||||
.stat .n { font-size: 1.7rem; font-weight: 700; font-family: var(--label); color: var(--ink); }
|
.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; }
|
.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; }
|
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 30px; }
|
||||||
@media (max-width: 620px) { .two { grid-template-columns: 1fr; } }
|
@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 { 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); }
|
.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; }
|
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; }
|
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 { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
|
||||||
.chead .cname { font-weight: 600; color: var(--ink); }
|
.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; }
|
.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 { 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; }
|
.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 */
|
/* Heuristic-vs-model preview badge */
|
||||||
.vbadge { display: inline-block; margin-left: 8px; padding: 1px 8px; border-radius: 999px;
|
.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; }
|
font-size: 0.68rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; cursor: help; }
|
||||||
.vbadge.model { background: #e3efe4; color: #3f7048; }
|
.vbadge.model { background: #e3efe4; color: #3f7048; }
|
||||||
.vbadge.fast { background: var(--line); color: var(--muted); }
|
.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 { 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 .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; }
|
.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 { margin-top: 5px; color: var(--ink); }
|
||||||
.chkex .chklbl { color: var(--muted); }
|
.chkex .chklbl { color: var(--muted); }
|
||||||
.chkex.chkrej { 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 { white-space: nowrap; }
|
||||||
.srctable .rowactions .act {
|
.srctable .rowactions .act {
|
||||||
background: none; border: 1px solid var(--line); color: var(--accent-deep);
|
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; }
|
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-when { color: var(--muted); white-space: nowrap; }
|
||||||
.ce-reason { font-family: var(--label); color: #9a3b3b; }
|
.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 { opacity: 0.6; }
|
||||||
.cerrs li.bot .ce-reason { color: var(--muted); }
|
.cerrs li.bot .ce-reason { color: var(--muted); }
|
||||||
.ce-bot { display: inline-block; margin-left: 8px; padding: 1px 8px; border-radius: 999px;
|
.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; }
|
font: inherit; font-weight: 600; cursor: pointer; }
|
||||||
.wp-add:hover { background: var(--accent-deep); }
|
.wp-add:hover { background: var(--accent-deep); }
|
||||||
.wp-msg { color: var(--accent-deep); font-size: 0.9rem; margin: 6px 0 0; }
|
.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-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 h3 { margin: 0 0 2px; font-size: 1.05rem; }
|
||||||
.wp-col .count { font-size: 0.85rem; }
|
.wp-col .count { font-size: 0.85rem; }
|
||||||
@@ -1357,4 +1899,45 @@
|
|||||||
border: 1px solid var(--line); border-radius: 12px; padding: 12px 16px; }
|
border: 1px solid var(--line); border-radius: 12px; padding: 12px 16px; }
|
||||||
.wt-name { font-weight: 600; }
|
.wt-name { font-weight: 600; }
|
||||||
.wt-count { color: var(--muted); font-size: 0.84rem; margin-right: auto; }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -3,27 +3,55 @@
|
|||||||
import { goto, afterNavigate } from '$app/navigation';
|
import { goto, afterNavigate } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { getJSON } from '$lib/api.js';
|
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 WordGame from '$lib/components/WordGame.svelte';
|
||||||
import WordSearchGame from '$lib/components/WordSearchGame.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
|
// 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
|
// Hub → Game Selection → Game (each screen is its own history entry), instead of
|
||||||
// jumping straight out of /play.
|
// jumping straight out of /play.
|
||||||
let sp = $derived($page.url.searchParams);
|
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'));
|
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 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');
|
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 date = $state('');
|
||||||
let wordStatus = $state({ 5: null, 6: null });
|
let wordStatus = $state({ 5: null, 6: null });
|
||||||
let wsStatus = $state(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) {
|
function readWord(v) {
|
||||||
try {
|
try {
|
||||||
const s = JSON.parse(localStorage.getItem(`goodnews:word:${v}:${date}`) || 'null');
|
const s = JSON.parse(localStorage.getItem(`goodnews:word:${v}:${date}`) || 'null');
|
||||||
if (s && (s.status === 'won' || s.status === 'lost')) {
|
const tries = (s?.guesses || []).length;
|
||||||
return { status: s.status, tries: (s.guesses || []).length, max: v === '6' ? 7 : 6 };
|
// 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 */ }
|
} catch { /* ignore */ }
|
||||||
return null;
|
return null;
|
||||||
@@ -35,6 +63,36 @@
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
return null;
|
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() {
|
function refreshStatus() {
|
||||||
wordStatus = { 5: readWord('5'), 6: readWord('6') };
|
wordStatus = { 5: readWord('5'), 6: readWord('6') };
|
||||||
let ws = null;
|
let ws = null;
|
||||||
@@ -44,17 +102,62 @@
|
|||||||
if (s && s.found > 0 && !ws) ws = s;
|
if (s && s.found > 0 && !ws) ws = s;
|
||||||
}
|
}
|
||||||
wsStatus = ws;
|
wsStatus = ws;
|
||||||
|
bloomStatus = readBloom();
|
||||||
|
matchStatus = readMatch();
|
||||||
|
if (date) ritual = ritualState(date, prefs.data.ritual);
|
||||||
}
|
}
|
||||||
function fmtMs(ms) {
|
function fmtMs(ms) {
|
||||||
const s = Math.round(ms / 1000);
|
const s = Math.round(ms / 1000);
|
||||||
return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
|
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
|
// Hub card one-liners
|
||||||
function wordLabel() {
|
function wordLabel() {
|
||||||
const a = wordStatus['5'], b = wordStatus['6'];
|
const a = wordStatus['5'], b = wordStatus['6'];
|
||||||
if (!a && !b) return 'Guess the day’s word';
|
if (!a && !b) return 'Guess the day’s 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)}`;
|
return `Today · 5:${part(a, 6)} 6:${part(b, 7)}`;
|
||||||
}
|
}
|
||||||
function wsHubLabel() {
|
function wsHubLabel() {
|
||||||
@@ -63,12 +166,32 @@
|
|||||||
if (wsStatus.found > 0) return `Today: ${wsStatus.found} found`;
|
if (wsStatus.found > 0) return `Today: ${wsStatus.found} found`;
|
||||||
return 'Find the day’s themed words';
|
return 'Find the day’s themed words';
|
||||||
}
|
}
|
||||||
|
function bloomHubLabel() {
|
||||||
|
if (!bloomStatus || !bloomStatus.count) return 'Make words from today’s letters';
|
||||||
|
if (bloomStatus.full) return 'Today: Full Bloom 🌸';
|
||||||
|
return `Today: ${bloomStatus.count} ${bloomStatus.count === 1 ? 'word' : 'words'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchHubLabel() {
|
||||||
|
if (!matchStatus) return 'Match the day’s 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
|
// Game-selection option statuses
|
||||||
function wordOpt(v) {
|
function wordOpt(v) {
|
||||||
const s = wordStatus[v];
|
const s = wordStatus[v];
|
||||||
if (!s) return 'Play';
|
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) {
|
function wsOpt(sz) {
|
||||||
const s = readWsSize(sz);
|
const s = readWsSize(sz);
|
||||||
@@ -91,13 +214,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Canonicalize shareable/bookmarked URLs: unknown game → hub; invalid v for the
|
// 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(() => {
|
$effect(() => {
|
||||||
const g = sp.get('game'), v = sp.get('v');
|
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) {
|
if (g && v) {
|
||||||
const valid = g === 'word' ? ['5', '6'] : ['small', 'med', 'large'];
|
const valid = g === 'word' ? ['5', '6']
|
||||||
if (!valid.includes(v)) goto(`/play?game=${g}&v=${g === 'word' ? '5' : 'med'}`, { replaceState: true });
|
: 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('');
|
let wsTheme = $state('');
|
||||||
|
|
||||||
onMount(async () => {
|
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 { date = (await getJSON('/api/puzzle/word?variant=5')).date; } catch { /* offline */ }
|
||||||
try { wsTheme = (await getJSON('/api/puzzle/wordsearch?variant=med')).theme; } catch { /* offline */ }
|
try { wsTheme = (await getJSON('/api/puzzle/wordsearch?variant=med')).theme; } catch { /* offline */ }
|
||||||
refreshStatus();
|
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).
|
// Refresh hub/selection statuses whenever we land on a screen (incl. Back).
|
||||||
afterNavigate(() => refreshStatus());
|
afterNavigate(() => refreshStatus());
|
||||||
</script>
|
</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">
|
<header class="bar">
|
||||||
<div class="container inner">
|
<div class="container inner">
|
||||||
@@ -147,6 +287,19 @@
|
|||||||
{#if view === 'hub'}
|
{#if view === 'hub'}
|
||||||
<h1>Play</h1>
|
<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>
|
<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">
|
<div class="cards">
|
||||||
<button class="gamecard" onclick={() => openGame('word')}>
|
<button class="gamecard" onclick={() => openGame('word')}>
|
||||||
<div class="gc-icon">◧</div>
|
<div class="gc-icon">◧</div>
|
||||||
@@ -164,19 +317,81 @@
|
|||||||
<p class="gc-status" class:played={wsStatus && (wsStatus.status === 'done' || wsStatus.found > 0)}>{wsHubLabel()}</p>
|
<p class="gc-status" class:played={wsStatus && (wsStatus.status === 'done' || wsStatus.found > 0)}>{wsHubLabel()}</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</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 today’s 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>
|
</div>
|
||||||
|
|
||||||
{:else if view === 'select'}
|
{: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}
|
{#if game === 'wordsearch' && wsTheme}
|
||||||
<div class="themecard">
|
<div class="themecard">
|
||||||
<span class="tc-label">Today’s theme</span>
|
<span class="tc-label">Today’s theme</span>
|
||||||
<span class="tc-name">{wsTheme}</span>
|
<span class="tc-name">{wsTheme}</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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 today’s board or free play.' : 'Play today’s shared puzzle, or graze freely.'}</p>
|
||||||
<div class="opts">
|
<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">Today’s 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>Today’s 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')}>
|
<button class="opt" onclick={() => pick('5')}>
|
||||||
<span class="opt-main"><strong>Daily Word</strong><span>5 letters · 6 guesses</span></span>
|
<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>
|
<span class="opt-go" class:done={wordStatus['5']}>{wordOpt('5')}</span>
|
||||||
@@ -198,8 +413,16 @@
|
|||||||
{:else if view === 'play'}
|
{:else if view === 'play'}
|
||||||
{#if game === 'word'}
|
{#if game === 'word'}
|
||||||
<WordGame {variant} onstatus={refreshStatus} />
|
<WordGame {variant} onstatus={refreshStatus} />
|
||||||
{:else}
|
{:else if game === 'wordsearch'}
|
||||||
<WordSearchGame size={wsSize} onstatus={refreshStatus} />
|
<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}
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
@@ -219,8 +442,42 @@
|
|||||||
margin: 8px 0 24px; max-width: 460px; text-align: center; box-shadow: var(--shadow); }
|
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;
|
.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; }
|
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; }
|
.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; }
|
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px; }
|
||||||
.gamecard {
|
.gamecard {
|
||||||
display: flex; gap: 14px; align-items: center; text-align: left;
|
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;
|
box-shadow: var(--shadow); transition: border-color 0.14s ease, transform 0.14s ease;
|
||||||
}
|
}
|
||||||
.gamecard:hover { border-color: var(--accent); transform: translateY(-1px); }
|
.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-icon { font-size: 2rem; color: var(--accent); line-height: 1; flex-shrink: 0; }
|
||||||
.gc-body h2 { font-size: 1.2rem; margin: 0 0 3px; }
|
.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-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; }
|
.gc-status { font-size: 0.84rem; color: var(--accent-deep); font-weight: 600; margin: 0; }
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -18,10 +18,13 @@ import hashlib
|
|||||||
import hmac
|
import hmac
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -33,7 +36,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import BaseModel
|
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 .localtime import local_today
|
||||||
from .markup import reply_html_to_text, sanitize_reply_html
|
from .markup import reply_html_to_text, sanitize_reply_html
|
||||||
from .db import connect
|
from .db import connect
|
||||||
@@ -42,7 +45,7 @@ from .hero import safe_to_lead
|
|||||||
from .llm import LocalModelClient
|
from .llm import LocalModelClient
|
||||||
from .moods import MOODS, mood_filter
|
from .moods import MOODS, mood_filter
|
||||||
from .lanes import build_lane_pool
|
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
|
from .taxonomy import FAMILIES, FLAVORS, TOPICS
|
||||||
|
|
||||||
# Edge-cache directives for GLOBAL endpoints — responses that depend only on the
|
# 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)
|
_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
|
_PRIVATE = "private, no-store" # never share across users
|
||||||
|
|
||||||
|
log = logging.getLogger("goodnews.api")
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
DEFAULT_DB = ROOT / "data" / "goodnews.sqlite3"
|
DEFAULT_DB = ROOT / "data" / "goodnews.sqlite3"
|
||||||
# Prefer the built SvelteKit site; fall back to the legacy single-page harness.
|
# 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.
|
# scrapers don't each kick off a duplicate LLM call.
|
||||||
_summarizing: set[int] = set()
|
_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:
|
def _run_summary(article_id: int) -> None:
|
||||||
try:
|
try:
|
||||||
@@ -158,6 +189,29 @@ def _run_summary(article_id: int) -> None:
|
|||||||
_summarizing.discard(article_id)
|
_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:
|
def _kick_summary(article_id: int, background_tasks: BackgroundTasks) -> None:
|
||||||
if article_id in _summarizing:
|
if article_id in _summarizing:
|
||||||
return
|
return
|
||||||
@@ -216,7 +270,7 @@ def _pick_lead(items: list[dict]) -> list[dict]:
|
|||||||
still appear in the set — they just don't lead.
|
still appear in the set — they just don't lead.
|
||||||
"""
|
"""
|
||||||
def gentle(a: dict) -> bool:
|
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 (
|
for ok in (
|
||||||
lambda a: gentle(a) and bool(a.get("image_url")),
|
lambda a: gentle(a) and bool(a.get("image_url")),
|
||||||
@@ -290,7 +344,7 @@ class Article(BaseModel):
|
|||||||
reason_text=row.get("reason_text"),
|
reason_text=row.get("reason_text"),
|
||||||
model_name=row.get("model_name"),
|
model_name=row.get("model_name"),
|
||||||
rank=row.get("rank"),
|
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],
|
tags=[t for t in (raw_tags.split(",") if raw_tags else []) if t],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -332,7 +386,7 @@ class SourcePreview(BaseModel):
|
|||||||
sampled: int
|
sampled: int
|
||||||
classified: bool
|
classified: bool
|
||||||
accepted: int
|
accepted: int
|
||||||
acceptance_rate: float
|
acceptance_rate: float | None # None when there are no English items to judge (all held)
|
||||||
avg_cortisol: float
|
avg_cortisol: float
|
||||||
avg_ragebait: float
|
avg_ragebait: float
|
||||||
avg_pr_risk: 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
|
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):
|
class WordPoolBody(BaseModel):
|
||||||
word: str
|
word: str
|
||||||
|
|
||||||
@@ -441,6 +550,10 @@ class SourceVisibilityBody(BaseModel):
|
|||||||
visible: bool = True
|
visible: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class SourcePaywallBody(BaseModel):
|
||||||
|
override: str | None = None # None = use domain rule · 'free' · 'paywalled'
|
||||||
|
|
||||||
|
|
||||||
class CandidateSuggestBody(BaseModel):
|
class CandidateSuggestBody(BaseModel):
|
||||||
feed_url: str = ""
|
feed_url: str = ""
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
@@ -475,13 +588,28 @@ class SourceReviewBody(BaseModel):
|
|||||||
_FEEDBACK_CATEGORIES = {"idea", "concern", "bug", "praise", "other"}
|
_FEEDBACK_CATEGORIES = {"idea", "concern", "bug", "praise", "other"}
|
||||||
|
|
||||||
# The only event kinds we record. All aggregate, non-personal.
|
# 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 = {
|
_EVENT_KINDS = {
|
||||||
"visit", "open", "summary_viewed", "full_story", "source_click",
|
"visit", "open", "summary_viewed", "full_story", "source_click",
|
||||||
"share_ub", "copy_source", "native_share",
|
"share_ub", "copy_source", "native_share",
|
||||||
"not_today", "less_like_this", "hide_topic",
|
"not_today", "less_like_this", "hide_topic",
|
||||||
"replace_used", "replace_none", "paywall_replace", "paywalled_source_open",
|
"replace_used", "replace_none", "paywall_replace", "paywalled_source_open",
|
||||||
"client_error", # boot-failure seatbelt beacon (blank-screen risk signal)
|
"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:
|
def _visitor_hash(token: str | None) -> str:
|
||||||
@@ -649,22 +777,38 @@ def create_app() -> FastAPI:
|
|||||||
state: str | None = None,
|
state: str | None = None,
|
||||||
error: str | None = None,
|
error: str | None = None,
|
||||||
) -> RedirectResponse:
|
) -> RedirectResponse:
|
||||||
fail = RedirectResponse(f"{PUBLIC_BASE_URL}/auth/verify?error=google", status_code=302)
|
# The user always sees the same generic error=google (no detail leaked),
|
||||||
if error or not code or not state:
|
# but we log WHY internally so device/host-specific failures (e.g. a www
|
||||||
return fail
|
# 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))
|
saved = _unsign(request.cookies.get(OAUTH_COOKIE))
|
||||||
if not saved:
|
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(":")
|
saved_state, _, verifier = saved.partition(":")
|
||||||
if not hmac.compare_digest(saved_state, state):
|
if not hmac.compare_digest(saved_state, state):
|
||||||
return fail
|
return fail("state_mismatch")
|
||||||
try:
|
try:
|
||||||
tokens = oauth_google.exchange_code(code, _google_redirect_uri(), verifier)
|
tokens = oauth_google.exchange_code(code, _google_redirect_uri(), verifier)
|
||||||
info = oauth_google.verify_id_token(tokens["id_token"])
|
info = oauth_google.verify_id_token(tokens["id_token"])
|
||||||
if not info.get("picture") and tokens.get("access_token"):
|
if not info.get("picture") and tokens.get("access_token"):
|
||||||
info["picture"] = oauth_google.fetch_userinfo(tokens["access_token"]).get("picture")
|
info["picture"] = oauth_google.fetch_userinfo(tokens["access_token"]).get("picture")
|
||||||
except Exception:
|
except Exception as exc: # noqa: BLE001 — log reason, show generic error
|
||||||
return fail
|
return fail("token_exchange_or_verify", exc)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
user_id = auth.find_or_create_user(
|
user_id = auth.find_or_create_user(
|
||||||
conn, info["email"], "google", info["sub"],
|
conn, info["email"], "google", info["sub"],
|
||||||
@@ -914,13 +1058,19 @@ def create_app() -> FastAPI:
|
|||||||
|
|
||||||
# --- Public share/landing page for an article -------------------------
|
# --- 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:
|
def share_page(article_id: str, background_tasks: BackgroundTasks) -> HTMLResponse:
|
||||||
not_found = HTMLResponse(share.render_not_found(PUBLIC_BASE_URL), status_code=404)
|
not_found = HTMLResponse(share.render_not_found(PUBLIC_BASE_URL), status_code=404)
|
||||||
try:
|
try:
|
||||||
aid = int(article_id)
|
aid = int(article_id)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return not_found # malformed id → calm 404, no stack trace
|
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:
|
with get_conn() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT a.id, a.title, a.description, a.image_url, a.canonical_url, "
|
"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 = ?",
|
"LEFT JOIN article_scores s ON s.article_id = a.id WHERE a.id = ?",
|
||||||
(aid,),
|
(aid,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
# Only render real, accepted, non-duplicate stories.
|
if not row:
|
||||||
if not row or row["duplicate_of"] is not None or not row["accepted"]:
|
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
|
return not_found
|
||||||
summary = summarize.get_summary(conn, aid)
|
summary = summarize.get_summary(conn, aid)
|
||||||
explanation = summarize.get_explanation(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
|
_kick_summary(aid, background_tasks) # generate/top-up for next time; page polls
|
||||||
return HTMLResponse(
|
html = share.render_share_page(dict(row), PUBLIC_BASE_URL, summary=summary, explanation=explanation)
|
||||||
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 -------------------------
|
# --- Privacy-respecting first-party analytics -------------------------
|
||||||
|
|
||||||
@@ -1139,6 +1318,40 @@ def create_app() -> FastAPI:
|
|||||||
url = src["feed_url"]
|
url = src["feed_url"]
|
||||||
return _preview_or_502(url) # safe fetch, no DB connection held
|
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) ----------------
|
# --- Source candidates (supervised add-a-source pipeline) ----------------
|
||||||
|
|
||||||
def _candidate_dict(row) -> dict:
|
def _candidate_dict(row) -> dict:
|
||||||
@@ -1260,6 +1473,76 @@ def create_app() -> FastAPI:
|
|||||||
cand = conn.execute("SELECT * FROM source_candidates WHERE id = ?", (cid,)).fetchone()
|
cand = conn.execute("SELECT * FROM source_candidates WHERE id = ?", (cid,)).fetchone()
|
||||||
return _candidate_dict(cand)
|
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) ---------------
|
# --- CSV exports (admin-gated, for inspection / archiving) ---------------
|
||||||
|
|
||||||
def _csv_cell(v):
|
def _csv_cell(v):
|
||||||
@@ -1540,7 +1823,7 @@ def create_app() -> FastAPI:
|
|||||||
)
|
)
|
||||||
# Keep the top of a browse view readable: stable-sort paywalled items
|
# Keep the top of a browse view readable: stable-sort paywalled items
|
||||||
# below readable ones (composite order preserved within each group).
|
# 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(
|
return FeedResponse(
|
||||||
topic=topic,
|
topic=topic,
|
||||||
flavor=flavor,
|
flavor=flavor,
|
||||||
@@ -1548,6 +1831,32 @@ def create_app() -> FastAPI:
|
|||||||
items=[Article.from_row(r) for r in rows],
|
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}")
|
@app.get("/api/puzzle/{game}")
|
||||||
def daily_puzzle(game: str, variant: str = Query("5")) -> dict:
|
def daily_puzzle(game: str, variant: str = Query("5")) -> dict:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
@@ -1555,8 +1864,29 @@ def create_app() -> FastAPI:
|
|||||||
return games.word_puzzle_response(conn, local_today(), variant)
|
return games.word_puzzle_response(conn, local_today(), variant)
|
||||||
if game == "wordsearch":
|
if game == "wordsearch":
|
||||||
return games.wordsearch_response(conn, local_today(), variant)
|
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")
|
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")
|
@app.post("/api/puzzle/word/guess")
|
||||||
def word_guess(body: WordGuessRequest) -> dict:
|
def word_guess(body: WordGuessRequest) -> dict:
|
||||||
if body.variant not in games.WORD_VARIANTS:
|
if body.variant not in games.WORD_VARIANTS:
|
||||||
@@ -1567,7 +1897,108 @@ def create_app() -> FastAPI:
|
|||||||
raise HTTPException(status_code=400, detail=res["error"])
|
raise HTTPException(status_code=400, detail=res["error"])
|
||||||
return res
|
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: 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")
|
@app.get("/api/admin/word/lookup")
|
||||||
def admin_word_lookup(word: str, request: Request) -> dict:
|
def admin_word_lookup(word: str, request: Request) -> dict:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
@@ -1671,7 +2102,7 @@ def create_app() -> FastAPI:
|
|||||||
rows = queries.feed(conn, sort="latest", since=since, limit=60, **kw)
|
rows = queries.feed(conn, sort="latest", since=since, limit=60, **kw)
|
||||||
if fp.avoid_terms:
|
if fp.avoid_terms:
|
||||||
rows = filter_articles(rows, fp, now)
|
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]])
|
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)
|
@app.get("/api/brief", response_model=BriefResponse)
|
||||||
@@ -1752,7 +2183,7 @@ def create_app() -> FastAPI:
|
|||||||
for r in filter_articles(rows, fp, now):
|
for r in filter_articles(rows, fp, now):
|
||||||
if r["id"] in excl:
|
if r["id"] in excl:
|
||||||
continue
|
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
|
continue
|
||||||
if gentle and not safe_to_lead(r):
|
if gentle and not safe_to_lead(r):
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -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
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
from .localtime import local_today
|
from .localtime import local_today
|
||||||
from .paywall import is_paywalled
|
from .paywall import is_paywalled, is_paywalled_for_source
|
||||||
|
|
||||||
|
|
||||||
def build_daily_brief(
|
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
|
# changed. A calm daily brief shouldn't repeatedly hand the reader a locked
|
||||||
# door: push paywalled candidates below readable ones (stable sort) first.
|
# door: push paywalled candidates below readable ones (stable sort) first.
|
||||||
rows = _candidate_articles(conn, target_date, window_days)
|
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 = _select_diverse(rows, limit)
|
||||||
selected_ids = [row["id"] for row in selected]
|
selected_ids = [row["id"] for row in selected]
|
||||||
|
|
||||||
@@ -121,6 +121,7 @@ def _candidate_articles(
|
|||||||
src.name AS source_name,
|
src.name AS source_name,
|
||||||
src.default_category,
|
src.default_category,
|
||||||
src.trust_score,
|
src.trust_score,
|
||||||
|
src.paywall_override AS paywall_override,
|
||||||
s.constructive_score,
|
s.constructive_score,
|
||||||
s.cortisol_score,
|
s.cortisol_score,
|
||||||
s.ragebait_score,
|
s.ragebait_score,
|
||||||
|
|||||||
+66
-16
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import contextlib
|
||||||
import os
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -10,7 +11,7 @@ from .db import connect, init_db
|
|||||||
from .digest import send_due_digests
|
from .digest import send_due_digests
|
||||||
from .games import generate_daily_puzzles
|
from .games import generate_daily_puzzles
|
||||||
from .localtime import local_today
|
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 .enrich import enrich_brief_images, enrich_recent_images, enrich_summarized_images
|
||||||
from .summarize import generate_summary, get_summary
|
from .summarize import generate_summary, get_summary
|
||||||
from .feeds import (
|
from .feeds import (
|
||||||
@@ -39,9 +40,17 @@ DEFAULT_DB = ROOT / "data" / "goodnews.sqlite3"
|
|||||||
DEFAULT_SOURCES = ROOT / "config" / "sources.toml"
|
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:
|
def main() -> None:
|
||||||
parser = argparse.ArgumentParser(prog="goodnews")
|
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 = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
subparsers.add_parser("init-db", help="Create or update the SQLite schema")
|
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("--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("--base-url", help="OpenAI-compatible base URL")
|
||||||
dedup_parser.add_argument("--model", help="Chat model name (unused for embeddings)")
|
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 = 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")
|
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
|
import json as _json
|
||||||
|
|
||||||
p = _json.loads(r["preview_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)
|
print(line)
|
||||||
elif args.command == "promote-candidate":
|
elif args.command == "promote-candidate":
|
||||||
init_db(conn)
|
init_db(conn)
|
||||||
@@ -286,6 +300,22 @@ def main() -> None:
|
|||||||
print(f"enrich-images: {found} new image(s) for summarized articles")
|
print(f"enrich-images: {found} new image(s) for summarized articles")
|
||||||
elif args.command == "dedup":
|
elif args.command == "dedup":
|
||||||
init_db(conn)
|
init_db(conn)
|
||||||
|
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)
|
client = llm_client_from_args(args)
|
||||||
stats = run_dedup(
|
stats = run_dedup(
|
||||||
conn, client, threshold=args.threshold, window_days=args.window_days, embed_limit=args.embed_limit
|
conn, client, threshold=args.threshold, window_days=args.window_days, embed_limit=args.embed_limit
|
||||||
@@ -368,7 +398,9 @@ def list_recent(conn: sqlite3.Connection, limit: int, accepted_only: bool) -> No
|
|||||||
def print_preview(p: dict) -> None:
|
def print_preview(p: dict) -> None:
|
||||||
mode = "model" if p["classified"] else "heuristic"
|
mode = "model" if p["classified"] else "heuristic"
|
||||||
print(f"Preview of {p['url']} ({mode})")
|
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" 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']}")
|
print(f" averages: cortisol={p['avg_cortisol']} ragebait={p['avg_ragebait']} pr_risk={p['avg_pr_risk']}")
|
||||||
if p["topic_mix"]:
|
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 ---")
|
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:
|
def run_cycle(conn: sqlite3.Connection, args: argparse.Namespace) -> None:
|
||||||
"""One end-to-end pass for a scheduler: poll due sources, classify the new
|
"""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
|
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
|
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.
|
ticks) can never overlap and contend on the database and model.
|
||||||
"""
|
"""
|
||||||
import fcntl
|
with cycle_lock(args.db) as acquired:
|
||||||
|
if not acquired:
|
||||||
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")
|
print("cycle: another cycle is already running; skipping")
|
||||||
lock_file.close()
|
|
||||||
return
|
return
|
||||||
try:
|
|
||||||
_run_cycle_locked(conn, args)
|
_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:
|
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:
|
except Exception as exc:
|
||||||
print(f"review: skipped ({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:
|
if not args.no_digest:
|
||||||
try:
|
try:
|
||||||
sent = send_due_digests(conn) # morning-gated + deduped internally
|
sent = send_due_digests(conn) # morning-gated + deduped internally
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
["vagina", "vulva", "nipple", "rectum", "anal", "fecal", "ejaculation", "eunuch", "nude", "nudity", "butt"]
|
||||||
@@ -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
@@ -28,6 +28,7 @@ CREATE TABLE IF NOT EXISTS sources (
|
|||||||
retry_after_at TEXT,
|
retry_after_at TEXT,
|
||||||
review_flag INTEGER NOT NULL DEFAULT 0,
|
review_flag INTEGER NOT NULL DEFAULT 0,
|
||||||
review_reason TEXT,
|
review_reason TEXT,
|
||||||
|
x_handle TEXT, -- the source's own verified X handle, if known
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_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,
|
reason_text TEXT,
|
||||||
topic TEXT,
|
topic TEXT,
|
||||||
flavor TEXT,
|
flavor TEXT,
|
||||||
|
language TEXT,
|
||||||
model_name TEXT,
|
model_name TEXT,
|
||||||
scored_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
scored_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
@@ -300,6 +302,47 @@ CREATE TABLE IF NOT EXISTS daily_puzzles (
|
|||||||
UNIQUE (puzzle_date, game, variant)
|
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 (approve→allow / 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 (
|
CREATE TABLE IF NOT EXISTS user_follows (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
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,
|
sent_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
UNIQUE (user_id, brief_date)
|
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.
|
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)")}
|
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:
|
if column not in score_cols:
|
||||||
conn.execute(f"ALTER TABLE article_scores ADD COLUMN {column} TEXT")
|
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",
|
"consecutive_failures": "INTEGER NOT NULL DEFAULT 0",
|
||||||
"review_flag": "INTEGER NOT NULL DEFAULT 0",
|
"review_flag": "INTEGER NOT NULL DEFAULT 0",
|
||||||
"review_reason": "TEXT",
|
"review_reason": "TEXT",
|
||||||
|
"paywall_override": "TEXT", # NULL = use domain rule · 'free' · 'paywalled'
|
||||||
}
|
}
|
||||||
for column, decl in health_columns.items():
|
for column, decl in health_columns.items():
|
||||||
if column not in source_cols:
|
if column not in source_cols:
|
||||||
conn.execute(f"ALTER TABLE sources ADD COLUMN {column} {decl}")
|
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
|
# Lifecycle: status (active/paused/retired) + content_visible. `active` is
|
||||||
# kept as a synced mirror so legacy code (scheduler/CLI) keeps working.
|
# kept as a synced mirror so legacy code (scheduler/CLI) keeps working.
|
||||||
|
|||||||
+35
-3
@@ -102,7 +102,8 @@ def cluster_duplicates(
|
|||||||
(COALESCE(s.constructive_score,0) + COALESCE(s.agency_score,0)
|
(COALESCE(s.constructive_score,0) + COALESCE(s.agency_score,0)
|
||||||
+ COALESCE(s.human_benefit_score,0) + src.trust_score
|
+ COALESCE(s.human_benefit_score,0) + src.trust_score
|
||||||
- COALESCE(s.cortisol_score,0) - COALESCE(s.ragebait_score,0)
|
- 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
|
FROM articles a
|
||||||
JOIN article_embeddings e ON e.article_id = a.id
|
JOIN article_embeddings e ON e.article_id = a.id
|
||||||
JOIN sources src ON src.id = a.source_id
|
JOIN sources src ON src.id = a.source_id
|
||||||
@@ -114,7 +115,8 @@ def cluster_duplicates(
|
|||||||
items = []
|
items = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
vec = _unit(array("f", r["vector"]).tolist())
|
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]}
|
clusters: list[dict] = [] # {anchor_vec, anchor_ord, members:[item]}
|
||||||
for it in items:
|
for it in items:
|
||||||
@@ -130,6 +132,14 @@ def cluster_duplicates(
|
|||||||
if not placed:
|
if not placed:
|
||||||
clusters.append({"anchor_vec": it["vec"], "anchor_ord": it["ord"], "members": [it]})
|
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.
|
# Reset prior decisions for everything we considered, then re-apply.
|
||||||
considered = [it["id"] for it in items]
|
considered = [it["id"] for it in items]
|
||||||
conn.executemany(
|
conn.executemany(
|
||||||
@@ -142,7 +152,19 @@ def cluster_duplicates(
|
|||||||
if len(cl["members"]) < 2:
|
if len(cl["members"]) < 2:
|
||||||
continue
|
continue
|
||||||
dup_clusters += 1
|
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"]:
|
for m in cl["members"]:
|
||||||
if m["id"] != rep["id"]:
|
if m["id"] != rep["id"]:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -166,6 +188,16 @@ def dedup(
|
|||||||
embed_limit: int | None = None,
|
embed_limit: int | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
embedded = ensure_embeddings(conn, client, limit=embed_limit)
|
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 = cluster_duplicates(conn, threshold=threshold, window_days=window_days)
|
||||||
stats["embedded"] = embedded
|
stats["embedded"] = embedded
|
||||||
return stats
|
return stats
|
||||||
|
|||||||
+5
-5
@@ -16,7 +16,7 @@ from html import escape
|
|||||||
|
|
||||||
from . import email_send
|
from . import email_send
|
||||||
from .localtime import local_now, local_today
|
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_HOUR = int(os.environ.get("GOODNEWS_DIGEST_HOUR", "7"))
|
||||||
DIGEST_WINDOW_HOURS = 4 # send between DIGEST_HOUR and +4h, site-local
|
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)."""
|
"""The brief's items with the bits a calm email needs (visible sources only)."""
|
||||||
rows = conn.execute(
|
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
|
(SELECT summary FROM article_summaries WHERE article_id = a.id) AS summary
|
||||||
FROM daily_briefs b
|
FROM daily_briefs b
|
||||||
JOIN daily_brief_items bi ON bi.brief_id = b.id
|
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 = []
|
items = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
d = dict(r)
|
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)
|
items.append(d)
|
||||||
return items
|
return items
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ def followed_digest_items(conn: sqlite3.Connection, user_id: int, exclude_ids, l
|
|||||||
params += ftags
|
params += ftags
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
f"""
|
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
|
(SELECT summary FROM article_summaries WHERE article_id = a.id) AS summary
|
||||||
FROM articles a
|
FROM articles a
|
||||||
JOIN sources s ON s.id = a.source_id
|
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:
|
if d["id"] in exclude or per_source.get(d["source_id"], 0) >= 1:
|
||||||
continue
|
continue
|
||||||
per_source[d["source_id"]] = 1
|
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)
|
out.append(d)
|
||||||
if len(out) >= limit:
|
if len(out) >= limit:
|
||||||
break
|
break
|
||||||
|
|||||||
+84
-1
@@ -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:
|
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.
|
"""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"],
|
cortisol=ns["cortisol_score"],
|
||||||
ragebait=ns["ragebait_score"],
|
ragebait=ns["ragebait_score"],
|
||||||
pr_risk=ns["pr_risk_score"],
|
pr_risk=ns["pr_risk_score"],
|
||||||
|
reason_code=ns["reason_code"],
|
||||||
|
language=ns.get("language", ""),
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # one bad item shouldn't sink the whole preview
|
pass # one bad item shouldn't sink the whole preview
|
||||||
|
|
||||||
total = len(rows)
|
total = len(rows)
|
||||||
accepted = sum(1 for r in rows if r["accepted"])
|
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:
|
def _avg(key: str) -> float:
|
||||||
return round(sum(r[key] for r in rows) / total, 1) if total else 0.0
|
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,
|
"sampled": total,
|
||||||
"classified": classified,
|
"classified": classified,
|
||||||
"accepted": accepted,
|
"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_cortisol": _avg("cortisol"),
|
||||||
"avg_ragebait": _avg("ragebait"),
|
"avg_ragebait": _avg("ragebait"),
|
||||||
"avg_pr_risk": _avg("pr_risk"),
|
"avg_pr_risk": _avg("pr_risk"),
|
||||||
"newest_published": newest,
|
"newest_published": newest,
|
||||||
"recent_7d": recent_7d,
|
"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"])),
|
"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"])),
|
"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],
|
"examples_accepted": [r["title"] for r in rows if r["accepted"]][:5],
|
||||||
|
|||||||
+411
-18
@@ -17,6 +17,8 @@ import re
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from . import bloom
|
||||||
|
|
||||||
_DATA = Path(__file__).parent / "data"
|
_DATA = Path(__file__).parent / "data"
|
||||||
_POOL = json.loads((_DATA / "wordpool.json").read_text()) # curated static answer pool
|
_POOL = json.loads((_DATA / "wordpool.json").read_text()) # curated static answer pool
|
||||||
# Guess dictionaries (same lists the client validates against) — used server-side to
|
# 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.
|
# Daily Word: 5 letters / 6 guesses · Long Word: 6 letters / 7 guesses.
|
||||||
WORD_VARIANTS = {"5": {"length": 5, "guesses": 6}, "6": {"length": 6, "guesses": 7}}
|
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:
|
def _seed(*parts: str) -> int:
|
||||||
return int(hashlib.sha256(":".join(parts).encode()).hexdigest(), 16)
|
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)]
|
_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
|
# 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
|
# 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.
|
# 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"])
|
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]]:
|
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
|
"""Place words in a size×size grid (date-seeded, deterministic) and fill the
|
||||||
rest. Returns (rows, placed_words). Every returned word is genuinely placed."""
|
rest. Returns (rows, placed_words). Every returned word is genuinely placed."""
|
||||||
rng = random.Random(seed)
|
grid, placements = _place_words(words, size, seed)
|
||||||
grid: list[list[str | None]] = [[None] * size for _ in range(size)]
|
rng = random.Random(_seed(str(seed), "fill"))
|
||||||
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
|
|
||||||
for r in range(size):
|
for r in range(size):
|
||||||
for c in range(size):
|
for c in range(size):
|
||||||
if grid[r][c] is None:
|
if grid[r][c] is None:
|
||||||
grid[r][c] = chr(65 + rng.randrange(26))
|
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:
|
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():
|
).fetchone():
|
||||||
generate_wordsearch_puzzle(conn, date, client=client)
|
generate_wordsearch_puzzle(conn, date, client=client)
|
||||||
made += 1
|
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
|
return made
|
||||||
|
|||||||
+93
-6
@@ -49,6 +49,7 @@ CLASSIFICATION_SCHEMA = {
|
|||||||
"tags",
|
"tags",
|
||||||
"reason_code",
|
"reason_code",
|
||||||
"reason_text",
|
"reason_text",
|
||||||
|
"language",
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"constructive_score": _SCORE_FIELD,
|
"constructive_score": _SCORE_FIELD,
|
||||||
@@ -64,6 +65,7 @@ CLASSIFICATION_SCHEMA = {
|
|||||||
"tags": {"type": "array", "items": {"type": "string", "enum": list(ALLOWED_TAGS)}, "maxItems": MAX_TAGS},
|
"tags": {"type": "array", "items": {"type": "string", "enum": list(ALLOWED_TAGS)}, "maxItems": MAX_TAGS},
|
||||||
"reason_code": {"type": "string"},
|
"reason_code": {"type": "string"},
|
||||||
"reason_text": {"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
|
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.
|
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:
|
Return only JSON with this exact shape:
|
||||||
{{
|
{{
|
||||||
"constructive_score": 0,
|
"constructive_score": 0,
|
||||||
@@ -118,7 +125,8 @@ Return only JSON with this exact shape:
|
|||||||
"flavor": "one_of_the_allowed_flavors",
|
"flavor": "one_of_the_allowed_flavors",
|
||||||
"tags": ["one_to_four_allowed_tags"],
|
"tags": ["one_to_four_allowed_tags"],
|
||||||
"reason_code": "short_snake_case",
|
"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())
|
""".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))
|
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:
|
def _raw_content(self, payload: dict) -> str:
|
||||||
body = json.dumps(payload).encode("utf-8")
|
body = json.dumps(payload).encode("utf-8")
|
||||||
headers = {"Content-Type": "application/json"}
|
headers = {"Content-Type": "application/json"}
|
||||||
@@ -304,7 +366,29 @@ def parse_classifier_json(content: str) -> dict:
|
|||||||
return json.loads(content[start : end + 1])
|
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:
|
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 {
|
return {
|
||||||
"constructive_score": _bounded_int(data.get("constructive_score")),
|
"constructive_score": _bounded_int(data.get("constructive_score")),
|
||||||
"cortisol_score": _bounded_int(data.get("cortisol_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")),
|
"human_benefit_score": _bounded_int(data.get("human_benefit_score")),
|
||||||
"novelty_score": _bounded_int(data.get("novelty_score")),
|
"novelty_score": _bounded_int(data.get("novelty_score")),
|
||||||
"pr_risk_score": _bounded_int(data.get("pr_risk_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")),
|
"topic": coerce_topic(data.get("topic")),
|
||||||
"flavor": coerce_flavor(data.get("flavor")),
|
"flavor": coerce_flavor(data.get("flavor")),
|
||||||
"tags": coerce_tags(data.get("tags")),
|
"tags": coerce_tags(data.get("tags")),
|
||||||
"reason_code": str(data.get("reason_code") or "model_no_reason")[:120],
|
"reason_code": reason_code,
|
||||||
"reason_text": str(data.get("reason_text") or "")[:1000],
|
"reason_text": reason_text,
|
||||||
|
"language": language,
|
||||||
"model_name": model_name,
|
"model_name": model_name,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,9 +414,9 @@ def upsert_article_score(conn: sqlite3.Connection, article_id: int, scores: dict
|
|||||||
INSERT INTO article_scores (
|
INSERT INTO article_scores (
|
||||||
article_id, constructive_score, cortisol_score, ragebait_score,
|
article_id, constructive_score, cortisol_score, ragebait_score,
|
||||||
agency_score, human_benefit_score, novelty_score, pr_risk_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
|
ON CONFLICT(article_id) DO UPDATE SET
|
||||||
constructive_score = excluded.constructive_score,
|
constructive_score = excluded.constructive_score,
|
||||||
cortisol_score = excluded.cortisol_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,
|
flavor = excluded.flavor,
|
||||||
reason_code = excluded.reason_code,
|
reason_code = excluded.reason_code,
|
||||||
reason_text = excluded.reason_text,
|
reason_text = excluded.reason_text,
|
||||||
|
language = excluded.language,
|
||||||
model_name = excluded.model_name,
|
model_name = excluded.model_name,
|
||||||
scored_at = CURRENT_TIMESTAMP
|
scored_at = CURRENT_TIMESTAMP
|
||||||
""",
|
""",
|
||||||
@@ -362,6 +448,7 @@ def upsert_article_score(conn: sqlite3.Connection, article_id: int, scores: dict
|
|||||||
scores["flavor"],
|
scores["flavor"],
|
||||||
scores["reason_code"],
|
scores["reason_code"],
|
||||||
scores["reason_text"],
|
scores["reason_text"],
|
||||||
|
scores.get("language"),
|
||||||
scores["model_name"],
|
scores["model_name"],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ and for replacements. It will never be perfect; it's an honest hint, not a gate.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
# Host suffixes considered paywalled. Subdomains match (news.nature.com → nature.com).
|
# Host suffixes considered paywalled. Subdomains match (news.nature.com → nature.com).
|
||||||
@@ -35,7 +36,72 @@ PAYWALL_DOMAINS = {
|
|||||||
|
|
||||||
|
|
||||||
def is_paywalled(url: str | None) -> bool:
|
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()
|
host = urlsplit(url or "").netloc.lower()
|
||||||
if host.startswith("www."):
|
if host.startswith("www."):
|
||||||
host = host[4:]
|
host = host[4:]
|
||||||
return any(host == d or host.endswith("." + d) for d in PAYWALL_DOMAINS)
|
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"
|
||||||
|
|||||||
@@ -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
@@ -11,7 +11,8 @@ import sqlite3
|
|||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
from .feeds import MAX_BACKOFF_MINUTES
|
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
|
# UA substrings that mark automated clients. Crawlers run JS on a throttled
|
||||||
# budget and trip the boot-failure beacon routinely — without this filter they
|
# budget and trip the boot-failure beacon routinely — without this filter they
|
||||||
@@ -53,6 +54,7 @@ _ARTICLE_COLUMNS = f"""
|
|||||||
s.reason_code,
|
s.reason_code,
|
||||||
s.reason_text,
|
s.reason_text,
|
||||||
s.model_name,
|
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,
|
(SELECT group_concat(t.tag) FROM article_tags t WHERE t.article_id = a.id) AS tags,
|
||||||
{RANK_SCORE_SQL} AS rank_score
|
{RANK_SCORE_SQL} AS rank_score
|
||||||
"""
|
"""
|
||||||
@@ -77,6 +79,7 @@ def feed(
|
|||||||
follow_sources: list[int] | None = None,
|
follow_sources: list[int] | None = None,
|
||||||
follow_tags: list[str] | None = None,
|
follow_tags: list[str] | None = None,
|
||||||
since: str | None = None,
|
since: str | None = None,
|
||||||
|
match: str | None = None,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Return articles with categorical filters applied in SQL.
|
"""Return articles with categorical filters applied in SQL.
|
||||||
|
|
||||||
@@ -91,6 +94,14 @@ def feed(
|
|||||||
"""
|
"""
|
||||||
clauses = ["a.duplicate_of IS NULL", "src.content_visible = 1"]
|
clauses = ["a.duplicate_of IS NULL", "src.content_visible = 1"]
|
||||||
params: list = []
|
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:
|
if accepted_only:
|
||||||
clauses.append("s.accepted = 1")
|
clauses.append("s.accepted = 1")
|
||||||
if topic:
|
if topic:
|
||||||
@@ -154,17 +165,19 @@ def feed(
|
|||||||
where = "WHERE " + " AND ".join(clauses)
|
where = "WHERE " + " AND ".join(clauses)
|
||||||
params.extend([limit, offset])
|
params.extend([limit, offset])
|
||||||
|
|
||||||
order_by = (
|
if match:
|
||||||
"COALESCE(a.published_at, a.discovered_at) DESC, rank_score DESC"
|
order_by = "bm25(article_search), COALESCE(a.published_at, a.discovered_at) DESC" # relevance, then recency
|
||||||
if sort == "latest"
|
elif sort == "latest":
|
||||||
else "rank_score DESC, COALESCE(a.published_at, a.discovered_at) DESC"
|
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(
|
rows = conn.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT {_ARTICLE_COLUMNS}
|
SELECT {_ARTICLE_COLUMNS}
|
||||||
FROM articles a
|
FROM articles a
|
||||||
JOIN sources src ON src.id = a.source_id
|
JOIN sources src ON src.id = a.source_id
|
||||||
JOIN article_scores s ON s.article_id = a.id
|
JOIN article_scores s ON s.article_id = a.id
|
||||||
|
{fts_join}
|
||||||
{where}
|
{where}
|
||||||
ORDER BY {order_by}
|
ORDER BY {order_by}
|
||||||
LIMIT ? OFFSET ?
|
LIMIT ? OFFSET ?
|
||||||
@@ -174,6 +187,27 @@ def feed(
|
|||||||
return [dict(row) for row in rows]
|
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:
|
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."""
|
"""Return a stored daily brief (latest if no date) with its ranked items."""
|
||||||
target_date = brief_date or _latest_brief_date(conn)
|
target_date = brief_date or _latest_brief_date(conn)
|
||||||
@@ -335,7 +369,7 @@ def source_health(conn: sqlite3.Connection) -> list[dict]:
|
|||||||
SELECT
|
SELECT
|
||||||
s.id, s.name, s.feed_url, s.homepage_url, s.default_category AS category, s.active,
|
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.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.poll_interval_minutes AS interval_minutes,
|
||||||
s.last_success_at, s.last_error_at, substr(s.last_error, 1, 160) AS last_error,
|
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
|
(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 WHERE a.source_id = s.id) AS total_articles,
|
||||||
(SELECT COUNT(*) FROM articles a JOIN article_scores sc ON sc.article_id = a.id
|
(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,
|
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 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
|
(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,
|
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)
|
d = dict(r)
|
||||||
total = d["total_articles"] or 0
|
total = d["total_articles"] or 0
|
||||||
accepted = d["accepted_total"] 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
|
d["duplicate_rate"] = round(100 * d["duplicates"] / total) if total else None
|
||||||
# Curation quality: of what this source got ACCEPTED, how much was a
|
# Curation quality: of what this source got ACCEPTED, how much was a
|
||||||
# duplicate of content already served (accepted_total − served = accepted dupes).
|
# 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["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
|
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).
|
# Paywall is a domain-level hint + a per-source override; show the EFFECTIVE flag.
|
||||||
d["paywalled"] = is_paywalled(d.get("homepage_url") or d.get("feed_url"))
|
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
|
# Match the REAL scheduler gate: due = the later of the streak-backoff time
|
||||||
# and any retry_after_at rest (UTC strings sort chronologically).
|
# 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]
|
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
|
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:
|
def admin_stats(conn: sqlite3.Connection, days: int = 30) -> dict:
|
||||||
"""Aggregate, non-personal usage stats for the admin dashboard."""
|
"""Aggregate, non-personal usage stats for the admin dashboard."""
|
||||||
since = f"-{days} days"
|
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=()):
|
def scalar(sql, params=()):
|
||||||
return conn.execute(sql, params).fetchone()[0] or 0
|
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)}
|
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 — aggregate counts only (no emails, no per-user listing).
|
||||||
accounts = {
|
accounts = {
|
||||||
"total": scalar("SELECT COUNT(*) FROM users"),
|
"total": scalar("SELECT COUNT(*) FROM users"),
|
||||||
@@ -572,6 +713,7 @@ def admin_stats(conn: sqlite3.Connection, days: int = 30) -> dict:
|
|||||||
"emotional_mix": emotional_mix,
|
"emotional_mix": emotional_mix,
|
||||||
"paywall": paywall,
|
"paywall": paywall,
|
||||||
"replace": replace,
|
"replace": replace,
|
||||||
|
"games": games,
|
||||||
"top_articles": top_articles,
|
"top_articles": top_articles,
|
||||||
"top_groupings": top_groupings,
|
"top_groupings": top_groupings,
|
||||||
"top_topics": top_topics,
|
"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.
|
# check routinely and would read as real users seeing blank screens.
|
||||||
"client_errors": {
|
"client_errors": {
|
||||||
"today": scalar(
|
"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(
|
"window": scalar(
|
||||||
f"SELECT COUNT(*) FROM client_errors WHERE created_at>=date('now',?) AND {_NOT_BOT_SQL}",
|
f"SELECT COUNT(*) FROM client_errors WHERE created_at>=date('now',?) AND {_NOT_BOT_SQL}",
|
||||||
|
|||||||
+8
-3
@@ -216,9 +216,14 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None,
|
|||||||
<script>
|
<script>
|
||||||
(function(){{
|
(function(){{
|
||||||
try{{
|
try{{
|
||||||
var v=localStorage.getItem('goodnews:visitor')||'';
|
var v=localStorage.getItem('goodnews:visitor');
|
||||||
var b=JSON.stringify({{kind:'summary_viewed',article_id:{aid},visitor:v}});
|
if(!v){{v=crypto.randomUUID?crypto.randomUUID():String(Math.random()).slice(2)+Date.now();localStorage.setItem('goodnews:visitor',v);}}
|
||||||
if(navigator.sendBeacon) navigator.sendBeacon('/api/events', new Blob([b],{{type:'application/json'}}));
|
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){{}}
|
}}catch(e){{}}
|
||||||
}})();
|
}})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+15
-3
@@ -7,7 +7,7 @@ from datetime import datetime, timezone
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import urlsplit
|
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]:
|
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
|
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(
|
def promote_candidate(
|
||||||
conn: sqlite3.Connection,
|
conn: sqlite3.Connection,
|
||||||
candidate_id: int,
|
candidate_id: int,
|
||||||
@@ -244,7 +256,7 @@ def review_sources(
|
|||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
flagged = []
|
flagged = []
|
||||||
sources = conn.execute(
|
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()
|
).fetchall()
|
||||||
|
|
||||||
for s in sources:
|
for s in sources:
|
||||||
@@ -292,7 +304,7 @@ def review_sources(
|
|||||||
avg_rage = sum(r["ragebait_score"] or 0 for r in recent) / n
|
avg_rage = sum(r["ragebait_score"] or 0 for r in recent) / n
|
||||||
if avg_rage > 3:
|
if avg_rage > 3:
|
||||||
reasons.append(f"high ragebait (avg {avg_rage:.1f})")
|
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:
|
if paywalled > 0.5:
|
||||||
reasons.append(f"paywall-heavy ({paywalled * 100:.0f}%)")
|
reasons.append(f"paywall-heavy ({paywalled * 100:.0f}%)")
|
||||||
|
|
||||||
|
|||||||
@@ -419,7 +419,9 @@
|
|||||||
box.append(d);
|
box.append(d);
|
||||||
};
|
};
|
||||||
stat("Mode:", p.classified ? "model (accurate)" : "heuristic (quick, conservative)");
|
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("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}`);
|
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(" · ") || "—";
|
const mix = (m) => Object.entries(m).map(([k, v]) => `${k} ${v}`).join(" · ") || "—";
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ $ = informational
|
|||||||
- Date showed 6/2/2026 while it was still 6/1/2026 at 10:32pm
|
- 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.
|
- 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
|
- 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.)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
@@ -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")
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -503,3 +503,51 @@ def test_wordsearch_theme_admin(tmp_path, monkeypatch):
|
|||||||
# remove
|
# remove
|
||||||
left = tc.delete(f"/api/admin/wordsearch/themes/{tid}").json()
|
left = tc.delete(f"/api/admin/wordsearch/themes/{tid}").json()
|
||||||
assert not any(t["id"] == tid for t in left)
|
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
|
||||||
|
|||||||
@@ -114,3 +114,27 @@ def test_brief_cache_boundary(client):
|
|||||||
assert "public" in client.get("/api/brief").headers.get("cache-control", "")
|
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={"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"
|
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"])
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -5,6 +5,7 @@ from goodnews.sources import (
|
|||||||
list_candidates,
|
list_candidates,
|
||||||
promote_candidate,
|
promote_candidate,
|
||||||
reject_candidate,
|
reject_candidate,
|
||||||
|
restore_candidate,
|
||||||
save_candidate,
|
save_candidate,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,6 +33,18 @@ def test_re_preview_preserves_curator_status(conn):
|
|||||||
assert list_candidates(conn)[0]["status"] == "rejected"
|
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):
|
def test_promote_creates_inactive_source_and_marks_promoted(conn):
|
||||||
cand = save_candidate(conn, "http://x/feed", name="Lovely Feed")
|
cand = save_candidate(conn, "http://x/feed", name="Lovely Feed")
|
||||||
source_id = promote_candidate(conn, cand["id"]) # inactive by default
|
source_id = promote_candidate(conn, cand["id"]) # inactive by default
|
||||||
|
|||||||
@@ -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
@@ -35,7 +35,7 @@ def conn():
|
|||||||
c.close()
|
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(
|
conn.execute(
|
||||||
"INSERT INTO articles (id, source_id, canonical_url, title, published_at, url_hash) "
|
"INSERT INTO articles (id, source_id, canonical_url, title, published_at, url_hash) "
|
||||||
"VALUES (?, 1, ?, ?, ?, ?)",
|
"VALUES (?, 1, ?, ?, ?, ?)",
|
||||||
@@ -44,8 +44,8 @@ def _add(conn, article_id, vector, constructive, when="2026-05-30T10:00:00+00:00
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO article_scores (article_id, constructive_score, agency_score, "
|
"INSERT INTO article_scores (article_id, constructive_score, agency_score, "
|
||||||
"human_benefit_score, cortisol_score, ragebait_score, pr_risk_score, accepted) "
|
"human_benefit_score, cortisol_score, ragebait_score, pr_risk_score, accepted) "
|
||||||
"VALUES (?, ?, 0, 0, 0, 0, 0, 1)",
|
"VALUES (?, ?, 0, 0, 0, 0, 0, ?)",
|
||||||
(article_id, constructive),
|
(article_id, constructive, accepted),
|
||||||
)
|
)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO article_embeddings (article_id, vector, dim, model) VALUES (?, ?, ?, 'test')",
|
"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
|
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):
|
def test_distinct_articles_are_not_clustered(conn):
|
||||||
_add(conn, 1, [1.0, 0.0, 0.0, 0.0], constructive=5)
|
_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)
|
_add(conn, 2, [0.0, 1.0, 0.0, 0.0], constructive=5)
|
||||||
|
|||||||
@@ -51,3 +51,34 @@ def test_unknown_kind_is_ignored(app_db):
|
|||||||
app, db = app_db
|
app, db = app_db
|
||||||
assert TestClient(app).post("/api/events", json={"kind": "evil", "visitor": "x"}).json() == {"ok": True}
|
assert TestClient(app).post("/api/events", json={"kind": "evil", "visitor": "x"}).json() == {"ok": True}
|
||||||
assert _count(db) == 0
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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") == []
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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/999").status_code == 404 # unknown
|
||||||
assert tc.get("/a/not-a-number").status_code == 404 # malformed → calm 404
|
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/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):
|
def test_share_page_no_image_uses_summary_card(client, tmp_path, monkeypatch):
|
||||||
# article 1 has an image → large card
|
# article 1 has an image → large card
|
||||||
assert 'summary_large_image' in TestClient(client).get("/a/1").text
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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(', '));
|
||||||
@@ -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(',')}]`);
|
||||||
@@ -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])}]`);
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
@@ -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})`);
|
||||||
Generated
+47
@@ -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=="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"name":"glb-split","private":true,"type":"module","dependencies":{"@gltf-transform/core":"^4.4.0","@gltf-transform/extensions":"^4.4.0"}}
|
||||||
@@ -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'));
|
||||||
@@ -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`);
|
||||||
Reference in New Issue
Block a user