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:
@@ -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
@@ -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
@@ -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
@@ -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 & 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>
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user