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,
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
The database is never baked into the image — the API and the ingestion CLI share