Admin: Daily Word pool curation (lookup + add/remove)

First games admin tool. A "Games" tab in the operator console for the Daily Word
answer pool.
* Lookup: is a word real (in the guess dictionary), the right length (5/6), and
  already in the pool — instant as you type.
* Add: appends to the pool, enforcing the invariant (alpha · 5/6 letters · in the
  guess dict) so the daily answer is always guessable. Remove: drops admin-added
  words (curated static ones stay).
* Additions persist in a new word_pool table (survives redeploys, unlike the
  baked-in JSON); the daily picker reads static pool ∪ DB additions. Guess dicts
  shipped with the package (goodnews/data/words-5/6.json) for server-side
  validation. Admin-gated endpoints + tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-11 11:42:52 -04:00
parent 7e4d3e2cd9
commit 903b27fc8d
7 changed files with 230 additions and 2 deletions
+99
View File
@@ -31,17 +31,48 @@
await loadStats();
feedback = await getJSON('/api/admin/feedback');
candidates = await getJSON('/api/admin/candidates');
wpPool = await getJSON('/api/admin/word/pool');
} catch {
error = "Couldn't load stats.";
}
});
// --- Games: Daily Word pool ---
let wpWord = $state('');
let wpResult = $state(null); // lookup result for the current input
let wpPool = $state(null); // { '5': {curated, added[], total}, '6': {...} }
let wpMsg = $state('');
let wpTimer;
const canAddWord = $derived(!!wpResult && !!wpResult.variant && wpResult.in_dictionary && !wpResult.in_pool);
function onWpInput() {
wpMsg = '';
clearTimeout(wpTimer);
const w = wpWord.trim();
if (!w) { wpResult = null; return; }
wpTimer = setTimeout(async () => {
try { wpResult = await getJSON('/api/admin/word/lookup?word=' + encodeURIComponent(w)); }
catch { wpResult = null; }
}, 200);
}
async function addWord() {
if (!canAddWord) return;
try {
const res = await postJSON('/api/admin/word/pool', { word: wpWord.trim() });
wpPool = res.pool; wpWord = ''; wpResult = null; wpMsg = `Added “${res.word}” to the ${res.variant}-letter pool.`;
} catch (e) { wpMsg = e.message || 'Could not add that word.'; }
}
async function removeWord(w) {
try { wpPool = await delJSON('/api/admin/word/pool/' + encodeURIComponent(w)); } catch { /* ignore */ }
}
const TABS = [
{ key: 'overview', label: 'Overview' },
{ key: 'content', label: 'Content' },
{ key: 'sources', label: 'Sources' },
{ key: 'audience', label: 'Audience' },
{ key: 'feedback', label: 'Feedback' },
{ key: 'games', label: 'Games' },
];
const VALID_SECTIONS = new Set(TABS.map((t) => t.key));
// Unknown ?section= values fall back to Overview so the page never renders blank.
@@ -679,6 +710,47 @@
</ul>
{:else}<p class="muted">Nothing in this filter.</p>{/if}
{:else}<p class="muted">No feedback yet.</p>{/if}
{:else if section === 'games'}
<h2>Daily Word pool</h2>
<p class="muted">Look up a word and add it to the answer pool. Only real, 5- or 6-letter
words in the guess dictionary qualify, so the daily answer is always solvable.</p>
<div class="wp-lookup">
<input type="text" bind:value={wpWord} oninput={onWpInput} maxlength="6" autocapitalize="off"
autocomplete="off" spellcheck="false" placeholder="Type a word to check…" />
{#if wpResult && wpResult.word}
{#if !wpResult.variant}
<span class="wp-tag bad">Must be 5 or 6 letters</span>
{:else if wpResult.in_pool}
<span class="wp-tag ok">Already in the pool</span>
{:else if wpResult.in_dictionary}
<span class="wp-tag ok">Valid {wpResult.variant}-letter word</span>
<button class="wp-add" onclick={addWord}>Add to pool</button>
{:else}
<span class="wp-tag bad">Not in the guess dictionary</span>
{/if}
{/if}
</div>
{#if wpMsg}<p class="wp-msg">{wpMsg}</p>{/if}
{#if wpPool}
<div class="wp-cols">
{#each ['5', '6'] as v (v)}
<div class="wp-col">
<h3>{v === '6' ? 'Long Word' : 'Daily Word'} <span class="count">· {v} letters · {wpPool[v].total} words</span></h3>
<p class="muted small">{wpPool[v].curated} curated + {wpPool[v].added.length} added by you</p>
{#if wpPool[v].added.length}
<ul class="wp-added">
{#each wpPool[v].added as w (w)}
<li>{w}<button class="x" onclick={() => removeWord(w)} aria-label={'Remove ' + w}>×</button></li>
{/each}
</ul>
{:else}<p class="muted small">No words added yet — the curated pool is in use.</p>{/if}
</div>
{/each}
</div>
{/if}
{/if}
{/if}
</main>
@@ -929,4 +1001,31 @@
}
.factions .act:hover { color: var(--accent-deep); border-bottom-color: var(--accent); }
.factions .act.del:hover { color: #9a3b3b; border-bottom-color: #9a3b3b; }
/* Games — Daily Word pool */
.wp-lookup { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin: 14px 0 6px; }
.wp-lookup input {
font: inherit; font-size: 1.05rem; padding: 10px 14px; border: 1px solid var(--line); border-radius: 10px;
text-transform: uppercase; letter-spacing: 0.06em; width: 210px; background: var(--surface); color: var(--ink);
}
.wp-tag { font-size: 0.84rem; font-weight: 600; padding: 4px 11px; border-radius: 999px; }
.wp-tag.ok { background: var(--accent-soft); color: var(--accent-deep); }
.wp-tag.bad { background: #f3e0e0; color: #9a3b3b; }
.wp-add { background: var(--accent); color: #fff; border: none; border-radius: 999px; padding: 8px 18px;
font: inherit; font-weight: 600; cursor: pointer; }
.wp-add:hover { background: var(--accent-deep); }
.wp-msg { color: var(--accent-deep); font-size: 0.9rem; margin: 6px 0 0; }
.wp-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 22px; margin-top: 24px; }
.wp-col h3 { margin: 0 0 2px; font-size: 1.05rem; }
.wp-col .count { font-size: 0.85rem; }
.small { font-size: 0.82rem; }
.wp-added { list-style: none; display: flex; flex-wrap: wrap; gap: 8px; padding: 0; margin: 10px 0 0; }
.wp-added li {
display: inline-flex; align-items: center; gap: 4px; background: var(--surface); border: 1px solid var(--line);
border-radius: 999px; padding: 4px 6px 4px 12px; font-family: var(--label); text-transform: uppercase;
font-size: 0.82rem; letter-spacing: 0.04em; color: var(--ink);
}
.wp-added .x { background: none; border: none; color: var(--muted); cursor: pointer; font-size: 1.15rem;
line-height: 1; padding: 0 5px; border-radius: 50%; }
.wp-added .x:hover { color: #9a3b3b; }
</style>