Files
upbeatBytes/goodnews/static/index.html
T
thejayman77 091dec64ae Calm Filters MVP: device-local personalization across feed/brief/counts
- API endpoints (feed, brief, category-counts) accept a 'prefs' JSON query
  param, parsed tolerantly into FilterPrefs (bad blobs never break the feed).
- Feed over-fetches then applies word-boundary filters in Python and slices to
  the page; brief is filtered down (no refill); counts are computed over the
  same filtered set so browse numbers match the feed exactly.
- Pause.active() coerces naive datetimes to UTC; FilterPrefs.from_dict skips
  malformed pauses and non-string list entries.
- Static site adds the humane ladder (Not today / Less like this / Always hide)
  plus a Calm filters panel managing pauses, mutes, and avoid-terms in
  localStorage. Nothing leaves the device.
- Tests now 38 (added forgiving-parse and naive-now cases). README documents it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 19:16:42 +00:00

358 lines
16 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>goodNews — calm, constructive news</title>
<style>
:root {
--bg: #f7f5f0; --card: #fff; --ink: #1f2a24; --muted: #6b7770;
--accent: #2f7d5b; --accent-soft: #e4efe8; --line: #e3e0d8;
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--ink);
font: 16px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
header {
padding: 28px 20px 18px; text-align: center; border-bottom: 1px solid var(--line);
background: var(--card); position: relative;
}
header h1 { margin: 0; font-size: 1.7rem; letter-spacing: -0.02em; }
header h1 span { color: var(--accent); }
header p { margin: 6px 0 0; color: var(--muted); font-size: 0.95rem; }
.calm-btn {
position: absolute; top: 20px; right: 20px; border: 1px solid var(--line);
background: var(--card); border-radius: 999px; padding: 6px 14px; cursor: pointer;
font-size: 0.85rem; color: var(--ink);
}
.calm-btn.on { background: var(--accent); color: #fff; border-color: var(--accent); }
main { max-width: 760px; margin: 0 auto; padding: 20px; }
.chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 6px 0 18px; }
.chip {
border: 1px solid var(--line); background: var(--card); color: var(--ink);
border-radius: 999px; padding: 6px 13px; font-size: 0.85rem; cursor: pointer;
transition: all .12s ease;
}
.chip:hover { border-color: var(--accent); }
.chip.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.chip-group-label { font-size: 0.72rem; text-transform: uppercase; letter-spacing: .08em;
color: var(--muted); margin: 4px 0; width: 100%; }
.section-title { font-size: 0.95rem; color: var(--muted); margin: 22px 0 10px;
text-transform: uppercase; letter-spacing: .06em; }
article {
background: var(--card); border: 1px solid var(--line); border-radius: 12px;
padding: 16px 18px; margin-bottom: 12px;
}
article .meta { display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
font-size: 0.78rem; color: var(--muted); margin-bottom: 6px; }
.tag { background: var(--accent-soft); color: var(--accent); border-radius: 6px;
padding: 1px 8px; font-weight: 600; }
article h3 { margin: 0 0 6px; font-size: 1.08rem; line-height: 1.35; }
article h3 a { color: var(--ink); text-decoration: none; }
article h3 a:hover { color: var(--accent); }
article p.desc { margin: 0 0 8px; color: #44514a; font-size: 0.92rem; }
article .why { font-size: 0.8rem; color: var(--muted); font-style: italic; }
.rank-badge { background: var(--accent); color: #fff; border-radius: 50%;
width: 24px; height: 24px; display: inline-flex; align-items: center;
justify-content: center; font-size: 0.8rem; font-weight: 700; }
.actions { margin-top: 10px; display: flex; gap: 14px; }
.actions button {
background: none; border: none; padding: 0; cursor: pointer; font-size: 0.78rem;
color: var(--muted); border-bottom: 1px dotted var(--line);
}
.actions button:hover { color: var(--accent); border-bottom-color: var(--accent); }
.empty { color: var(--muted); text-align: center; padding: 30px; }
/* Calm settings panel */
.panel {
background: var(--card); border: 1px solid var(--line); border-radius: 12px;
padding: 16px 18px; margin-bottom: 18px; display: none;
}
.panel.open { display: block; }
.panel h2 { font-size: 1rem; margin: 0 0 4px; }
.panel .hint { color: var(--muted); font-size: 0.82rem; margin: 0 0 12px; }
.panel .group { margin-bottom: 12px; }
.panel .group-label { font-size: 0.74rem; text-transform: uppercase; letter-spacing: .07em;
color: var(--muted); margin-bottom: 6px; }
.pill {
display: inline-flex; align-items: center; gap: 6px; background: var(--accent-soft);
color: var(--accent); border-radius: 999px; padding: 3px 6px 3px 11px; font-size: 0.82rem;
margin: 0 6px 6px 0;
}
.pill button { background: none; border: none; cursor: pointer; color: var(--accent);
font-size: 0.95rem; line-height: 1; padding: 0 2px; }
.panel input[type=text] { border: 1px solid var(--line); border-radius: 8px; padding: 6px 10px;
font-size: 0.88rem; width: 60%; }
.panel .addbtn { margin-left: 6px; border: 1px solid var(--accent); background: var(--accent);
color: #fff; border-radius: 8px; padding: 6px 12px; cursor: pointer; font-size: 0.85rem; }
.panel .reset { margin-top: 6px; background: none; border: none; color: var(--muted);
cursor: pointer; font-size: 0.8rem; text-decoration: underline; }
.calm-note { color: var(--muted); font-size: 0.8rem; margin: -8px 0 14px; }
footer { text-align: center; color: var(--muted); font-size: 0.78rem; padding: 20px; }
footer a { color: var(--accent); }
</style>
</head>
<body>
<header>
<h1>good<span>News</span></h1>
<p>Calm, constructive news worth your attention — and nothing that isn't.</p>
<button id="calm-toggle" class="calm-btn">Calm filters</button>
</header>
<main>
<div id="panel" class="panel">
<h2>Calm filters</h2>
<p class="hint">Your boundaries, kept on this device. Nothing is sent anywhere or tied to an account.</p>
<div class="group">
<div class="group-label">Paused for now</div>
<div id="panel-pauses"></div>
</div>
<div class="group">
<div class="group-label">Always hidden</div>
<div id="panel-mutes"></div>
</div>
<div class="group">
<div class="group-label">Avoid words &amp; phrases</div>
<div id="panel-terms"></div>
<input id="term-input" type="text" placeholder="e.g. election, stock market" />
<button id="term-add" class="addbtn">Add</button>
</div>
<button id="reset" class="reset">Reset all calm filters</button>
</div>
<div id="calm-note" class="calm-note"></div>
<div id="brief-block">
<div class="section-title">Five Good Things</div>
<div id="brief"><div class="empty">Loading…</div></div>
</div>
<div class="section-title">Browse by category</div>
<div id="topic-chips" class="chips"></div>
<div id="flavor-chips" class="chips"></div>
<div id="feed"></div>
</main>
<footer>
goodNews · metadata &amp; links only, no stored articles ·
<a href="/docs">API</a>
</footer>
<script>
const PREFS_KEY = "goodnews:prefs";
const browse = { topic: null, flavor: null };
const el = (id) => document.getElementById(id);
// ---- preferences (the canonical FilterPrefs shape) ----
function blankPrefs() {
return { include_topics: [], include_flavors: [], mute_topics: [],
mute_flavors: [], avoid_terms: [], pauses: [] };
}
function loadPrefs() {
let p;
try { p = JSON.parse(localStorage.getItem(PREFS_KEY)) || {}; } catch { p = {}; }
const prefs = Object.assign(blankPrefs(), p);
// prune expired pauses so localStorage stays tidy
const now = Date.now();
prefs.pauses = (prefs.pauses || []).filter(x => x && x.until && new Date(x.until).getTime() > now);
return prefs;
}
let prefs = loadPrefs();
function savePrefs() { localStorage.setItem(PREFS_KEY, JSON.stringify(prefs)); }
function prefsActive() {
return prefs.mute_topics.length || prefs.mute_flavors.length ||
prefs.avoid_terms.length || prefs.pauses.length ||
prefs.include_topics.length || prefs.include_flavors.length;
}
function prefsParam() {
return prefsActive() ? "&prefs=" + encodeURIComponent(JSON.stringify(prefs)) : "";
}
function hoursFromNow(h) { return new Date(Date.now() + h * 3600e3).toISOString(); }
// ---- gentle actions: the humane ladder ----
function notToday(topic) { // pause this topic 24h
if (!topic) return;
prefs.pauses = prefs.pauses.filter(p => !(p.kind === "topic" && p.value === topic));
prefs.pauses.push({ kind: "topic", value: topic, until: hoursFromNow(24) });
savePrefs(); refreshAll();
}
function lessLikeThis(flavor) { // ease off this flavor for a few days
if (!flavor) return;
prefs.pauses = prefs.pauses.filter(p => !(p.kind === "flavor" && p.value === flavor));
prefs.pauses.push({ kind: "flavor", value: flavor, until: hoursFromNow(72) });
savePrefs(); refreshAll();
}
function alwaysHide(topic) { // standing mute, undoable in the panel
if (!topic || prefs.mute_topics.includes(topic)) return;
prefs.mute_topics.push(topic);
savePrefs(); refreshAll();
}
async function getJSON(url) {
const r = await fetch(url);
if (!r.ok) throw new Error(await r.text());
return r.json();
}
function articleCard(a, showRank) {
const rank = showRank && a.rank ? `<span class="rank-badge">${a.rank}</span>` : "";
const tags = [a.topic, a.flavor].filter(Boolean).map(t => `<span class="tag">${t}</span>`).join(" ");
const desc = a.description ? `<p class="desc">${a.description}</p>` : "";
const why = a.reason_text ? `<div class="why">${a.reason_text}</div>` : "";
const acts = [];
if (a.topic) acts.push(`<button data-act="notToday" data-topic="${a.topic}">Not today</button>`);
if (a.flavor) acts.push(`<button data-act="lessLikeThis" data-flavor="${a.flavor}">Less like this</button>`);
if (a.topic) acts.push(`<button data-act="alwaysHide" data-topic="${a.topic}">Always hide ${a.topic}</button>`);
return `<article>
<div class="meta">${rank}${tags}<span>${a.source}</span>
${a.published_at ? `<span>· ${a.published_at.slice(0,10)}</span>` : ""}</div>
<h3><a href="${a.url}" target="_blank" rel="noopener">${a.title}</a></h3>
${desc}${why}
<div class="actions">${acts.join("")}</div>
</article>`;
}
function renderList(target, items, showRank) {
target.innerHTML = items.length
? items.map(a => articleCard(a, showRank)).join("")
: `<div class="empty">Nothing here right now — try easing a filter.</div>`;
}
// delegated clicks for the per-article gentle actions
function wireActions(container) {
container.addEventListener("click", (e) => {
const b = e.target.closest("button[data-act]");
if (!b) return;
const act = b.dataset.act;
if (act === "notToday") notToday(b.dataset.topic);
else if (act === "lessLikeThis") lessLikeThis(b.dataset.flavor);
else if (act === "alwaysHide") alwaysHide(b.dataset.topic);
});
}
async function loadBrief() {
try {
const b = await getJSON(`/api/brief?limit=5${prefsParam()}`);
if (b.title) el("brief-block").querySelector(".section-title").textContent = b.title;
renderList(el("brief"), b.items, true);
} catch { el("brief").innerHTML = `<div class="empty">No brief yet.</div>`; }
}
async function loadFeed() {
const params = new URLSearchParams({ limit: "30" });
if (browse.topic) params.set("topic", browse.topic);
if (browse.flavor) params.set("flavor", browse.flavor);
const f = await getJSON(`/api/feed?${params.toString()}${prefsParam()}`);
renderList(el("feed"), f.items, false);
}
function chip(label, active, onClick) {
const c = document.createElement("button");
c.className = "chip" + (active ? " active" : "");
c.textContent = label;
c.onclick = onClick;
return c;
}
function pill(label, onRemove) {
const span = document.createElement("span");
span.className = "pill";
span.append(label);
const x = document.createElement("button");
x.textContent = "×"; x.title = "remove"; x.onclick = onRemove;
span.append(x);
return span;
}
function renderPanel() {
const pauses = el("panel-pauses"); pauses.innerHTML = "";
if (!prefs.pauses.length) pauses.append(Object.assign(document.createElement("span"),
{ className: "hint", textContent: "Nothing paused." }));
prefs.pauses.forEach((p, i) => {
const when = new Date(p.until).toLocaleString();
pauses.append(pill(`${p.kind}: ${p.value} (until ${when})`, () => {
prefs.pauses.splice(i, 1); savePrefs(); refreshAll();
}));
});
const mutes = el("panel-mutes"); mutes.innerHTML = "";
const allMutes = [...prefs.mute_topics.map(v => ["topic", v]), ...prefs.mute_flavors.map(v => ["flavor", v])];
if (!allMutes.length) mutes.append(Object.assign(document.createElement("span"),
{ className: "hint", textContent: "Nothing hidden." }));
allMutes.forEach(([kind, v]) => {
mutes.append(pill(`${kind}: ${v}`, () => {
const arr = kind === "topic" ? prefs.mute_topics : prefs.mute_flavors;
arr.splice(arr.indexOf(v), 1); savePrefs(); refreshAll();
}));
});
const terms = el("panel-terms"); terms.innerHTML = "";
if (!prefs.avoid_terms.length) terms.append(Object.assign(document.createElement("span"),
{ className: "hint", textContent: "No avoided words yet." }));
prefs.avoid_terms.forEach((t, i) => terms.append(pill(t, () => {
prefs.avoid_terms.splice(i, 1); savePrefs(); refreshAll();
})));
}
function renderNote() {
const n = prefsActive()
? "Calm filters on — your feed is personalized on this device."
: "";
el("calm-note").textContent = n;
el("calm-toggle").classList.toggle("on", !!prefsActive());
}
async function refreshAll() {
renderPanel(); renderNote();
await Promise.all([loadBrief(), loadFeed(), refreshCounts()]);
}
async function refreshCounts() {
// counts reflect the same filters so chip-able categories match the feed
try { await getJSON(`/api/category-counts?${prefsParam().slice(1)}`); } catch {}
}
async function init() {
wireActions(el("feed"));
wireActions(el("brief"));
const cats = await getJSON("/api/categories");
const tc = el("topic-chips");
tc.appendChild(Object.assign(document.createElement("span"),
{ className: "chip-group-label", textContent: "Topic" }));
tc.appendChild(chip("all", true, () => setTopic(null)));
cats.topics.forEach(t => tc.appendChild(chip(t.key, false, () => setTopic(t.key))));
const fc = el("flavor-chips");
fc.appendChild(Object.assign(document.createElement("span"),
{ className: "chip-group-label", textContent: "Flavor" }));
fc.appendChild(chip("all", true, () => setFlavor(null)));
cats.flavors.forEach(f => fc.appendChild(chip(f.key, false, () => setFlavor(f.key))));
// panel controls
el("calm-toggle").onclick = () => el("panel").classList.toggle("open");
el("term-add").onclick = addTerm;
el("term-input").addEventListener("keydown", (e) => { if (e.key === "Enter") addTerm(); });
el("reset").onclick = () => { prefs = blankPrefs(); savePrefs(); refreshAll(); };
refreshAll();
}
function addTerm() {
const v = el("term-input").value.trim();
if (v && !prefs.avoid_terms.includes(v)) { prefs.avoid_terms.push(v); savePrefs(); }
el("term-input").value = "";
refreshAll();
}
function refreshActive(containerId, value) {
[...el(containerId).querySelectorAll(".chip")].forEach(c => {
c.classList.toggle("active", c.textContent === (value || "all"));
});
}
function setTopic(t) { browse.topic = t; refreshActive("topic-chips", t); loadFeed(); }
function setFlavor(f) { browse.flavor = f; refreshActive("flavor-chips", f); loadFeed(); }
init();
</script>
</body>
</html>