Files
siftlode/backend/app
peter f3c0b7be23 feat(e24): the Home surface — one question, one answer, both sources
E24 S2. A new `home` module in layout C+ (the mockup the owner picked): the
time-budget chips on top, one large hero that answers them, then the comfort
shelf and a cross-source resume rail. Home is the landing — deliberately OVER
the remembered last module, since landing on whatever you happened to close the
app on is the habit this epic exists to replace; `LS.startOnHome = "0"` opts an
account back out (Settings toggle lands in S4).

No third card component: each unified card carries the native feed / Plex
payload, so the rails render through the existing VideoCard (`cardsSmall`) and
PlexPosterCard, and `aspect` decides the tile shape without branching on source.
Plex playback hands off to the Plex module via a new `jumpTo` type — the player
lives there with its lazy hls.js and its provider tree, and mounting a second one
on the landing page to save one hand-off would be the expensive way round.

Four defects found by driving it in a real browser against the live account, each
now pinned by a mutation-checked test:

- **The reroll gave the same card back.** A new seed only re-draws the SURPRISE
  tier; the resume tier deterministically returns the most recently progressed
  thing that fits, so anyone with something half-finished pressed a dead button.
  `/pick` now takes an `exclude` list of `<source>:<id>` keys and the client keeps
  a rolling window of 8. Excluding a SHOW also excludes the episode it resolves
  to — otherwise the same suggestion comes back wearing a hat.
- **"Nothing to suggest yet" flashed over a full library** whenever a reroll was
  in flight: the new query key has no data for a beat. An empty state standing in
  for a loading one is the dishonesty the house rules name outright. The hero now
  falls back to the loaded payload while a pick is pending, and only trusts a null
  once the pick has RESOLVED.
- **A budget change replaced the page — chips included — with a spinner**, so the
  control you just clicked vanished under your cursor. `keepPreviousData`, the
  same reason the feed uses it.
- **The hero printed twice**, once as itself and once as the first resume tile.

Two smaller UI calls made from the same session: the budget caption sits NEXT to
its chips rather than in SegmentedToggle's justify-between mode, which on an 80rem
page throws the answers to the far edge; and the feed takes the YouTube mark its
own toolbar already uses, since Home now owns the house icon.

Gate green: ruff, ruff format, 3x tsc, eslint, prettier, knip, vitest 780,
pytest 989. Verified end to end on localdev against the real account: budget
chips re-answer (matched on REMAINING time — a 51-minute video with 2:46 left is
a ten-minute answer), the reroll walks three distinct heroes, and a Plex show
tile opens its drill-down in the Plex module.
2026-09-06 04:14:14 +02:00
..
2026-06-14 04:40:22 +02:00

Backend conventions

The rules below are already followed across backend/app — they lived only in a few module docstrings (demo.py, userscope.py, routes/_common.py) until this file collected them (E3 S3, B-core-20). When you add code, match these so the next reader doesn't have to guess "which pattern do I copy".

Module layering

Three layers, by who runs the code — not by feature:

  1. routes/… — request-only. A route module holds HTTP concerns: auth dependencies, request/ response models, status-code mapping, serialization. It owns logic that runs only on a request. Business logic that a request happens to trigger does not live here if anything else runs it too.

  2. plex/, downloads/, sync/ — domain logic shared by more than one caller. Anything the scheduler or the worker runs (not just a request) lives in one of these domain packages, so the route, the worker, and the scheduler share one definition. Examples from this codebase: downloads/service.py owns enqueue + the job state transitions (routes call it); plex/sync.py is run by both the admin route and the scheduler; downloads/pipeline.py is the worker's child pipeline.

  3. Flat app/*.py core modules — cross-cutting primitives. auth, sysconfig, quota, audit, notifications, userscope, realtime, ratelimit, progress, … are single-file primitives many layers import. They depend on nothing above them (a core module must never import a routes/… module).

Extraction trigger: the SECOND consumer. Keep new logic inline in its first caller. The moment a second caller needs it (a worker as well as a route; a scheduler as well as a route), extract it down a layer — into the relevant domain package, or a flat core module if it's cross-cutting. Don't pre-abstract for a single caller; don't leave shared logic duplicated once there are two.

Transaction ownership

This follows CLAUDE.md's canonical rule verbatim — "the handler/job-wrapper commits; helpers flush (only quota owns its session)" — so read it that way, not as an exception to it. The commit belongs to the operation owner; anything called inside another transaction only stages its change (db.add / flush) and lets the owner commit, so the whole operation is one transaction. audit.record() is the reference: it adds the audit row and the caller commits it together with the mutation it records, so an audit entry can never exist without its change (or vice-versa).

  • The operation owner is usually the route handler. A service.* operation the handler delegates a whole transition to (e.g. service.cancel) is that owner and commits its one transition — it isn't a mid-operation helper. A helper that owns its OWN short-lived session (the worker's jobdb writes, the same carve-out CLAUDE.md grants quota) commits that session. Everything else flushes.
  • SessionLocal is expire_on_commit=False: after a raw UPDATE + commit, an ORM object you already loaded is stale. Read the fresh value from the row (RETURNING, or a re-SELECT), not the ORM attribute — see service.decr_ref_count.

Shared counters and status: do it atomically at the DB

  • Never read-modify-write a shared counter in Python (x.count = x.count + 1). Concurrent sessions lose an update. Use a DB-atomic statement — col = col + 1, or greatest(col - 1, 0) with RETURNING so the caller branches on the post-value under the row lock (service.incr_ref_count / decr_ref_count).
  • Download job status is a JobStatus StrEnum (downloads/status.py), not bare strings. The HOLDING set defines which states hold a MediaAsset (so release accounting stays correct); STOPPED_BY_ROUTE is the route-initiated stop set. A worker child's terminal write goes through service.finalize_if_running (an atomic UPDATE … WHERE status='running') so a finishing child can't resurrect a job a route just stopped.

Config: one home per kind

  • app.config.settings — boot/env config (DB URL, download root, worker concurrency): read once at startup, not user-tunable at runtime.
  • sysconfig.SPECS — the runtime admin knobs (DB-override-or-default). A new admin-tunable value goes in SPECS; don't invent a fourth store. (state.py's maintenance_batch predates this and is slated to fold into sysconfig — do not add more like it.)

The gate

Everything below must be green before a change is done (run via siftlode check): ruff (backend) · tsc ×3 + eslint + prettier + knip (frontend) · vitest · pytest. The pytest lane runs in backend/docker-compose.test.yml — an ephemeral Postgres + the backend tree bind-mounted — so the DB-backed integration tests (the db fixture) actually run; SIFTLODE_TEST_ REQUIRE_DB=1 there turns a would-be silent skip into a hard failure. See docs/development.md.