feat: unify /notifications and dashboard activity feed into a /chronik page #285
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Motivation
The Bell icon and
/notificationspage today only surface personal mentions/replies and waste a lot of horizontal space. In parallel, the dashboard now shows an ambient activity feed (uploads, transcription, annotations, comments, mentions) whose "Alle anzeigen" link currently points to/documents— wrong.We want one coherent page that:
/documentslink on the dashboard activity feedDesign spec
docs/specs/chronik-spec.html(committed on main, 2 043 lines, 11 states × 3 viewports, light + dark, fullimpl-reftables and i18n keys).View locally:
Key design decisions (locked via interview)
/chronik· "Chronik" (301 redirect from/notifications)max-w-3xl, mobile-firstBLOCK_REVIEWED; excludesSTATUS_CHANGEDandMETADATA_UPDATED/chronikwith label "Zur Chronik →"Scope / implementation plan (high level)
Backend
kindsquery param toGET /api/dashboard/activity(CSV ofAuditKind, default = 6 kinds).AuditLogQueryRepository.findActivityFeed: group consecutive rows by (actor_id, document_id, kind) within a 120-min window.ActivityFeedRow/ActivityFeedItemDTOwithcount(int) andhappenedAtUntil(nullable OffsetDateTime) — singletons:count=1,happenedAtUntil=null.Frontend
src/routes/chronik/+page.svelte++page.server.ts.ChronikFuerDichBox.svelte— unread mentions card with empty-state variantChronikFilterPills.svelte— 5 pills,role="radiogroup", URL-synced via?filter=ChronikTimeline.svelte— day-grouped listChronikRow.svelte— 4 row types (simple / rollup / comment / for-you)ChronikEmptyState.svelte— first-run / filter-empty / inbox-zeroChronikErrorCard.svelte— warning card with retryhooks.server.ts: 301 redirect/notifications→/chronik.NotificationDropdown.svelte: footer link →/chronik, relabel to "Zur Chronik".DashboardActivityFeed.svelte: fix broken link — changehref="/documents"→href="/chronik".i18n
Testing
/chronikin both light AND dark mode.Acceptance
/chronikrenders all 11 content states specified in §02/notificationsredirects to/chronik(301)/chronikReferences
docs/specs/chronik-spec.html— commit5f30807esrc/routes/notifications/+page.sveltesrc/lib/components/DashboardActivityFeed.svelte(broken link fix)AuditKind.java👨💻 Felix Brandt — Senior Fullstack Developer
Observations
AuditLogQueryRepository.findDedupedActivityFeed(L25), notfindActivityFeedas the issue says. Service wrapperAuditLogQueryService.findActivityFeed()calls it.date_trunc('hour', ...)— a different mechanism than the proposed 2 h rollup. Two aggregation strategies side by side would be confusing.ActivityFeedItemDTOis a Java record; extending it means updating the call site atDashboardService.java:133-140+ OpenAPI regen.Notificationis a persisted entity, distinct fromaudit_log. Für-dich (notifications table) and Timeline (audit_log) legitimately need two queries — not a duplication to refactor.accessibility.spec.tsalready parameterizes overAUTHENTICATED_PAGES× 3 modes (light, system dark, manual dark). Adding/chronikto that array is a one-line win.hooks.server.tsusessequence(). A redirect there would compete with the simpler+page.server.tsapproach.Recommendations
findDedupedActivityFeed. Since it will no longer dedupe but roll up, rename tofindRolledUpActivityFeedalong with the change.(actor_id, document_id, kind, floor(epoch/7200))for 2 h buckets. KeepingDISTINCT ONleaves zombie logic.src/routes/notifications/+page.server.tswiththrow redirect(301, '/chronik'). Colocated, no hook ordering concerns.<a href="/documents/{id}">wraps the row; the document title is a styled<span>, not an inner<a>withpointer-events:none. Valid HTML + same UX.ChronikRow.svelteas one file with a$derivedvariant discriminator unless it crosses 60 lines. 4 variants = one orchestrator, not 4 components.$lib/components/chronik/— 7 components in a shared folder is noise.rolledUpFeed_combines_same_actor_same_doc_within_2hrolledUpFeed_splits_at_2h_boundaryrolledUpFeed_never_rolls_up_COMMENT_ADDED_or_MENTION_CREATEDrolledUpFeed_exposes_count_and_happenedAtUntilchronik_load_redirects_from_notifications_pathAlle gelesenvisibility via$derived($page.data.unreadCount > 0)so SSE-delivered new notifications re-show the button without a reload.Open Decisions
🏛️ Markus Keller — Senior Application Architect
Observations
notificationstable (persisted per-recipient delivery with read state) andaudit_log(global event log). AMENTION_CREATEDaudit event produces one or morenotificationsrows. Intentional duplication — the notifications system owns delivery; audit owns history. Spec correctly keeps both.DashboardControllerrequiresREAD_ALL(permission-gated);NotificationControlleris user-scoped without@RequirePermission(deliberate, see L39-41 comment). A user lackingREAD_ALLhits 403 on/api/dashboard/activitybut 200 on/api/notifications.src/hooks.tsusesdeLocalizeUrl. Route registration for/chronikneeds localized slugs (Paraglide messages + routing strategy entries for en/es).Recommendations
/api/chronikas an orchestrator. Let+page.server.tsdo the composition. Merging creates a coupling that hurts both domains./chronikload function should branch onlocals.user.permissions. Users withoutREAD_ALLsee only Für-dich and an explicit "Mehr Zugriff nötig" empty timeline state. Do not weakenDashboardController's permission requirement.V{next}__add_audit_log_rollup_index.sql. Partial index matches the WHERE clause exactly; DESC supports the outer ORDER BY without a sort step.ChronikTimeline.sveltebuckets into Heute/Gestern/Diese Woche/Älter using locale-aware date math. Keeps the API decoupled from UI iteration.dashboard/since it shares the/api/dashboard/activityendpoint. If Chronik acquires its own endpoint in v2, extract to a newchronik/package then.Open Decisions
/chronikpage load. Option A: requireREAD_ALL(mirror dashboard, users without it get 403 on the page). Option B: any authenticated user can see the page, timeline degrades gracefully when the endpoint returns 403. Tradeoff: A is consistent, B is forgiving to lower-permission roles (e.g., users who can only comment). Affects both routing and empty-state design.🔒 Nora "NullX" Steiner — Application Security Engineer
Observations
NotificationControllerintentionally skips@RequirePermissionand scopes byuser.getId()(L39-41).markRead(L74-80) passesuser.getId()to the service so ownership can be enforced there — the service implementation should be covered by an explicit ownership test.{@html renderBody(...)}inCommentThread.svelte(that render path is trusted to sanitize). The Chronik preview is a different display context with stricter needs.redirect(status, ...)must be called with301explicitly — default forLocationredirects in SK is307, not301.Recommendations
{text}interpolation — never{@html}.@RequirePermissiontrust model — if someone refactors the service and forgets the ownership check, this fails.301, not302/307. 301 enables caching.{text}interpolation escapes by default. Confirm no{@html}creeps in for titles in the new components.<form method="POST" use:enhance>) for "Alle gelesen" and the per-row Dismiss — not a raw clientfetch()— so CSRF protection is automatic and consistent with existing admin patterns.<a>. Add a test that asserts clicking Dismiss marks read without navigating.Open Decisions
🧪 Sara Holt — Senior QA Engineer
Observations
e2e/accessibility.spec.tsalready parameterizes overAUTHENTICATED_PAGES× 3 modes (light, system dark, manual dark). Adding/chronikto that array gives us three new a11y checks with ~one line of code — that fulfills the "axe-playwright both modes" acceptance item immediately.toHaveScreenshot, baseline images, CI artifact pipeline, flake handling around font rendering).DashboardActivityFeed.svelte.spec.tslives next to the component.postgres:16-alpineis canonical for integration tests — no migration needed to adopt it here.Recommendations
/chroniktoAUTHENTICATED_PAGESas part of this issue. Three test cases light up for free.insertAuditEvents(actor, doc, kind, startTime, count, gapMinutes). The test that matters: insert 20 TEXT_SAVED events 8 min apart starting at T0 (span: 152 min, crosses a 2 h boundary). Assert 2 rollup rows produced, counts summing to 20, time ranges non-overlapping./api/notifications/streamwhile user is on/chronik— Für-dich box prepends it without reload. Currently onlyNotificationBellconsumes the stream; this may surface missing plumbing.chronik_rollup_text_savedwithcount=1vscount=20renders correct German plural (Paraglide handles pluralization).Open Decisions
🎨 Leonie Voss — UX/Design Lead
I wrote this spec, so I'm reviewing my own work with a critical second pass. Five gaps / refinements found.
Observations
NotificationBellconsumes/api/notifications/stream; Chronik's Für-dich box should behave the same way — but it's not written down.@for MENTION and↩for REPLY. A futureNotificationTypeenum value renders as "UNKNOWN" unless we add a fallback.Recommendations
createNotificationStream()already exists — reuse it inChronikFuerDichBox.svelte, don't fork.aria-busy="true"to the button. Keyboard focus stays on the button after load so screen-reader users don't lose their place.ChronikFuerDichBox— add a default branch. Future-proofs against newNotificationTypevalues.Intl.DateTimeFormatwithweekday+Temporal(or dayjs) to compute. Confirm week-start is locale-aware (Monday de/es, Sunday en-US) before hard-coding.#a1dcd8active-pill text contrast in axe CI on /chronik — ratio should be 9.8:1 against#012851, but confirm empirically at all three viewports.Open Decisions
🏗️ Tobias Wendt — DevOps & Platform Engineer
Observations
+page.server.ts) or Caddy (redir /notifications /chronik permanent). App-level wins: testable in CI, self-contained, doesn't couple to the reverse-proxy config./api/dashboard/activitytakes on more traffic. The rollup query becomes a new hot path./api/notifications/streamSSE already works through Caddy (Bell dropdown uses it). No config change expected.Recommendations
+page.server.ts, survives reverse-proxy refactors, CI-testable.V{next}__add_audit_log_rollup_index.sql). Never viaCREATE INDEX IF NOT EXISTSat app boot — migrations are the single source of truth./api/dashboard/activity: request rate + p95 latency. Alert at p95 > 500 ms sustained 5 min. The rollup is the new hot path; if the index is missing or the query regresses, this fires first.Open Decisions
🗳️ Decision Queue — Action Required
3 decisions need your input before implementation starts.
Architecture & Access
/chronikpage load.READ_ALLon the page (mirrorDashboardController). Users without it get 403 on the page itself./api/dashboard/activityreturns 403, Für-dich still renders.UX
Testing Scope
toHaveScreenshot, baseline strategy, Docker font pinning, flake tooling). Chronik ships faster.All other persona feedback consists of concrete recommendations that Felix can implement during the TDD cycle without blocking discussion.
🏛️ Markus Keller — Architectural Decisions (Discussion Outcome)
Walked through the five architect-scope open items with Marcel. All five resolved — no lingering architectural questions block implementation.
Resolved
Permission model for
/chronik. No explicit@RequirePermissionguard on the page —handleAuthinhooks.server.tsis sufficient. Every user has at leastREAD_ALL, so the degraded-access state is academic and gets cut.DashboardController.@RequirePermission(READ_ALL)stays untouched — belt-and-braces at the endpoint layer.Rollup SQL algorithm. Session-style grouping via
LAG()window function + 120 min gap detection. A new session starts whenever the gap to the previous event (same actor / document / kind) exceeds 120 min. Rejected: fixed 2 h wall-clock buckets — they split a natural session like13:58 / 13:59 / 14:01into two rollup rows. No hard cap on total span (one long transcription sitting = one row).Rollup scope — everywhere. The query change affects both
/chronikand the dashboard side-rail (DashboardActivityFeed.svelte). The existingfindDedupedActivityFeedis renamed tofindRolledUpActivityFeedand theDISTINCT ON … date_trunc('hour', ...)logic is deleted. One aggregation strategy across the whole app. Dashboard side-rail benefits: count badges replace granular hour-deduped rows — improvement, not regression.Localized URL slugs. Universal
/chronikacross all locales. No Paraglide URL pattern entry. Consistent with the existing German-URL-only convention (/dokumente,/personen,/briefwechsel). Page content is Paraglide-translated; the URL is a stable German identifier.ADR in scope. Write
docs/adr/003-chronik-unified-activity-feed.mdas part of the implementation branch, following the ADR-001/002 format (Status / Context / Decision / Alternatives / Consequences). Captures: session-style rollup choice, hour-dedupe deletion, single-endpoint-for-both-surfaces decision, German-URL-only rationale. Bundled with the rollup SQL commit so the record is never orphaned from its code.Skipped / unresolved
None.
Overall read
The spec is architecturally sound. The riskiest call was the rollup algorithm — session-grouping via
LAG()is 15 extra lines of SQL that prevent a class of "two rollup rows for one burst" bugs the fixed-bucket approach would produce on day one. The rest of the architectural surface (single endpoint, universal URL, no new package, index-backed partial SELECT) is the path of minimum operational overhead. Felix can start implementation.🎨 Leonie Voss — UX/Design Decisions (Discussion Outcome)
Walked through the five UX-scope open items with Marcel. All five resolved — no unresolved design decisions block implementation. Several items are spec addenda that I'll fold into
docs/specs/chronik-spec.htmlbefore Felix starts.Resolved
Mobile Dismiss button at 320 px (Decision Queue item). Option A2: show the ✓ button on mobile, but drop the
@/↩marker at 320 px only — it's redundant with the verb text ("Mama erwähnte dich" / "Anna antwortete") and the pixels are worth ~200 px of body space instead of ~178. Redundant cues preserved: unread dot (color), accent left border (color), verb text — two non-color signals, WCAG-safe. Marker returns at ≥ 640 px.Row markup (Felix's anti-pattern flag). Single outer
<a href="/documents/{id}">wraps the entire row. Document title renders as a styled<span>with the existingunderline decoration-accenttreatment — preserves the learned "underlined accent text = document" visual contract used elsewhere in the app (/dokumente, person pages, conversation view). Row-level hover/focus on the anchor. Dismiss button usesstopPropagationso its click does not navigate.SSE live-arrival behavior (spec gap I flagged in my own review). Four sub-decisions packaged together:
translateY(-4px → 0)). Layout shift of rows below is unavoidable but handled by browser scroll anchoring for users reading the timeline; for users already looking at Für-dich, the shift is honest context ("3 → 4 unreads, here's the new one").prefers-reduced-motion: animation dropped entirely. Row appears instantly.aria-live="polite"on the count badge still announces the update for screen readers.Pagination loading micro-state (spec gap I flagged in my own review). Hybrid (Option C): button label swaps "Mehr laden" → "Lädt …" with
aria-busy="true"and focus preserved on the button, plus 3 static (non-shimmer) skeleton rows render below the button during fetch to signal where the new rows will land. On completion, skeletons replaced by real rows, button label restored, focus stays on the button (does NOT auto-move to new content).aria-live="polite"wrapper around the timeline announces "X weitere Einträge geladen" for screen-reader users.Dashboard side-rail rollup visual fit (from Markus's "rollup everywhere" decision). Rail is 280 px content width — slightly narrower than Chronik's 320 px mobile. Rollup rows still fit cleanly. Option B — compound timestamp: line 2 becomes
"14. Apr. · 14:02–14:32"for rollup rows, stays"14. Apr. 2026"for singletons. Count badge inline on line 1 matches Chronik's styling (bg-primary text-primary-fg text-xs px-2 py-0.5 rounded-sm) — shared visual soDashboardActivityFeed.svelteand/chronikcannot drift apart. Existing "FÜR DICH" accent pill foryouMentionedcoexists with rollup (mentions never roll up).Spec addenda for me to apply before implementation
Skipped / unresolved
None.
Overall read
The design is tight now. The five items above were the gaps between "design looks great" and "developer can implement without guessing." The row-markup fix (single anchor + styled span) is the single highest-value call — it swaps an invalid HTML pattern for one that's both accessible and visually indistinguishable from what we'd originally spec'd. Felix can start.
👨💻 Felix Brandt — Implementation Decisions (Discussion Outcome)
Walked through the six dev-scope open items with Marcel. All six resolved. One follow-up issue (#286) spawned during the discussion.
Resolved
DTO evolution. Single
ActivityFeedItemDTOextended with two fields — no discriminated union, no second DTO:count: intwith@Schema(requiredMode = REQUIRED)— always populated,1for singletons.happenedAtUntil: OffsetDateTimenullable —nullfor singletons, end-of-session for rollups.happenedAtsemantically becomes start-of-session. Dashboard side-rail + Chronik share the same shape.Dismiss button markup. Option C — split markup, honest semantics. Outer
<div class="chronik-row">(flex container hosts two children) with unified:hover→bg-muted:<a href="/documents/{id}">wraps avatar + body + time (preserves Leonie's "linked row" UX for that region).<form method="POST" action="?/dismiss" use:enhance>+<button type="submit">(CSRF-safe via SvelteKit form action).?/mark-all).NotificationBellto use form actions so the two surfaces don't diverge on the same endpoints.SSE stream sharing — Path X, in scope of #285. Refactor
useNotificationStream.svelte.ts(factory) →$lib/stores/notifications.svelte.ts(module-level singleton). Idempotentinit()(ref-counted), ref-counteddestroy(). Bell and Chronik Für-dich box both import the singleton. OneEventSourceper tab, always. Old file deleted — no zombie code.Paraglide pluralization. No ICU
matchexpressions. Split intochronik_singleton_Xandchronik_rollup_Xkey pairs per verb. Component branches oncount === 1:Matches the project's existing pragmatic precedent (e.g.
"vor {count} Minute(n)"). Translators need no grammatical plural rules.Day-bucket helper —
$lib/utils/date-buckets.ts. Pure functionbucketByDay(date, now?, locale?) → DayBucket+getBucketLabel(bucket). Co-located.spec.tscovers midnight boundary, Monday-start-of-week (de/es), Sunday-start (en-US), DST transition, and "older" threshold. Pure function = clean unit tests without component harness friction.Existing dedupe tests — audit result: nothing to delete. The only existing test (
findDedupedActivityFeed_returnsAnnotationEntry) is a one-row smoke test that doesn't exercise dedupe behavior. Renamed tofindRolledUpActivityFeed_returnsAnnotationEntry, strengthened withcount == 1+happenedAtUntil == nullassertions. Six new rollup-behavior tests added on top (covering combine, split, long-session-no-cap, COMMENT/MENTION never-rolled-up, DTO exposure).DashboardActivityFeed.svelte.spec.tsgets new singleton + rollup render cases added — purely additive.Skipped / unresolved
None.
Side effects
refactor(notification-bell): use SvelteKit form actions instead of raw fetch. Blocked by this issue; addresses the same form-action convention on the Bell to prevent divergence.Overall read
The design was already implementable before this discussion — but six dev-level unknowns would each have cost a ~20-minute decision during TDD. Resolving them up front means I write failing tests first without stopping to re-read the spec or poke at Paraglide docs. The highest-value call was the SSE singleton (Path X): shipping two
EventSourceconnections to production would've been a silent regression; catching it at design time costs one refactor commit. Ready to start red-phase.