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
+46 -8
View File
@@ -13,9 +13,10 @@ rather not see.
from __future__ import annotations
import json
import re
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
# 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"))
except (ValueError, AttributeError):
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
@@ -65,14 +72,30 @@ class FilterPrefs:
@classmethod
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(
include_topics=list(data.get("include_topics") or []),
include_flavors=list(data.get("include_flavors") or []),
mute_topics=list(data.get("mute_topics") or []),
mute_flavors=list(data.get("mute_flavors") or []),
avoid_terms=list(data.get("avoid_terms") or []),
pauses=[Pause(**p) for p in (data.get("pauses") or [])],
include_topics=_str_list(data.get("include_topics")),
include_flavors=_str_list(data.get("include_flavors")),
mute_topics=_str_list(data.get("mute_topics")),
mute_flavors=_str_list(data.get("mute_flavors")),
avoid_terms=_str_list(data.get("avoid_terms")),
pauses=pauses,
)
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:
"""True if an article (a feed/brief row dict) survives the preferences."""
topic = article.get("topic")