news-digest: per-item × button + cross-device hidden tray

Adds a small × on each item that hides it from the page. State is
server-side at /output/hidden.json so the same hidden set follows
the user across devices (home, ipad, laptop, work). A "Hidden (N)"
tray at the bottom shows what's hidden on the current page with a
restore button per row; older hidden ids that aren't on this page
sit silently and continue to filter future editions that include
the same article.

Architecture change: news-digest-web swaps from nginx:alpine to a
FastAPI app on uvicorn, built from the same Dockerfile as the
worker. Same image, different command (`uvicorn web:app` overrides
the worker's cron entrypoint via compose). Drops one image dependency,
adds /api/{hidden,hide,restore}.

Item ids are stable 12-char sha1 prefixes (`reddit:<post_id>` /
`miniflux:<entry_id>`) computed in digest.py at render time and
emitted as `data-id` on each .item. The frontend reads /api/hidden
once on load, applies `is-hidden` to matching items, and POSTs
hide/restore on user interaction (optimistic, with rollback on
network error).

Storage: single JSON array at /output/hidden.json, atomic writes
via tempfile + rename, threading.Lock around the read-modify-write
inside the single uvicorn worker. No auth — the digest itself is
unauthenticated on LAN; same trust boundary applies.

Playbook also drops the DOCKER_BUILDKIT=0 fallback now that
ana-docker is on docker-ce 29, and adds three verify steps
(/api/hidden returns a JSON array, app.js is reachable, full
hide/restore round-trip with a synthetic id).
This commit is contained in:
vh
2026-04-26 15:05:25 -07:00
parent c1427adc0f
commit f692b7ec7a
9 changed files with 596 additions and 45 deletions
+22 -10
View File
@@ -22,20 +22,21 @@ noisy. This stack:
/ showcase / drama / meme / other).
5. Renders an HTML page styled with Australis tokens + Fraunces
serif headlines.
6. Static page is served by a tiny `nginx:alpine`. Cron writes
`/output/index.html` atomically; nginx serves whichever copy is
there.
6. The page is served by a tiny FastAPI app on uvicorn that also
exposes `/api/{hidden,hide,restore}` for the per-item × button
(state in `/output/hidden.json`, shared across every device the
user opens the digest from).
Two editions per day: 8am and 8pm local. Plus per-edition archives
at `/edition-YYYY-MM-DD-{am,pm}.html`.
## Architecture
Two containers, both on `traefik-net`, sharing a bind-mounted
output dir:
Two containers built from the same Dockerfile, both on `traefik-net`,
sharing a bind-mounted output dir:
```
news-digest-worker (python:3.12-alpine + cron)
news-digest-worker (default ENTRYPOINT — busybox crond)
├── busybox crond fires at 0 8,20 * * *
├── digest.py:
│ ├── miniflux /v1/feeds → discover subreddits
@@ -44,13 +45,24 @@ news-digest-worker (python:3.12-alpine + cron)
│ ├── llama-swap /v1/chat/completions → batched per source
│ └── jinja2 render → /output/index.html (atomic .tmp + rename)
│ → /output/edition-2026-04-26-pm.html
└── style.css served from /output (copied at deploy)
└── style.css / favicon.svg / app.js staged in /output at deploy
news-digest-web (nginx:alpine)
└── serves /output as / on host port 8181
└── homepage card via container labels (group=News)
news-digest-web (entrypoint overridden → uvicorn web:app)
├── / → serve /output as static (index.html as default)
├── /api/hidden GET → JSON array of hidden item ids
├── /api/hide POST → {id} → adds id to hidden.json
├── /api/restore POST → {id} → removes id from hidden.json
├── /output/hidden.json — durable state (atomic writes + threading lock)
└── homepage card via container labels (group=News)
```
Hidden state is server-side and global per-user (single-user setup):
hide an article once and it stays hidden in any future edition that
includes the same article. The "Hidden (N)" tray at the bottom of
each page shows items hidden FROM THE CURRENT PAGE; older hidden ids
that aren't present on this page just sit silently in `hidden.json`
and continue to filter future editions.
## Deploy
```bash