Files
familienarchiv/docs/adr/003-chronik-unified-activity-feed.md
Marcel 6b433fa82a feat(chronik): add ADR-003 + Paraglide keys for /chronik page (de/en/es)
- docs/adr/003-chronik-unified-activity-feed.md: records the session-rollup
  decision (LAG + 120-min gap), the dedupe deletion, the single-endpoint
  composition, and the German-URL convention.
- frontend/messages/{de,en,es}.json: adds chronik_* keys for page title,
  Für-dich box, filter pills, day headers, singleton/rollup verb variants
  per kind, empty states, error card, Mehr-laden pagination, and the Bell
  footer link retarget.

No pluralization via ICU match — separate singleton/rollup keys per verb,
per the Felix discussion (comment #3573).

Part of #285.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:38:10 +02:00

4.9 KiB

ADR-003: Session-Rollup Unified Activity Feed on /chronik

Status

Accepted

Context

The app had two disconnected ways to see what was happening in the archive:

  1. /notifications — personal mentions/replies only, delivered via the notifications table and a Bell dropdown.
  2. Dashboard activity feed — ambient events (uploads, transcription, annotations, comments, mentions) via /api/dashboard/activity, which deduplicated using DISTINCT ON (actor_id, document_id, kind, date_trunc('hour', happened_at)).

Two separate lists was a poor mental model (personal vs. ambient feel the same to the user), the /notifications page wasted horizontal space, the dashboard's "Alle anzeigen" pointed to /documents (dead-end), and the hour-trunc dedupe produced ugly splits on natural sessions — saving 20 transcription blocks at 08:58, 08:59, 09:01 yielded two rows.

We needed one page that merges both streams, keeps personal mentions visually loud, and aggregates ambient noise coherently.

Decision

One page /chronik backed by two endpoints. The SvelteKit +page.server.ts composes data from /api/dashboard/activity (for the ambient timeline) and /api/notifications (for the "Für dich" box). No new /api/chronik orchestrator — the frontend load function is the composition seam.

Session-style rollup replaces hour-trunc dedupe everywhere. AuditLogQueryRepository.findDedupedActivityFeed is renamed to findRolledUpActivityFeed and rewritten using a LAG()-based session algorithm:

LAG(happened_at) OVER (PARTITION BY actor_id, document_id, kind ORDER BY happened_at)
  → is_new_session = gap > 7200s (or first event in partition, or kind ∈ {COMMENT_ADDED, MENTION_CREATED})
  → SUM(is_new_session) OVER (... ROWS UNBOUNDED PRECEDING) = session_id
  → GROUP BY (actor_id, document_id, kind, session_id) → MIN(happened_at), MAX(...), COUNT(*)

Events within 120 min on the same (actor, document, kind) become one row with count and happenedAtUntil fields. COMMENT_ADDED and MENTION_CREATED always start a new session — these kinds never roll up. No hard cap on total session span (a 4-hour transcription sitting is one row). The hour-trunc dedupe SQL is deleted, not kept alongside — one aggregation strategy per query.

URL is universal German /chronik across all locales, matching the existing convention (/dokumente, /personen, /briefwechsel). Content is translated via Paraglide; the URL is a stable German identifier, not a translatable route.

DTO extended, not replaced. ActivityFeedItemDTO gains count: int (required, 1 for singletons) and happenedAtUntil: OffsetDateTime? (null for singletons, end-of-session for rollups). One DTO shape serves both the Chronik timeline and the dashboard side-rail.

/notifications route is deleted outright. The app is pre-production — no 301 redirect, no zombie page.

Alternatives Considered

Alternative Why rejected
Fixed 2-hour wall-clock buckets (date_trunc('hour', happened_at / 2)) Splits natural sessions at bucket boundaries (e.g. events at 13:58 / 13:59 / 14:01 land in two rollup rows)
Keep DISTINCT ON hour-trunc alongside new rollup query Two aggregation strategies = zombie logic; dashboard and Chronik would drift
New /api/chronik endpoint that merges both streams Couples two domains (notifications + audit) at the API layer; composition belongs in +page.server.ts
Localized URL slugs (/chronik / /chronicle / /crónica) Breaks the project's existing German-URL convention and adds Paraglide routing overhead for zero UX value
Per-locale rollup in the SQL (e.g. align to local-day boundaries) Timezone-aware SQL is brittle; rollup is a time-gap concept, not a calendar-day concept

Consequences

Easier:

  • One hot path — /api/dashboard/activity is backed by a single partial covering index (V49__add_audit_log_rollup_index.sql) that matches the rollup query's WHERE clause exactly.
  • Dashboard side-rail gets rollup for free — 20 block-saves appear as one "Papa transkribierte 20 Blöcke" row with a time range, not 20 dedup'd hour buckets.
  • Component reuse — ChronikRow.svelte renders both singleton and rollup variants via a $derived discriminator; DashboardActivityFeed.svelte consumes the same DTO shape.

Harder:

  • The session SQL is ~15 lines longer than DISTINCT ON. That's the price for not splitting natural sessions at fixed boundaries — worth it on day one.
  • Historical /api/dashboard/activity consumers now see count and happenedAtUntil. No breaking change — count defaults to 1, happenedAtUntil is nullable — but pre-existing tests needed updating.
  • Rollup is load-bearing for the UX — if the index is missing or the query regresses, the page either runs slow or returns duplicate rows. Covered by the rolledUp integration tests and the partial covering index; worth a follow-up Grafana panel on /api/dashboard/activity p95 latency.