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>
This commit is contained in:
jay
2026-05-30 19:16:42 +00:00
parent 9cdcda5e02
commit 091dec64ae
5 changed files with 368 additions and 42 deletions
+28
View File
@@ -104,6 +104,34 @@ Endpoints:
The ingestion CLI stays pure-stdlib; only the `web` extra pulls in FastAPI/uvicorn, The ingestion CLI stays pure-stdlib; only the `web` extra pulls in FastAPI/uvicorn,
so the two halves can be deployed and upgraded independently. so the two halves can be deployed and upgraded independently.
## Calm Filters
Personal, device-local controls so a reader can stay informed without subjects
they'd rather not see right now. Preferences live in the browser (localStorage),
are sent to the read endpoints as a `prefs` JSON query param, and are applied
identically to the feed, the brief, and the category counts so the numbers always
match what's shown. The canonical shape (`goodnews/filters.py`):
```json
{
"include_topics": [], "include_flavors": [],
"mute_topics": [], "mute_flavors": [],
"avoid_terms": ["election", "stock market"],
"pauses": [{"kind": "topic", "value": "health", "until": "2026-06-02T00:00:00Z"}]
}
```
The site surfaces a humane ladder rather than a settings panel of dread:
- **Not today** → pause that article's topic for 24h.
- **Less like this** → ease off that flavor for ~3 days.
- **Always hide …** → a standing mute (undoable in the Calm filters panel).
Avoid-terms match whole words/phrases (case- and punctuation-insensitive, no
substring surprises like "pan" matching "pandemic"). The brief is filtered *down*
for MVP (no refill from outside the stored brief). No accounts; the same `prefs`
object is the clean migration path to server-side, multi-user preferences later.
## Deployment ## Deployment
The database is never baked into the image — the API and the ingestion CLI share The database is never baked into the image — the API and the ingestion CLI share
+43 -12
View File
@@ -15,7 +15,9 @@ from __future__ import annotations
import os import os
import sqlite3 import sqlite3
from collections import Counter
from contextlib import contextmanager from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, HTTPException, Query from fastapi import FastAPI, HTTPException, Query
@@ -25,6 +27,7 @@ from pydantic import BaseModel
from . import queries from . import queries
from .db import connect, init_db from .db import connect, init_db
from .filters import filter_articles, prefs_from_json
from .taxonomy import FLAVORS, TOPICS from .taxonomy import FLAVORS, TOPICS
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
@@ -148,9 +151,21 @@ def create_app() -> FastAPI:
) )
@app.get("/api/category-counts", response_model=list[CategoryCount]) @app.get("/api/category-counts", response_model=list[CategoryCount])
def category_counts(accepted_only: bool = True) -> list[CategoryCount]: def category_counts(accepted_only: bool = True, prefs: str | None = Query(None)) -> list[CategoryCount]:
fp = prefs_from_json(prefs)
with get_conn() as conn: with get_conn() as conn:
rows = queries.category_counts(conn, accepted_only=accepted_only) if fp.is_empty():
rows = queries.category_counts(conn, accepted_only=accepted_only)
else:
# Count over the SAME filtered set the feed would return, so the
# browse numbers always match what the user actually sees.
allrows = queries.feed(conn, accepted_only=accepted_only, limit=100000, offset=0)
kept = filter_articles(allrows, fp, datetime.now(timezone.utc))
counts = Counter((r["topic"], r["flavor"]) for r in kept)
rows = [
{"topic": t, "flavor": f, "count": n}
for (t, f), n in sorted(counts.items(), key=lambda kv: (str(kv[0][0]), str(kv[0][1])))
]
return [CategoryCount(**row) for row in rows] return [CategoryCount(**row) for row in rows]
@app.get("/api/feed", response_model=FeedResponse) @app.get("/api/feed", response_model=FeedResponse)
@@ -160,20 +175,27 @@ def create_app() -> FastAPI:
accepted_only: bool = True, accepted_only: bool = True,
limit: int = Query(30, ge=1, le=100), limit: int = Query(30, ge=1, le=100),
offset: int = Query(0, ge=0), offset: int = Query(0, ge=0),
prefs: str | None = Query(None),
) -> FeedResponse: ) -> FeedResponse:
if topic and topic.lower() not in TOPICS: if topic and topic.lower() not in TOPICS:
raise HTTPException(400, f"unknown topic: {topic}") raise HTTPException(400, f"unknown topic: {topic}")
if flavor and flavor.lower() not in FLAVORS: if flavor and flavor.lower() not in FLAVORS:
raise HTTPException(400, f"unknown flavor: {flavor}") raise HTTPException(400, f"unknown flavor: {flavor}")
fp = prefs_from_json(prefs)
with get_conn() as conn: with get_conn() as conn:
rows = queries.feed( if fp.is_empty():
conn, rows = queries.feed(
topic=topic, conn, topic=topic, flavor=flavor, accepted_only=accepted_only, limit=limit, offset=offset
flavor=flavor, )
accepted_only=accepted_only, else:
limit=limit, # Over-fetch, apply the calm filters in Python (word-boundary
offset=offset, # avoid-terms can't be done in SQL), then slice to the page.
) fetch_n = min(2000, (offset + limit) * 4 + 50)
raw = queries.feed(
conn, topic=topic, flavor=flavor, accepted_only=accepted_only, limit=fetch_n, offset=0
)
filtered = filter_articles(raw, fp, datetime.now(timezone.utc))
rows = filtered[offset : offset + limit]
return FeedResponse( return FeedResponse(
topic=topic, topic=topic,
flavor=flavor, flavor=flavor,
@@ -182,13 +204,22 @@ def create_app() -> FastAPI:
) )
@app.get("/api/brief", response_model=BriefResponse) @app.get("/api/brief", response_model=BriefResponse)
def brief(date: str | None = Query(None), limit: int = Query(10, ge=1, le=50)) -> BriefResponse: def brief(
date: str | None = Query(None),
limit: int = Query(10, ge=1, le=50),
prefs: str | None = Query(None),
) -> BriefResponse:
fp = prefs_from_json(prefs)
with get_conn() as conn: with get_conn() as conn:
data = queries.brief(conn, brief_date=date, limit=limit) data = queries.brief(conn, brief_date=date, limit=limit)
items = data["items"]
if not fp.is_empty():
# MVP: filter the stored brief DOWN; no refill from outside the brief.
items = filter_articles(items, fp, datetime.now(timezone.utc))
return BriefResponse( return BriefResponse(
brief_date=data["brief_date"], brief_date=data["brief_date"],
title=data["title"], title=data["title"],
items=[Article.from_row(r) for r in data["items"]], items=[Article.from_row(r) for r in items],
) )
@app.get("/api/brief-dates", response_model=list[str]) @app.get("/api/brief-dates", response_model=list[str])
+46 -8
View File
@@ -13,9 +13,10 @@ rather not see.
from __future__ import annotations from __future__ import annotations
import json
import re import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime, timezone
# Split on any run of non-alphanumerics so matching is punctuation- and # Split on any run of non-alphanumerics so matching is punctuation- and
# case-insensitive, and anchored to whole words/phrases (no substring surprises: # case-insensitive, and anchored to whole words/phrases (no substring surprises:
@@ -51,6 +52,12 @@ class Pause:
until = datetime.fromisoformat(self.until.replace("Z", "+00:00")) until = datetime.fromisoformat(self.until.replace("Z", "+00:00"))
except (ValueError, AttributeError): except (ValueError, AttributeError):
return False return False
# Defensive: never crash on an aware-vs-naive comparison. Treat a naive
# `now` (and a naive `until`) as UTC.
if now.tzinfo is None:
now = now.replace(tzinfo=timezone.utc)
if until.tzinfo is None:
until = until.replace(tzinfo=timezone.utc)
return until > now return until > now
@@ -65,14 +72,30 @@ class FilterPrefs:
@classmethod @classmethod
def from_dict(cls, data: dict | None) -> "FilterPrefs": def from_dict(cls, data: dict | None) -> "FilterPrefs":
data = data or {} if not isinstance(data, dict):
return cls()
def _str_list(value: object) -> list[str]:
if not isinstance(value, list):
return []
return [str(v) for v in value if isinstance(v, str)]
# Be forgiving: a malformed pause is skipped, never raised — a bad
# localStorage/API blob must not break the feed.
pauses: list[Pause] = []
for p in data.get("pauses") or []:
try:
pauses.append(Pause(kind=p["kind"], value=p["value"], until=p["until"]))
except (KeyError, TypeError):
continue
return cls( return cls(
include_topics=list(data.get("include_topics") or []), include_topics=_str_list(data.get("include_topics")),
include_flavors=list(data.get("include_flavors") or []), include_flavors=_str_list(data.get("include_flavors")),
mute_topics=list(data.get("mute_topics") or []), mute_topics=_str_list(data.get("mute_topics")),
mute_flavors=list(data.get("mute_flavors") or []), mute_flavors=_str_list(data.get("mute_flavors")),
avoid_terms=list(data.get("avoid_terms") or []), avoid_terms=_str_list(data.get("avoid_terms")),
pauses=[Pause(**p) for p in (data.get("pauses") or [])], pauses=pauses,
) )
def muted_topics(self, now: datetime) -> set[str]: def muted_topics(self, now: datetime) -> set[str]:
@@ -97,6 +120,21 @@ class FilterPrefs:
) )
def prefs_from_json(raw: str | None) -> FilterPrefs:
"""Parse a JSON prefs string (from a query param) into FilterPrefs.
Never raises on bad input — a malformed blob yields empty prefs so the feed
keeps working.
"""
if not raw:
return FilterPrefs()
try:
data = json.loads(raw)
except (ValueError, TypeError):
return FilterPrefs()
return FilterPrefs.from_dict(data)
def allows(article: dict, prefs: FilterPrefs, now: datetime) -> bool: def allows(article: dict, prefs: FilterPrefs, now: datetime) -> bool:
"""True if an article (a feed/brief row dict) survives the preferences.""" """True if an article (a feed/brief row dict) survives the preferences."""
topic = article.get("topic") topic = article.get("topic")
+216 -22
View File
@@ -16,11 +16,17 @@
} }
header { header {
padding: 28px 20px 18px; text-align: center; border-bottom: 1px solid var(--line); padding: 28px 20px 18px; text-align: center; border-bottom: 1px solid var(--line);
background: var(--card); background: var(--card); position: relative;
} }
header h1 { margin: 0; font-size: 1.7rem; letter-spacing: -0.02em; } header h1 { margin: 0; font-size: 1.7rem; letter-spacing: -0.02em; }
header h1 span { color: var(--accent); } header h1 span { color: var(--accent); }
header p { margin: 6px 0 0; color: var(--muted); font-size: 0.95rem; } 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; } main { max-width: 760px; margin: 0 auto; padding: 20px; }
.chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 6px 0 18px; } .chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 6px 0 18px; }
.chip { .chip {
@@ -50,7 +56,38 @@
.rank-badge { background: var(--accent); color: #fff; border-radius: 50%; .rank-badge { background: var(--accent); color: #fff; border-radius: 50%;
width: 24px; height: 24px; display: inline-flex; align-items: center; width: 24px; height: 24px; display: inline-flex; align-items: center;
justify-content: center; font-size: 0.8rem; font-weight: 700; } 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; } .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 { text-align: center; color: var(--muted); font-size: 0.78rem; padding: 20px; }
footer a { color: var(--accent); } footer a { color: var(--accent); }
</style> </style>
@@ -59,8 +96,31 @@
<header> <header>
<h1>good<span>News</span></h1> <h1>good<span>News</span></h1>
<p>Calm, constructive news worth your attention — and nothing that isn't.</p> <p>Calm, constructive news worth your attention — and nothing that isn't.</p>
<button id="calm-toggle" class="calm-btn">Calm filters</button>
</header> </header>
<main> <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 id="brief-block">
<div class="section-title">Five Good Things</div> <div class="section-title">Five Good Things</div>
<div id="brief"><div class="empty">Loading…</div></div> <div id="brief"><div class="empty">Loading…</div></div>
@@ -77,9 +137,55 @@
</footer> </footer>
<script> <script>
const state = { topic: null, flavor: null }; const PREFS_KEY = "goodnews:prefs";
const browse = { topic: null, flavor: null };
const el = (id) => document.getElementById(id); 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) { async function getJSON(url) {
const r = await fetch(url); const r = await fetch(url);
if (!r.ok) throw new Error(await r.text()); if (!r.ok) throw new Error(await r.text());
@@ -88,22 +194,54 @@
function articleCard(a, showRank) { function articleCard(a, showRank) {
const rank = showRank && a.rank ? `<span class="rank-badge">${a.rank}</span>` : ""; const rank = showRank && a.rank ? `<span class="rank-badge">${a.rank}</span>` : "";
const tags = [a.topic, a.flavor].filter(Boolean) const tags = [a.topic, a.flavor].filter(Boolean).map(t => `<span class="tag">${t}</span>`).join(" ");
.map(t => `<span class="tag">${t}</span>`).join(" ");
const desc = a.description ? `<p class="desc">${a.description}</p>` : ""; const desc = a.description ? `<p class="desc">${a.description}</p>` : "";
const why = a.reason_text ? `<div class="why">${a.reason_text}</div>` : ""; 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> return `<article>
<div class="meta">${rank}${tags}<span>${a.source}</span> <div class="meta">${rank}${tags}<span>${a.source}</span>
${a.published_at ? `<span>· ${a.published_at.slice(0,10)}</span>` : ""}</div> ${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> <h3><a href="${a.url}" target="_blank" rel="noopener">${a.title}</a></h3>
${desc}${why} ${desc}${why}
<div class="actions">${acts.join("")}</div>
</article>`; </article>`;
} }
function renderList(target, items, showRank) { function renderList(target, items, showRank) {
target.innerHTML = items.length target.innerHTML = items.length
? items.map(a => articleCard(a, showRank)).join("") ? items.map(a => articleCard(a, showRank)).join("")
: `<div class="empty">Nothing here yet.</div>`; : `<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) { function chip(label, active, onClick) {
@@ -114,26 +252,69 @@
return c; return c;
} }
async function loadBrief() { function pill(label, onRemove) {
try { const span = document.createElement("span");
const b = await getJSON("/api/brief?limit=5"); span.className = "pill";
if (b.title) el("brief-block").querySelector(".section-title").textContent = b.title; span.append(label);
renderList(el("brief"), b.items, true); const x = document.createElement("button");
} catch (e) { el("brief").innerHTML = `<div class="empty">No brief yet.</div>`; } x.textContent = "×"; x.title = "remove"; x.onclick = onRemove;
span.append(x);
return span;
} }
async function loadFeed() { function renderPanel() {
const params = new URLSearchParams({ limit: "30" }); const pauses = el("panel-pauses"); pauses.innerHTML = "";
if (state.topic) params.set("topic", state.topic); if (!prefs.pauses.length) pauses.append(Object.assign(document.createElement("span"),
if (state.flavor) params.set("flavor", state.flavor); { className: "hint", textContent: "Nothing paused." }));
const f = await getJSON("/api/feed?" + params.toString()); prefs.pauses.forEach((p, i) => {
renderList(el("feed"), f.items, false); 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() { async function init() {
loadBrief(); wireActions(el("feed"));
const cats = await getJSON("/api/categories"); wireActions(el("brief"));
const cats = await getJSON("/api/categories");
const tc = el("topic-chips"); const tc = el("topic-chips");
tc.appendChild(Object.assign(document.createElement("span"), tc.appendChild(Object.assign(document.createElement("span"),
{ className: "chip-group-label", textContent: "Topic" })); { className: "chip-group-label", textContent: "Topic" }));
@@ -146,7 +327,20 @@
fc.appendChild(chip("all", true, () => setFlavor(null))); fc.appendChild(chip("all", true, () => setFlavor(null)));
cats.flavors.forEach(f => fc.appendChild(chip(f.key, false, () => setFlavor(f.key)))); cats.flavors.forEach(f => fc.appendChild(chip(f.key, false, () => setFlavor(f.key))));
loadFeed(); // 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) { function refreshActive(containerId, value) {
@@ -154,8 +348,8 @@
c.classList.toggle("active", c.textContent === (value || "all")); c.classList.toggle("active", c.textContent === (value || "all"));
}); });
} }
function setTopic(t) { state.topic = t; refreshActive("topic-chips", t); loadFeed(); } function setTopic(t) { browse.topic = t; refreshActive("topic-chips", t); loadFeed(); }
function setFlavor(f) { state.flavor = f; refreshActive("flavor-chips", f); loadFeed(); } function setFlavor(f) { browse.flavor = f; refreshActive("flavor-chips", f); loadFeed(); }
init(); init();
</script> </script>
+35
View File
@@ -4,6 +4,7 @@ from goodnews.filters import (
FilterPrefs, FilterPrefs,
Pause, Pause,
filter_articles, filter_articles,
prefs_from_json,
text_matches_avoid_terms, text_matches_avoid_terms,
) )
@@ -86,3 +87,37 @@ def test_pause_active_helper():
assert Pause("topic", "health", "2026-06-02T00:00:00Z").active(NOW) assert Pause("topic", "health", "2026-06-02T00:00:00Z").active(NOW)
assert not Pause("topic", "health", "2026-05-01T00:00:00Z").active(NOW) assert not Pause("topic", "health", "2026-05-01T00:00:00Z").active(NOW)
assert not Pause("topic", "health", "garbage").active(NOW) assert not Pause("topic", "health", "garbage").active(NOW)
def test_pause_active_tolerates_naive_now():
# A naive `now` must not raise an aware-vs-naive comparison error.
naive = datetime(2026, 6, 1)
assert Pause("topic", "health", "2026-06-02T00:00:00Z").active(naive)
# --- forgiving parsing (bad blobs must never break the feed) ---
def test_prefs_from_json_tolerates_garbage():
assert prefs_from_json("not json").is_empty()
assert prefs_from_json(None).is_empty()
assert prefs_from_json("[1,2,3]").is_empty() # wrong shape
def test_from_dict_skips_malformed_pauses():
prefs = FilterPrefs.from_dict(
{
"mute_topics": ["health"],
"pauses": [
{"kind": "topic", "value": "science", "until": "2026-06-02T00:00:00Z"},
{"kind": "topic"}, # malformed — missing value/until
"garbage", # not even a dict
],
}
)
assert prefs.mute_topics == ["health"]
assert len(prefs.pauses) == 1 # only the well-formed pause survives
def test_from_dict_ignores_non_string_list_entries():
prefs = FilterPrefs.from_dict({"avoid_terms": ["ok", 5, None, "fine"]})
assert prefs.avoid_terms == ["ok", "fine"]