From 6b433fa82a8bea9ef23c294465160a6edc05c872 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 20 Apr 2026 16:31:28 +0200 Subject: [PATCH] feat(chronik): add ADR-003 + Paraglide keys for /chronik page (de/en/es) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- docs/adr/003-chronik-unified-activity-feed.md | 59 +++++++++++++++++++ frontend/messages/de.json | 41 ++++++++++++- frontend/messages/en.json | 41 ++++++++++++- frontend/messages/es.json | 41 ++++++++++++- 4 files changed, 179 insertions(+), 3 deletions(-) create mode 100644 docs/adr/003-chronik-unified-activity-feed.md diff --git a/docs/adr/003-chronik-unified-activity-feed.md b/docs/adr/003-chronik-unified-activity-feed.md new file mode 100644 index 00000000..1c6e25de --- /dev/null +++ b/docs/adr/003-chronik-unified-activity-feed.md @@ -0,0 +1,59 @@ +# 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. diff --git a/frontend/messages/de.json b/frontend/messages/de.json index bcf31c72..253449f6 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -751,5 +751,44 @@ "audit_action_comment_added": "hat kommentiert:", "audit_action_mention_created": "hat dich erwähnt in", - "dropzone_release": "Loslassen zum Hochladen" + "dropzone_release": "Loslassen zum Hochladen", + + "chronik_page_title": "Chronik", + "chronik_for_you_caption": "Für dich", + "chronik_for_you_count": "{count} neu", + "chronik_mark_read_aria": "Als gelesen markieren", + "chronik_mark_all_read": "Alle gelesen", + "chronik_inbox_zero_title": "Keine neuen Erwähnungen", + "chronik_inbox_zero_link": "Ältere Erwähnungen ansehen →", + "chronik_filter_label": "Aktivitäten filtern", + "chronik_filter_all": "Alle", + "chronik_filter_for_you": "Für dich", + "chronik_filter_uploaded": "Hochgeladen", + "chronik_filter_transcription": "Transkription", + "chronik_filter_comments": "Kommentare", + "chronik_day_today": "Heute", + "chronik_day_yesterday": "Gestern", + "chronik_day_this_week": "Diese Woche", + "chronik_day_older": "Älter", + "chronik_singleton_text_saved": "{actor} transkribierte einen Block in {doc}", + "chronik_rollup_text_saved": "{actor} transkribierte {count} Blöcke in {doc}", + "chronik_singleton_uploaded": "{actor} lud {doc} hoch", + "chronik_rollup_uploaded": "{actor} lud {count} Dokumente hoch", + "chronik_singleton_reviewed": "{actor} überprüfte einen Block in {doc}", + "chronik_rollup_reviewed": "{actor} überprüfte {count} Blöcke in {doc}", + "chronik_singleton_annotated": "{actor} annotierte {doc}", + "chronik_rollup_annotated": "{actor} annotierte {doc} {count}×", + "chronik_comment_added": "{actor} kommentierte {doc}", + "chronik_mention_created": "{actor} erwähnte dich in {doc}", + "chronik_reply_received": "{actor} antwortete dir in {doc}", + "chronik_empty_first_run_title": "Noch nichts geschehen", + "chronik_empty_first_run_body": "Sobald jemand aus der Familie Dokumente hochlädt oder transkribiert, erscheint hier die Aktivität.", + "chronik_empty_filter_title": "Nichts in dieser Ansicht", + "chronik_empty_filter_body": "In diesem Filter gibt es keine Einträge.", + "chronik_error_title": "Die Chronik konnte nicht geladen werden.", + "chronik_error_retry": "Erneut versuchen", + "chronik_load_more": "Mehr laden", + "chronik_loading": "Lädt …", + "chronik_load_more_announcement": "{count} weitere Einträge geladen", + "chronik_view_all": "Zur Chronik →" } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index ae06a435..80be4863 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -751,5 +751,44 @@ "audit_action_comment_added": "commented:", "audit_action_mention_created": "mentioned you in", - "dropzone_release": "Release to upload" + "dropzone_release": "Release to upload", + + "chronik_page_title": "Chronicle", + "chronik_for_you_caption": "For you", + "chronik_for_you_count": "{count} new", + "chronik_mark_read_aria": "Mark as read", + "chronik_mark_all_read": "Mark all read", + "chronik_inbox_zero_title": "No new mentions", + "chronik_inbox_zero_link": "See older mentions →", + "chronik_filter_label": "Filter activity", + "chronik_filter_all": "All", + "chronik_filter_for_you": "For you", + "chronik_filter_uploaded": "Uploaded", + "chronik_filter_transcription": "Transcription", + "chronik_filter_comments": "Comments", + "chronik_day_today": "Today", + "chronik_day_yesterday": "Yesterday", + "chronik_day_this_week": "This week", + "chronik_day_older": "Older", + "chronik_singleton_text_saved": "{actor} transcribed a block in {doc}", + "chronik_rollup_text_saved": "{actor} transcribed {count} blocks in {doc}", + "chronik_singleton_uploaded": "{actor} uploaded {doc}", + "chronik_rollup_uploaded": "{actor} uploaded {count} documents", + "chronik_singleton_reviewed": "{actor} reviewed a block in {doc}", + "chronik_rollup_reviewed": "{actor} reviewed {count} blocks in {doc}", + "chronik_singleton_annotated": "{actor} annotated {doc}", + "chronik_rollup_annotated": "{actor} annotated {doc} {count}×", + "chronik_comment_added": "{actor} commented on {doc}", + "chronik_mention_created": "{actor} mentioned you in {doc}", + "chronik_reply_received": "{actor} replied to you in {doc}", + "chronik_empty_first_run_title": "Nothing has happened yet", + "chronik_empty_first_run_body": "As soon as someone in the family uploads or transcribes a document, the activity will show up here.", + "chronik_empty_filter_title": "Nothing in this view", + "chronik_empty_filter_body": "There are no entries for this filter.", + "chronik_error_title": "The chronicle could not be loaded.", + "chronik_error_retry": "Try again", + "chronik_load_more": "Load more", + "chronik_loading": "Loading …", + "chronik_load_more_announcement": "{count} more entries loaded", + "chronik_view_all": "Open chronicle →" } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 57af4ab6..84e2cfeb 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -751,5 +751,44 @@ "audit_action_comment_added": "comentó:", "audit_action_mention_created": "te mencionó en", - "dropzone_release": "Suelta para subir" + "dropzone_release": "Suelta para subir", + + "chronik_page_title": "Crónica", + "chronik_for_you_caption": "Para ti", + "chronik_for_you_count": "{count} nuevas", + "chronik_mark_read_aria": "Marcar como leído", + "chronik_mark_all_read": "Marcar todas leídas", + "chronik_inbox_zero_title": "Sin nuevas menciones", + "chronik_inbox_zero_link": "Ver menciones anteriores →", + "chronik_filter_label": "Filtrar actividad", + "chronik_filter_all": "Todas", + "chronik_filter_for_you": "Para ti", + "chronik_filter_uploaded": "Subidos", + "chronik_filter_transcription": "Transcripción", + "chronik_filter_comments": "Comentarios", + "chronik_day_today": "Hoy", + "chronik_day_yesterday": "Ayer", + "chronik_day_this_week": "Esta semana", + "chronik_day_older": "Anterior", + "chronik_singleton_text_saved": "{actor} transcribió un bloque en {doc}", + "chronik_rollup_text_saved": "{actor} transcribió {count} bloques en {doc}", + "chronik_singleton_uploaded": "{actor} subió {doc}", + "chronik_rollup_uploaded": "{actor} subió {count} documentos", + "chronik_singleton_reviewed": "{actor} revisó un bloque en {doc}", + "chronik_rollup_reviewed": "{actor} revisó {count} bloques en {doc}", + "chronik_singleton_annotated": "{actor} anotó {doc}", + "chronik_rollup_annotated": "{actor} anotó {doc} {count}×", + "chronik_comment_added": "{actor} comentó en {doc}", + "chronik_mention_created": "{actor} te mencionó en {doc}", + "chronik_reply_received": "{actor} te respondió en {doc}", + "chronik_empty_first_run_title": "Aún no ha pasado nada", + "chronik_empty_first_run_body": "En cuanto alguien de la familia suba o transcriba un documento, la actividad aparecerá aquí.", + "chronik_empty_filter_title": "Nada en esta vista", + "chronik_empty_filter_body": "No hay entradas para este filtro.", + "chronik_error_title": "No se pudo cargar la crónica.", + "chronik_error_retry": "Reintentar", + "chronik_load_more": "Cargar más", + "chronik_loading": "Cargando …", + "chronik_load_more_announcement": "{count} entradas más cargadas", + "chronik_view_all": "Abrir crónica →" }