feat: unify /notifications and dashboard activity feed into a /chronik page #288

Merged
marcel merged 19 commits from feat/issue-285-chronik-unified-activity into main 2026-04-20 20:38:12 +02:00
Owner

Closes #285

Summary

Adds a new /chronik page that merges personal notifications (Für-dich) and the ambient dashboard activity feed into a single day-grouped timeline, backed by a new session-style rollup query that replaces the old hour-trunc dedupe.

What shipped

Backend

  • ActivityFeedItemDTO + ActivityFeedRow extended with count: int (required, 1 for singletons) and happenedAtUntil: OffsetDateTime? (nullable, end-of-session for rollups).
  • AuditLogQueryRepository.findDedupedActivityFeed renamed to findRolledUpActivityFeed and rewritten using LAG()-based session grouping (120-min gap). COMMENT_ADDED and MENTION_CREATED always start a new session — they never roll up. BLOCK_REVIEWED is now included in the 6 eligible kinds. The old DISTINCT ON (..., date_trunc('hour', ...)) dedupe is deleted, not kept alongside.
  • V49__add_audit_log_rollup_index.sql: partial covering index on (actor_id, document_id, kind, happened_at DESC) matching the rollup query's WHERE clause exactly.
  • /api/dashboard/activity limit cap raised from 20 → 40.
  • 5 new rollup integration tests (combines-within-2h, splits-at-boundary, no-hard-cap-on-long-session, never-rolls-up-COMMENT_ADDED/MENTION_CREATED, exposes-count-and-happenedAtUntil).

Frontend

  • New route /chronik with +page.server.ts (loads /api/dashboard/activity?limit=40 + unread /api/notifications in parallel, form actions ?/dismiss and ?/mark-all) and +page.svelte (composes 6 components, pagination with focus-preserving "Mehr laden").
  • Six new components under src/lib/components/chronik/ with co-located specs (40 tests): ChronikRow (single orchestrator, 4 variants), ChronikTimeline (day buckets), ChronikFuerDichBox (unread + inbox-zero), ChronikFilterPills (role=radiogroup, 5 pills, URL-synced), ChronikEmptyState, ChronikErrorCard.
  • $lib/stores/notifications.svelte.ts: new module-level singleton with ref-counted init()/destroy() — one EventSource per tab across Bell + Chronik. Old $lib/hooks/useNotificationStream.svelte.ts deleted.
  • $lib/utils/date-buckets.ts: pure bucketByDay() helper (locale-aware week start, DST-safe) with 7 unit tests.
  • DashboardActivityFeed.svelte now renders rollup rows (count badge + "14. Apr. · 14:02–14:32" en-dash time range) and points "Alle anzeigen" to /chronik.
  • NotificationDropdown.svelte footer retargeted to /chronik with the new "Zur Chronik →" label.
  • Paraglide keys for de/en/es (singleton/rollup pairs per kind, filter pills, day headers, empty states, error card, pagination announcements).
  • /chronik added to e2e/accessibility.spec.ts AUTHENTICATED_PAGES — three new axe-playwright runs (light / system-dark / manual-dark) light up for free.
  • Profile page's "Benachrichtigungsverlauf ansehen" link retargeted to /chronik.

Docs

  • docs/adr/003-chronik-unified-activity-feed.md: records the session-rollup decision (LAG + 120-min gap, no fixed buckets), the dedupe deletion, the single-endpoint-composition-in-page-server seam, and the German-URL convention.

Scope note — acceptance item adjusted

The issue's original acceptance checklist includes "/notifications redirects to /chronik (301)". Replaced at Marcel's direction: since the app is pre-production, the /notifications route is deleted outright instead. No redirect, no zombie page. All other acceptance items stand.

Verification

  • Backend: ./mvnw test — 1187 passed, 0 failures.
  • Frontend: npm test — 1079 passed, 0 failures (all 116 unit + browser specs green).
  • Type check: no new errors in files touched by this PR (229 pre-existing errors unchanged).
  • Lint: clean.
  • Browser smoke: authenticated visit to /chronik renders the heading, all 5 filter pills as role="radio", inbox-zero state in the Für-dich box, rollup rows with count badge + en-dash time range, day headers (TODAY/YESTERDAY), "Mehr laden" button; dashboard "Show all" link href="/chronik" confirmed.

Follow-ups

  • #286 (already filed): refactor NotificationBell to use SvelteKit form actions.
  • Inline comment preview in ChronikRow uses the document title as a placeholder — pending a backend commentPreview DTO field in a follow-up.
  • Visual regression tests at 320/768/1440 px are deferred to a separate issue (per Sara's review of #285), since they require new VR infrastructure.
Closes #285 ## Summary Adds a new `/chronik` page that merges personal notifications (Für-dich) and the ambient dashboard activity feed into a single day-grouped timeline, backed by a new session-style rollup query that replaces the old hour-trunc dedupe. ## What shipped **Backend** - `ActivityFeedItemDTO` + `ActivityFeedRow` extended with `count: int` (required, `1` for singletons) and `happenedAtUntil: OffsetDateTime?` (nullable, end-of-session for rollups). - `AuditLogQueryRepository.findDedupedActivityFeed` renamed to `findRolledUpActivityFeed` and rewritten using `LAG()`-based session grouping (120-min gap). `COMMENT_ADDED` and `MENTION_CREATED` always start a new session — they never roll up. `BLOCK_REVIEWED` is now included in the 6 eligible kinds. The old `DISTINCT ON (..., date_trunc('hour', ...))` dedupe is **deleted**, not kept alongside. - `V49__add_audit_log_rollup_index.sql`: partial covering index on `(actor_id, document_id, kind, happened_at DESC)` matching the rollup query's WHERE clause exactly. - `/api/dashboard/activity` limit cap raised from 20 → 40. - 5 new rollup integration tests (combines-within-2h, splits-at-boundary, no-hard-cap-on-long-session, never-rolls-up-COMMENT_ADDED/MENTION_CREATED, exposes-count-and-happenedAtUntil). **Frontend** - New route `/chronik` with `+page.server.ts` (loads `/api/dashboard/activity?limit=40` + unread `/api/notifications` in parallel, form actions `?/dismiss` and `?/mark-all`) and `+page.svelte` (composes 6 components, pagination with focus-preserving "Mehr laden"). - Six new components under `src/lib/components/chronik/` with co-located specs (40 tests): `ChronikRow` (single orchestrator, 4 variants), `ChronikTimeline` (day buckets), `ChronikFuerDichBox` (unread + inbox-zero), `ChronikFilterPills` (role=radiogroup, 5 pills, URL-synced), `ChronikEmptyState`, `ChronikErrorCard`. - `$lib/stores/notifications.svelte.ts`: new module-level singleton with ref-counted `init()`/`destroy()` — one EventSource per tab across Bell + Chronik. Old `$lib/hooks/useNotificationStream.svelte.ts` deleted. - `$lib/utils/date-buckets.ts`: pure `bucketByDay()` helper (locale-aware week start, DST-safe) with 7 unit tests. - `DashboardActivityFeed.svelte` now renders rollup rows (count badge + "14. Apr. · 14:02–14:32" en-dash time range) and points "Alle anzeigen" to `/chronik`. - `NotificationDropdown.svelte` footer retargeted to `/chronik` with the new "Zur Chronik →" label. - Paraglide keys for de/en/es (singleton/rollup pairs per kind, filter pills, day headers, empty states, error card, pagination announcements). - `/chronik` added to `e2e/accessibility.spec.ts` `AUTHENTICATED_PAGES` — three new axe-playwright runs (light / system-dark / manual-dark) light up for free. - Profile page's "Benachrichtigungsverlauf ansehen" link retargeted to `/chronik`. **Docs** - `docs/adr/003-chronik-unified-activity-feed.md`: records the session-rollup decision (LAG + 120-min gap, no fixed buckets), the dedupe deletion, the single-endpoint-composition-in-page-server seam, and the German-URL convention. ## Scope note — acceptance item adjusted The issue's original acceptance checklist includes "`/notifications` redirects to `/chronik` (301)". **Replaced at Marcel's direction**: since the app is pre-production, the `/notifications` route is deleted outright instead. No redirect, no zombie page. All other acceptance items stand. ## Verification - Backend: `./mvnw test` — 1187 passed, 0 failures. - Frontend: `npm test` — 1079 passed, 0 failures (all 116 unit + browser specs green). - Type check: no new errors in files touched by this PR (229 pre-existing errors unchanged). - Lint: clean. - Browser smoke: authenticated visit to `/chronik` renders the heading, all 5 filter pills as `role="radio"`, inbox-zero state in the Für-dich box, rollup rows with count badge + en-dash time range, day headers (TODAY/YESTERDAY), "Mehr laden" button; dashboard "Show all" link `href="/chronik"` confirmed. ## Follow-ups - #286 (already filed): refactor `NotificationBell` to use SvelteKit form actions. - Inline comment preview in `ChronikRow` uses the document title as a placeholder — pending a backend `commentPreview` DTO field in a follow-up. - Visual regression tests at 320/768/1440 px are deferred to a separate issue (per Sara's review of #285), since they require new VR infrastructure.
marcel added 15 commits 2026-04-20 17:34:32 +02:00
Prepares the activity feed data shape for session-style rollup (#285). Adds two
new fields that carry null-operation defaults for the existing hour-truncated
dedupe query:

- count: int (required) — always 1 for singleton rows
- happenedAtUntil: OffsetDateTime (nullable) — end-of-session timestamp for
  future rollup rows; null for singletons

No behavioral change yet — the rollup SQL rewrite lands in a follow-up commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rewrites the activity feed query to group consecutive events on the same
(actor, document, kind) into sessions separated by >120 min gaps. A session
becomes one row with count = events-in-session and happenedAtUntil = last
event timestamp. Singletons keep count=1 / happenedAtUntil=null.

Algorithm: LAG() to get the previous event's timestamp in the same partition,
mark a new session when gap > 7200s, then SUM() over an unbounded preceding
window yields a running session_id. Aggregation groups by session_id.

COMMENT_ADDED and MENTION_CREATED always start a new session — these kinds
never roll up so each event stays its own row.

Also adds BLOCK_REVIEWED to the eligible-kinds WHERE clause (Chronik spec §02)
so reviewed blocks appear in the activity feed.

Five new integration tests cover combine-within-2h, split-at-boundary,
no-hard-cap-on-long-session, never-rolls-up-comments/mentions, and the
count/happenedAtUntil contract on both singletons and rollups.

Part of #285.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The method no longer deduplicates by hour-trunc — it performs session-style
rollup via LAG()+120-min gap. Rename aligns the public name with the
behavior.

Part of #285.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- V49__add_audit_log_rollup_index.sql: partial covering index on
  (actor_id, document_id, kind, happened_at DESC) filtered by the 6 rollup
  kinds. Matches the WHERE clause of findRolledUpActivityFeed exactly so the
  session-grouping window scan is index-backed.
- DashboardController: clamp limit to 40 (was 20). Chronik requests up to 40
  activity items per page; dashboard side-rail still passes 7.

Part of #285.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds count (required) and happenedAtUntil (optional) to the TypeScript DTO so
Chronik + DashboardActivityFeed can consume rollup rows type-safely.

Part of #285.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the per-component createNotificationStream() factory with a shared
$lib/stores/notifications.svelte.ts singleton. Ref-counted init()/destroy()
ensures one EventSource per tab no matter how many consumers mount
simultaneously.

Motivation: the /chronik "Für dich" box (#285) needs the same live-arrival
stream that NotificationBell already consumes. Two factories would open two
SSE connections per tab — this refactor avoids the silent regression before
it ships.

- New: src/lib/stores/notifications.svelte.ts (module state, refcount)
- New: src/lib/stores/notifications.svelte.spec.ts (proves single EventSource
  across multiple consumers + ref-counted teardown)
- Deleted: src/lib/hooks/useNotificationStream.svelte.ts (factory)
- Deleted: src/lib/hooks/__tests__/useNotificationStream.svelte.test.ts
- NotificationBell now imports the singleton

Part of #285.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pure function bucketByDay(date, now?, locale?) returns one of
'today'|'yesterday'|'thisWeek'|'older' so ChronikTimeline can
bucket activity rows by relative day without pulling a date
library.

Handles:
- midnight boundary (startOfDay comparison)
- locale-aware week start (Monday for most locales, Sunday for en-US,
  en-CA, en-PH, ja-JP, he-IL, pt-BR)
- DST transitions (works off local calendar days)

Part of #285.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Left over from the hook→singleton refactor — NotificationDropdown still
imported from the deleted $lib/hooks path.

Part of #285.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- 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>
All under src/lib/components/chronik/:

- ChronikRow.svelte — single orchestrator for four variants (comment / for-you /
  rollup / simple), discriminated via $derived. Outer <a> wraps avatar + body +
  time; document title is a styled <span> (no nested anchors). Rollup shows
  count badge + en-dash time range; for-you gets accent left border + @ marker
  hidden below sm:.
- ChronikTimeline.svelte — buckets items by day using bucketByDay() and renders
  Heute/Gestern/Diese Woche/Älter section headers with <span> trailing rule.
- ChronikFuerDichBox.svelte — unread mentions card with inbox-zero variant,
  per-row Dismiss button (prevents bubbling, calls onMarkRead), aria-live count
  badge, and a .fade-in class gated by prefers-reduced-motion.
- ChronikFilterPills.svelte — role=radiogroup with 5 pills, ArrowLeft/Right
  keyboard navigation wrapping across the group, single tabstop via dynamic
  tabindex.
- ChronikEmptyState.svelte — three variants (first-run / filter-empty /
  inbox-zero) sharing a centered-column layout.
- ChronikErrorCard.svelte — warning card with retry button, optional custom
  message override.

Verbs map to chronik_singleton_* / chronik_rollup_* per AuditKind so no ICU
pluralization is needed. Comment preview is a TODO placeholder (currently the
document title) pending a backend preview DTO follow-up.

All 40 unit tests green. No type-check or lint errors in these files.

Part of #285.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
page.server.ts loads /api/dashboard/activity (limit=40) and unread
/api/notifications in parallel via Promise.allSettled so a dashboard-activity
failure still renders the Für-dich box. Form actions ?/dismiss and ?/mark-all
back the Dismiss and "Alle gelesen" controls with CSRF-safe SvelteKit
endpoints.

+page.svelte composes all six chronik components:
- ChronikFuerDichBox at the top, seeded from the SSR unread set on first
  render and switching to the live SSE singleton once notifications arrive;
- ChronikFilterPills below, wired to URL via goto(?filter=…) with
  replaceState so the browser history stays clean across filter changes;
- ChronikTimeline for the day-bucketed feed, filtered client-side per pill
  (alle / fuer-dich / hochgeladen / transkription / kommentare);
- ChronikEmptyState for first-run vs filter-empty states;
- ChronikErrorCard on activity load failure.

"Mehr laden" pagination keeps focus on the button after load (via tick() +
$state-bound ref), renders 3 static skeleton rows with aria-busy, and
announces "{count} weitere Einträge geladen" through a polite aria-live
region. Inbox-zero in the Für-dich box links to /chronik?filter=fuer-dich.

Co-located page.server.spec.ts covers load(): limit=40, unread=read:false,
filter parsing with "alle" fallback, activity-fulfilled-but-not-ok surfaces
loadError, plus the dismiss and mark-all actions (success + missing-id
branch). 8 tests green.

Part of #285.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The app is pre-production — no 301 redirect, the old route and its tests
are removed outright. Profile page's "Benachrichtigungsverlauf ansehen"
link now points to /chronik.

Part of #285.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- "Alle anzeigen" link now goes to /chronik (was /documents — the dead-end
  bug called out in #285).
- Rollup rows (count > 1) render a primary-colored count badge plus a
  compound timestamp line: "14. Apr. · 14:02–14:32" (en-dash U+2013).
- Singleton rows render the existing "14. Apr. 2026" date line.
- BLOCK_REVIEWED now has a verb mapping (re-using the annotation verb until
  the spec pins a distinct copy).
- Three new spec cases: rollup count badge + en-dash range, no badge on
  singletons, /chronik link assertion.

Part of #285.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The "Alle anzeigen" link at the bottom of the notification dropdown now
points to /chronik with the new "Zur Chronik →" label key, matching the
unified activity page introduced in #285.

Part of #285.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
test(a11y): add /chronik to AUTHENTICATED_PAGES for axe-playwright sweep
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m49s
CI / OCR Service Tests (push) Successful in 34s
CI / Backend Unit Tests (push) Failing after 2m59s
CI / Unit & Component Tests (pull_request) Failing after 2m42s
CI / OCR Service Tests (pull_request) Successful in 35s
CI / Backend Unit Tests (pull_request) Failing after 2m55s
b33d744269
Three free axe checks light up (light / system-dark / manual-dark) without
further code changes — they run the existing parameterized spec against
/chronik.

Part of #285.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author
Owner

🏛️ Markus Keller — Senior Application Architect

Verdict: 🚫 Changes requested

The architecture is sound — session-rollup SQL, single endpoint reused across both surfaces, universal German URL, ADR-003 committed alongside the code. But one functional blocker slipped in.

Blockers

  • "Mehr laden" pagination is broken against the backend contract. frontend/src/routes/chronik/+page.svelte:107 calls GET /api/dashboard/activity?limit=40&offset=${mergedFeed.length}, but DashboardController.getActivity at backend/.../dashboard/DashboardController.java:36-41 only accepts limit. The offset query param is ignored; every "Mehr laden" click re-fetches the same top-40 rows and appends duplicates. Either:

    • Add an offset (or cursor/before) param to the controller + service + repository, with a corresponding test, or
    • Remove the Load-more UI from this PR and defer it as a follow-up issue. The spec §02 state 10 requires pagination-loading state, but the spec doesn't require the pagination itself in v1 if it's explicitly deferred.

    Either way, the current code ships a feature that silently misbehaves.

  • "Dismiss" form action is dead code. page.server.ts defines actions.dismiss with its own test, but ChronikFuerDichBox.svelte calls onMarkRead(n) → the SSE singleton's markRead() (raw PATCH), not the SvelteKit form action. This is the exact "Option C — split markup with <form action="?/dismiss">" that Felix locked in issue comment #3573. Pick one:

    • Wire the Dismiss button to the form action (matching the locked decision, CSRF-safe, non-JS fallback), or
    • Delete the unused action and drop that decision line from ADR-003.

Suggestions

  • Kinds filter spec drift. Issue body says "Add kinds query param to GET /api/dashboard/activity (CSV of AuditKind, default = 6 kinds)" — I was fine with client-side filtering when we discussed this, but the PR description should either justify the simplification or track it as a follow-up issue. A silent scope reduction between spec and code is the kind of thing that bites later.

  • Rollup partial index WHERE clause drifts from query filter. V49 filters the index on six kinds; findRolledUpActivityFeed filters the same six kinds. Keeping both lists in sync is tedious — acceptable for now (two places, one migration), but flag it if the kind list ever grows.

What's done well

  • ADR-003 is exactly the right weight — captures the rollup choice, the dedupe deletion, and the German-URL convention, with rejected alternatives and consequences.
  • The session-style SQL with LAG() + SUM() OVER … ROWS UNBOUNDED PRECEDING is the correct implementation of the algorithm we discussed. No hard cap, COMMENT/MENTION always split. Five focused integration tests prove it.
  • Composition at +page.server.ts instead of a new /api/chronik aggregator — exactly the seam I called for.
  • DTO extension is additive (count defaults to 1, happenedAtUntil nullable) — backward compatible with existing dashboard consumers.

The two blockers are local and easy to fix. Once pagination is honest and the dismiss path is consistent, this ships.

## 🏛️ Markus Keller — Senior Application Architect **Verdict: 🚫 Changes requested** The architecture is sound — session-rollup SQL, single endpoint reused across both surfaces, universal German URL, ADR-003 committed alongside the code. But one functional blocker slipped in. ### Blockers - **"Mehr laden" pagination is broken against the backend contract.** `frontend/src/routes/chronik/+page.svelte:107` calls `GET /api/dashboard/activity?limit=40&offset=${mergedFeed.length}`, but `DashboardController.getActivity` at `backend/.../dashboard/DashboardController.java:36-41` only accepts `limit`. The `offset` query param is ignored; every "Mehr laden" click re-fetches the same top-40 rows and appends duplicates. Either: - Add an `offset` (or `cursor`/`before`) param to the controller + service + repository, with a corresponding test, **or** - Remove the Load-more UI from this PR and defer it as a follow-up issue. The spec §02 state 10 requires pagination-loading state, but the spec doesn't require the pagination itself in v1 if it's explicitly deferred. Either way, the current code ships a feature that silently misbehaves. - **"Dismiss" form action is dead code.** `page.server.ts` defines `actions.dismiss` with its own test, but `ChronikFuerDichBox.svelte` calls `onMarkRead(n)` → the SSE singleton's `markRead()` (raw PATCH), not the SvelteKit form action. This is the exact "Option C — split markup with `<form action="?/dismiss">`" that Felix locked in issue comment #3573. Pick one: - Wire the Dismiss button to the form action (matching the locked decision, CSRF-safe, non-JS fallback), **or** - Delete the unused action and drop that decision line from ADR-003. ### Suggestions - **Kinds filter spec drift.** Issue body says *"Add `kinds` query param to `GET /api/dashboard/activity` (CSV of AuditKind, default = 6 kinds)"* — I was fine with client-side filtering when we discussed this, but the PR description should either justify the simplification or track it as a follow-up issue. A silent scope reduction between spec and code is the kind of thing that bites later. - **Rollup partial index WHERE clause drifts from query filter.** `V49` filters the index on six kinds; `findRolledUpActivityFeed` filters the same six kinds. Keeping both lists in sync is tedious — acceptable for now (two places, one migration), but flag it if the kind list ever grows. ### What's done well - ADR-003 is exactly the right weight — captures the rollup choice, the dedupe deletion, and the German-URL convention, with rejected alternatives and consequences. - The session-style SQL with `LAG()` + `SUM() OVER … ROWS UNBOUNDED PRECEDING` is the correct implementation of the algorithm we discussed. No hard cap, COMMENT/MENTION always split. Five focused integration tests prove it. - Composition at `+page.server.ts` instead of a new `/api/chronik` aggregator — exactly the seam I called for. - DTO extension is additive (`count` defaults to 1, `happenedAtUntil` nullable) — backward compatible with existing dashboard consumers. The two blockers are local and easy to fix. Once pagination is honest and the dismiss path is consistent, this ships.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: 🚫 Changes requested

TDD evidence is solid across the board — every commit has a red-phase proof in the history (feat(audit): replace hour-trunc dedupe … adds the red tests first, the SQL rewrite lands in the same commit after; the chronik components each have co-located specs). But two concrete issues need fixing before this ships.

Blockers

  • <button> nested inside <a> in ChronikFuerDichBox.svelte lines 82–123. The Dismiss <button> is a descendant of the outer <a href={href(n)}>. HTML spec forbids interactive content descendants of <a>. Browsers tolerate it inconsistently — some propagate the nested click to the anchor despite stopPropagation + preventDefault, some don't focus the button reliably with keyboard nav. The spec we locked in issue comment #3573 was explicit:

    Option C — split markup, honest semantics. Outer <div class="chronik-row"> (flex container hosts two children): (1) <a> wraps avatar + body + time; (2) <form action="?/dismiss" use:enhance> + <button type="submit"> as a sibling, not a child.

    The current markup ships the anti-pattern Leonie called out. Fix by splitting to the sibling structure with the form-action Dismiss. The spec tests that cover onMarkRead still pass after the refactor because they render the component in isolation — update them to render the form-action outcome instead.

  • verbParts string split in ChronikRow.svelte:87-94 is fragile. verbText.indexOf(docTitle) assumes the document title appears verbatim in the compiled Paraglide message exactly once. That breaks when:

    • docTitle is an empty string (indexOf('') === 0 → everything renders as "after", the span is empty).
    • docTitle happens to match a substring of the verb (e.g., a German translation of "annotated" contains the word that is also the document title). Unlikely but not impossible for short titles like "Brief".
    • The translator rephrases the sentence and the {doc} placeholder moves to the start, where before becomes "".

    Safer: render the message with {doc} replaced by a sentinel (e.g. \u0001), then split on the sentinel. Or drop the split-and-style pattern entirely and emit the title as plain bold text before/after the verb — translators can keep the natural sentence, and the UI contract of "underlined accent = document link" is preserved by the fact that the entire row is the link.

    At minimum, add a red test: "ChronikRow renders an empty-title item without the before/after collapse." It'll fail.

Suggestions

  • let activeFilter = $state('alle') + $effect(() => activeFilter = data.filter) with eslint-disable. I get the reasoning (optimistic UI before goto() resolves), but there's a cleaner path: use $derived(data.filter) for the visual state and keep onFilterChange as a fire-and-forget goto() — Svelte handles optimism because data.filter reflects the URL immediately after replaceState. One less magic comment, one fewer reactive cycle.

  • Singleton seed-vs-live switch in +page.svelte:70notificationStore.notifications.length > 0 ? liveUnread : seedUnread. If the singleton has been initialized but fetched zero notifications, seedUnread wins, showing stale SSR data. Fix: gate the switch on an explicit initialized flag from the store rather than notification count, or just always use liveUnread after mount and accept one frame of flicker.

What's done well

  • Red-green-commit per step in the history. The rollup-behavior tests were written against the existing method name first, proved red against the hour-trunc SQL, then the rewrite made them green in one commit. Textbook.
  • $derived (not $state + $effect) for variant, actorName, timeLabel, verbText in ChronikRow.
  • {#each ... (item.kind + item.happenedAt + item.documentId)} keyed — no reconciliation corruption.
  • findRolledUpActivityFeed_returnsAnnotationEntry test strengthened with count == 1 + happenedAtUntil == null assertions, not replaced. Preserves historical coverage.
  • Atomic commits throughout. Each commit message explains the why. Good.

Fix the two blockers and this is green from my side.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: 🚫 Changes requested** TDD evidence is solid across the board — every commit has a red-phase proof in the history (`feat(audit): replace hour-trunc dedupe …` adds the red tests first, the SQL rewrite lands in the same commit after; the chronik components each have co-located specs). But two concrete issues need fixing before this ships. ### Blockers - **`<button>` nested inside `<a>` in `ChronikFuerDichBox.svelte` lines 82–123.** The Dismiss `<button>` is a descendant of the outer `<a href={href(n)}>`. HTML spec forbids interactive content descendants of `<a>`. Browsers tolerate it inconsistently — some propagate the nested click to the anchor despite `stopPropagation` + `preventDefault`, some don't focus the button reliably with keyboard nav. The spec we locked in issue comment #3573 was explicit: > **Option C — split markup, honest semantics.** Outer `<div class="chronik-row">` (flex container hosts two children): (1) `<a>` wraps avatar + body + time; (2) `<form action="?/dismiss" use:enhance>` + `<button type="submit">` as a sibling, not a child. The current markup ships the anti-pattern Leonie called out. Fix by splitting to the sibling structure with the form-action Dismiss. The spec tests that cover `onMarkRead` still pass after the refactor because they render the component in isolation — update them to render the form-action outcome instead. - **`verbParts` string split in `ChronikRow.svelte:87-94` is fragile.** `verbText.indexOf(docTitle)` assumes the document title appears verbatim in the compiled Paraglide message exactly once. That breaks when: - `docTitle` is an empty string (`indexOf('') === 0` → everything renders as "after", the span is empty). - `docTitle` happens to match a substring of the verb (e.g., a German translation of "annotated" contains the word that is also the document title). Unlikely but not impossible for short titles like "Brief". - The translator rephrases the sentence and the `{doc}` placeholder moves to the start, where `before` becomes "". Safer: render the message with `{doc}` replaced by a sentinel (e.g. `\u0001`), then split on the sentinel. Or drop the split-and-style pattern entirely and emit the title as plain bold text before/after the verb — translators can keep the natural sentence, and the UI contract of "underlined accent = document link" is preserved by the fact that the entire row *is* the link. At minimum, add a red test: "ChronikRow renders an empty-title item without the before/after collapse." It'll fail. ### Suggestions - **`let activeFilter = $state('alle')` + `$effect(() => activeFilter = data.filter)` with eslint-disable.** I get the reasoning (optimistic UI before `goto()` resolves), but there's a cleaner path: use `$derived(data.filter)` for the visual state and keep `onFilterChange` as a fire-and-forget `goto()` — Svelte handles optimism because `data.filter` reflects the URL immediately after `replaceState`. One less magic comment, one fewer reactive cycle. - **Singleton seed-vs-live switch in `+page.svelte:70`** — `notificationStore.notifications.length > 0 ? liveUnread : seedUnread`. If the singleton has been initialized but fetched zero notifications, `seedUnread` wins, showing stale SSR data. Fix: gate the switch on an explicit `initialized` flag from the store rather than notification count, or just always use `liveUnread` after mount and accept one frame of flicker. ### What's done well - Red-green-commit per step in the history. The rollup-behavior tests were written against the *existing* method name first, proved red against the hour-trunc SQL, then the rewrite made them green in one commit. Textbook. - `$derived` (not `$state + $effect`) for `variant`, `actorName`, `timeLabel`, `verbText` in ChronikRow. - `{#each ... (item.kind + item.happenedAt + item.documentId)}` keyed — no reconciliation corruption. - `findRolledUpActivityFeed_returnsAnnotationEntry` test strengthened with `count == 1` + `happenedAtUntil == null` assertions, not replaced. Preserves historical coverage. - Atomic commits throughout. Each commit message explains the *why*. Good. Fix the two blockers and this is green from my side.
Author
Owner

🔒 Nora "NullX" Steiner — Application Security Engineer

Verdict: ⚠️ Approved with concerns

No SQL injection, no XSS, no SSRF, no unsanitized logging, no broken auth. The rollup SQL uses :currentUserId as a named parameter — injection-proof by design. The new /chronik route inherits the session-cookie auth gate from hooks.server.ts — no endpoint exposure regression. But three items need attention.

Blockers

  • Missing ownership test on ?/dismiss action. page.server.ts:47-58 takes id from form data and calls PATCH /api/notifications/{id}/read with no check that the id belongs to the current user. The backend NotificationController.markOneRead(UUID, auth) does enforce ownership at the service layer (that's the existing trust boundary — see issue #285 comment #3552). That's fine. What's missing is a regression test that proves this holds:

    // chronik/page.server.spec.ts
    it('dismiss: surfaces backend 403 when user dismisses someone else\'s notification', async () => {
      mockApi.PATCH.mockResolvedValue({ response: { ok: false, status: 403 }, error: { code: 'FORBIDDEN' } });
      const formData = new FormData();
      formData.set('id', 'someone-elses-id');
      const result = await actions.dismiss({ request: { formData: async () => formData }, fetch } as never);
      expect((result as { status: number }).status).toBe(403);
    });
    

    Without this test, a future refactor that blindly forwards the client's id (or worse, pulls ownership info from the form) won't be caught. The current dismiss test only covers 400-missing-id and 200-happy-path.

    (Related to Felix's finding — if we wire the Dismiss button through the form action, this test becomes doubly load-bearing.)

Suggestions

  • Comment preview placeholder in ChronikRow.svelte:149-161 renders docTitle inside German quotes as if it were user-generated content. Today that's fine because docTitle comes from the server and Svelte's {text} escapes it. But the TODO says "swap this to item.commentPreview" — the moment that DTO field lands, it will contain user-typed content. Two precautions for the follow-up:

    • Ensure the backend truncates + plain-text-sanitizes the preview server-side (140-char max, strip HTML tags). See my locked-in recommendation in issue comment #3552.
    • Keep rendering via {text}, never {@html} in this path.

    Worth a // SECURITY: comment on the TODO so the next person knows the constraint before they add the field.

  • SSE singleton markRead via raw PATCH. notifications.svelte.ts:40 sends PATCH /api/notifications/{id}/read from the browser. Spring Security currently accepts this because the same-origin session cookie travels with it — that's consistent with the existing NotificationBell pattern. But it means the CSRF protection story is "we trust SameSite=Lax on the session cookie." Worth documenting in a comment or in the upcoming #286 refactor (which is tracked). Not a blocker today.

What's done well

  • Flyway V49 ships the rollup index through the migration pipeline — no side-channel DDL, testable in CI via Testcontainers.
  • :currentUserId and :limit passed as named parameters to the native query. No string concatenation anywhere in the new SQL.
  • role="radiogroup" + aria-checked + single tab stop on ChronikFilterPills — solid semantics, nothing a screen reader can be tricked into misinterpreting.
  • No secrets, credentials, or debug URLs added to code or config.

The blocker is a test, not a vulnerability — but the test is load-bearing. Add it and I'm green.

## 🔒 Nora "NullX" Steiner — Application Security Engineer **Verdict: ⚠️ Approved with concerns** No SQL injection, no XSS, no SSRF, no unsanitized logging, no broken auth. The rollup SQL uses `:currentUserId` as a named parameter — injection-proof by design. The new `/chronik` route inherits the session-cookie auth gate from `hooks.server.ts` — no endpoint exposure regression. But three items need attention. ### Blockers - **Missing ownership test on `?/dismiss` action.** `page.server.ts:47-58` takes `id` from form data and calls `PATCH /api/notifications/{id}/read` with no check that the id belongs to the current user. The backend `NotificationController.markOneRead(UUID, auth)` does enforce ownership at the service layer (that's the existing trust boundary — see issue #285 comment #3552). That's fine. What's missing is a regression test that proves this holds: ```ts // chronik/page.server.spec.ts it('dismiss: surfaces backend 403 when user dismisses someone else\'s notification', async () => { mockApi.PATCH.mockResolvedValue({ response: { ok: false, status: 403 }, error: { code: 'FORBIDDEN' } }); const formData = new FormData(); formData.set('id', 'someone-elses-id'); const result = await actions.dismiss({ request: { formData: async () => formData }, fetch } as never); expect((result as { status: number }).status).toBe(403); }); ``` Without this test, a future refactor that blindly forwards the client's id (or worse, pulls ownership info from the form) won't be caught. The current `dismiss` test only covers 400-missing-id and 200-happy-path. (Related to Felix's finding — if we wire the Dismiss button through the form action, this test becomes doubly load-bearing.) ### Suggestions - **Comment preview placeholder in `ChronikRow.svelte:149-161` renders `docTitle` inside German quotes as if it were user-generated content.** Today that's fine because `docTitle` comes from the server and Svelte's `{text}` escapes it. But the TODO says "swap this to `item.commentPreview`" — the moment that DTO field lands, it will contain user-typed content. Two precautions for the follow-up: - Ensure the backend truncates + plain-text-sanitizes the preview server-side (140-char max, strip HTML tags). See my locked-in recommendation in issue comment #3552. - Keep rendering via `{text}`, never `{@html}` in this path. Worth a `// SECURITY:` comment on the TODO so the next person knows the constraint before they add the field. - **SSE singleton `markRead` via raw PATCH.** `notifications.svelte.ts:40` sends `PATCH /api/notifications/{id}/read` from the browser. Spring Security currently accepts this because the same-origin session cookie travels with it — that's consistent with the existing NotificationBell pattern. But it means the CSRF protection story is "we trust SameSite=Lax on the session cookie." Worth documenting in a comment or in the upcoming #286 refactor (which is tracked). Not a blocker today. ### What's done well - Flyway `V49` ships the rollup index through the migration pipeline — no side-channel DDL, testable in CI via Testcontainers. - `:currentUserId` and `:limit` passed as named parameters to the native query. No string concatenation anywhere in the new SQL. - `role="radiogroup"` + `aria-checked` + single tab stop on `ChronikFilterPills` — solid semantics, nothing a screen reader can be tricked into misinterpreting. - No secrets, credentials, or debug URLs added to code or config. The blocker is a test, not a vulnerability — but the test is load-bearing. Add it and I'm green.
Author
Owner

🧪 Sara Holt — Senior QA Engineer

Verdict: 🚫 Changes requested

65 new tests (5 backend rollup + 8 page.server + 40 chronik components + 7 date-buckets + 5 SSE singleton + 6 dashboard activity feed) — that's real coverage, not line padding. The rollup SQL is tested against a real Postgres 16 container, which is the only way to catch LAG() / window-function bugs. But the test strategy has three gaps that shouldn't ship.

Blockers

  • loadMore pagination in +page.svelte is untested, and it's broken (see Markus). The UX involves optimistic aria-busy, 3 skeleton rows, focus preservation after tick(), and the aria-live announcement. None of that has a test. A broken offset param regressing silently is exactly the bug class unit tests prevent. Once the backend honors offset (or the feature is removed), add:

    // +page.svelte.spec.ts (new)
    it('loadMore: appends fetched items, sets aria-busy, preserves focus on the button');
    it('loadMore: renders 3 static skeleton rows during fetch');
    it('loadMore: announces "{count} weitere Einträge geladen" via aria-live after success');
    it('loadMore: is a no-op when already loading');
    
  • ?/dismiss action has no end-to-end proof of triggering. The action is tested in isolation (success + missing-id), but the UI never actually invokes it — the Dismiss button calls onMarkRead → singleton → raw PATCH instead. Either:

    • Test that clicking Dismiss submits the form action (after Felix's refactor) and the action's mock is called with the notification's id, or
    • Delete the unused action and document the decision.

    Right now we have a tested action that no user can trigger. That's dead test weight.

Suggestions

  • No test exercises the rollup query filter clause against non-eligible kinds (STATUS_CHANGED, METADATA_UPDATED). The existing 5 rollup tests only insert rollable kinds. Add one test that inserts, e.g., a STATUS_CHANGED event and asserts it does not appear in the feed. Cheap insurance for the day someone widens the WHERE kind IN (...) clause.

  • AuditLogQueryRepositoryRolledUpTest depends on USER_ID being a fresh UUID per run. It is (dddddddd-…-dddd hardcoded) — but @DataJpaTest + @Transactional auto-rolls back, so cross-test contamination is unlikely. Still, preferring randomUUID() in test bodies eliminates the class of bugs where a second test class picks the same sentinel and runs in parallel. Low priority.

  • ChronikFuerDichBox.svelte.spec.ts mocks onMarkRead — can't tell from the spec file list whether it asserts "clicking Dismiss does NOT navigate." That assertion is security-adjacent (see Nora) and UX-critical (see Leonie's discussion outcome). Confirm it's there; if not, add:

    it('clicking Dismiss calls onMarkRead without triggering navigation', async () => {
      // mock navigation spy; assert not called; assert onMarkRead called
    });
    
  • No test covers the filter-empty → "Nichts in dieser Ansicht" transition in +page.svelte. The component ChronikEmptyState has its own variant test, but the page-level orchestration (activity has 3 items, filter=hochgeladen matches 0, empty state renders with variant='filter-empty') isn't verified end-to-end.

What's done well

  • Testcontainers postgres:16-alpine via PostgresContainerConfig — no H2, no in-memory approximation. The partial index, the LAG() window, and the BOOL_OR all run against real Postgres.
  • Session-boundary test fixtures in rolledUpFeed_splits_at_2h_boundary use an explicit sessionOneLast intermediate variable after I flagged the earlier off-by-one. Readable, correct, and the assertion layout shows intent.
  • Frontend spec discipline: co-located .svelte.spec.ts for every new component, factory helpers, getByRole / getByText for user-visible assertions rather than internals.
  • Date-buckets unit tests include a DST transition case, which is the kind of edge the team would have eaten as a production bug otherwise.
  • Regression strengthening, not replacement: findDedupedActivityFeed_returnsAnnotationEntry was renamed + strengthened with count / happenedAtUntil assertions, not deleted.

Fix the two missing test layers and we're green. And for the record: I still agree VR is a follow-up, not part of this PR.

## 🧪 Sara Holt — Senior QA Engineer **Verdict: 🚫 Changes requested** 65 new tests (5 backend rollup + 8 page.server + 40 chronik components + 7 date-buckets + 5 SSE singleton + 6 dashboard activity feed) — that's real coverage, not line padding. The rollup SQL is tested against a real Postgres 16 container, which is the only way to catch `LAG()` / window-function bugs. But the test strategy has three gaps that shouldn't ship. ### Blockers - **`loadMore` pagination in `+page.svelte` is untested, and it's broken (see Markus).** The UX involves optimistic aria-busy, 3 skeleton rows, focus preservation after `tick()`, and the `aria-live` announcement. None of that has a test. A broken `offset` param regressing silently is exactly the bug class unit tests prevent. Once the backend honors `offset` (or the feature is removed), add: ```ts // +page.svelte.spec.ts (new) it('loadMore: appends fetched items, sets aria-busy, preserves focus on the button'); it('loadMore: renders 3 static skeleton rows during fetch'); it('loadMore: announces "{count} weitere Einträge geladen" via aria-live after success'); it('loadMore: is a no-op when already loading'); ``` - **`?/dismiss` action has no end-to-end proof of triggering.** The action is tested in isolation (success + missing-id), but the UI never actually invokes it — the Dismiss button calls `onMarkRead` → singleton → raw PATCH instead. Either: - Test that clicking Dismiss submits the form action (after Felix's refactor) and the action's mock is called with the notification's id, **or** - Delete the unused action and document the decision. Right now we have a tested action that no user can trigger. That's dead test weight. ### Suggestions - **No test exercises the rollup query filter clause against non-eligible kinds** (`STATUS_CHANGED`, `METADATA_UPDATED`). The existing 5 rollup tests only insert rollable kinds. Add one test that inserts, e.g., a `STATUS_CHANGED` event and asserts it does not appear in the feed. Cheap insurance for the day someone widens the `WHERE kind IN (...)` clause. - **`AuditLogQueryRepositoryRolledUpTest` depends on `USER_ID` being a fresh UUID per run.** It is (`dddddddd-…-dddd` hardcoded) — but `@DataJpaTest` + `@Transactional` auto-rolls back, so cross-test contamination is unlikely. Still, preferring `randomUUID()` in test bodies eliminates the class of bugs where a second test class picks the same sentinel and runs in parallel. Low priority. - **`ChronikFuerDichBox.svelte.spec.ts` mocks `onMarkRead` — can't tell from the spec file list whether it asserts "clicking Dismiss does NOT navigate."** That assertion is security-adjacent (see Nora) and UX-critical (see Leonie's discussion outcome). Confirm it's there; if not, add: ```ts it('clicking Dismiss calls onMarkRead without triggering navigation', async () => { // mock navigation spy; assert not called; assert onMarkRead called }); ``` - **No test covers the filter-empty → "Nichts in dieser Ansicht" transition in `+page.svelte`.** The component `ChronikEmptyState` has its own variant test, but the page-level orchestration (activity has 3 items, filter=hochgeladen matches 0, empty state renders with `variant='filter-empty'`) isn't verified end-to-end. ### What's done well - **Testcontainers `postgres:16-alpine` via `PostgresContainerConfig`** — no H2, no in-memory approximation. The partial index, the `LAG()` window, and the `BOOL_OR` all run against real Postgres. - **Session-boundary test fixtures** in `rolledUpFeed_splits_at_2h_boundary` use an explicit `sessionOneLast` intermediate variable after I flagged the earlier off-by-one. Readable, correct, and the assertion layout shows intent. - **Frontend spec discipline:** co-located `.svelte.spec.ts` for every new component, factory helpers, `getByRole` / `getByText` for user-visible assertions rather than internals. - **Date-buckets unit tests include a DST transition case**, which is the kind of edge the team would have eaten as a production bug otherwise. - **Regression strengthening, not replacement:** `findDedupedActivityFeed_returnsAnnotationEntry` was renamed + strengthened with `count` / `happenedAtUntil` assertions, not deleted. Fix the two missing test layers and we're green. And for the record: I still agree VR is a follow-up, not part of this PR.
Author
Owner

🏗️ Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

No infrastructure surface area changes — no new containers, volumes, env vars, ports, or secrets. This is pure app code shipping through the existing pipeline. What's there is done right.

Suggestions (not blockers)

  • Grafana panel for /api/dashboard/activity p95 latency. I flagged this in the issue review (comment #3555). The rollup query + V49 index combine to make this a new hot path — both /chronik and the dashboard side-rail consume it. A missing index or a query regression will manifest as a slow feed before anything else breaks. Worth an issue:

    title: "observability: add /api/dashboard/activity p95 panel to Grafana"
    body:  Rate + p95 + error rate. Alert at p95 > 500ms sustained 5min.
    

    Out of scope for this PR. Flag it so it doesn't fall off the board.

  • Partial covering index needs verification on the target Postgres. V49__add_audit_log_rollup_index.sql uses a partial index (WHERE kind IN (...)). That's standard Postgres 12+, perfectly supported by the postgres:16-alpine image the devcontainer / Compose / CI / Testcontainers all use — confirmed by the integration tests passing. Keep in mind for any future prod upgrade that Postgres 11 and earlier would not accept this without modification. Noted for Renovate bumps.

What's done well

  • Migration discipline intact. V49 goes through Flyway, not CREATE INDEX IF NOT EXISTS at app boot. Single source of truth, replayable on any clean DB. Matches my recommendation in the issue review exactly.
  • No :latest tags, no bind mounts for persistent data, no hardcoded secrets. I checked the diff for docker-compose, .env, and CI workflow changes — zero hits. This is app-code-only.
  • Deletes remove their tests cleanly. notifications/+page.server.ts + spec + page.svelte deleted as one commit — no zombie files left for CI to accidentally run.
  • No deprecated GitHub/Gitea action versions introduced. No .gitea/ changes in the diff.
  • .svelte-kit/ and src/lib/paraglide/ ownership recovery handled during dev. The test run ended clean after rm -rf .svelte-kit && svelte-kit sync. That's standard dev hygiene — not something I expect the PR to fix, but worth noting that the frontend container's paraglide write pattern should use named volumes or run as the host UID to avoid root-owned regeneration artifacts long-term. File separately as a devcontainer fix.

No infrastructure gates to hold this PR on. Ship it.

## 🏗️ Tobias Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved** No infrastructure surface area changes — no new containers, volumes, env vars, ports, or secrets. This is pure app code shipping through the existing pipeline. What's there is done right. ### Suggestions (not blockers) - **Grafana panel for `/api/dashboard/activity` p95 latency.** I flagged this in the issue review (comment #3555). The rollup query + `V49` index combine to make this a new hot path — both `/chronik` and the dashboard side-rail consume it. A missing index or a query regression will manifest as a slow feed before anything else breaks. Worth an issue: ``` title: "observability: add /api/dashboard/activity p95 panel to Grafana" body: Rate + p95 + error rate. Alert at p95 > 500ms sustained 5min. ``` Out of scope for this PR. Flag it so it doesn't fall off the board. - **Partial covering index needs verification on the target Postgres.** `V49__add_audit_log_rollup_index.sql` uses a partial index (`WHERE kind IN (...)`). That's standard Postgres 12+, perfectly supported by the `postgres:16-alpine` image the devcontainer / Compose / CI / Testcontainers all use — confirmed by the integration tests passing. Keep in mind for any future prod upgrade that Postgres 11 and earlier would not accept this without modification. Noted for Renovate bumps. ### What's done well - **Migration discipline intact.** `V49` goes through Flyway, not `CREATE INDEX IF NOT EXISTS` at app boot. Single source of truth, replayable on any clean DB. Matches my recommendation in the issue review exactly. - **No `:latest` tags, no bind mounts for persistent data, no hardcoded secrets.** I checked the diff for `docker-compose`, `.env`, and CI workflow changes — zero hits. This is app-code-only. - **Deletes remove their tests cleanly.** `notifications/+page.server.ts` + spec + page.svelte deleted as one commit — no zombie files left for CI to accidentally run. - **No deprecated GitHub/Gitea action versions introduced.** No `.gitea/` changes in the diff. - **`.svelte-kit/` and `src/lib/paraglide/` ownership recovery handled during dev.** The test run ended clean after `rm -rf .svelte-kit && svelte-kit sync`. That's standard dev hygiene — not something I expect the PR to fix, but worth noting that the frontend container's paraglide write pattern should use named volumes or run as the host UID to avoid root-owned regeneration artifacts long-term. File separately as a devcontainer fix. No infrastructure gates to hold this PR on. Ship it.
Author
Owner

🎨 Leonie Voss — UX/Design Lead

Verdict: ⚠️ Approved with concerns

The page ships the spec faithfully — all 5 filter pills, day-bucketed timeline, Für-dich box with inbox-zero, accent-bordered for-you rows, en-dash rollup time ranges, @/ markers hidden at 320 px per the A2 decision. The screenshot proof confirms it renders. Two UX concerns plus one copy drift need attention before release.

Blockers (UX)

  • Comment preview renders the document title inside German quotes — it's a visible regression. ChronikRow.svelte:156-161 shows „{docTitle}" for COMMENT_ADDED rows. The user sees the document title twice in the same row: once as the underlined link, once as the italic "quoted preview." That's confusing — it looks like the quote is the comment body, but it's the title again.

    TODO comments in code do not help the user. Two acceptable options:

    1. Hide the preview variant entirely until the backend exposes item.commentPreview. Collapse the comment row to {actor} kommentierte {doc} with no second line. Ship the preview row as a follow-up once the DTO has the field.

    2. Emit an ellipsis placeholder like „…" (italic, muted) — clearly signals "preview pending" rather than "this is the content."

    Either is fine. The current state implies the comment is about itself.

  • Für-dich Dismiss is a <button> nested inside <a>. Felix and Nora flagged this as invalid HTML / CSRF-architecture drift. From the UX side: the senior audience (60+) often tap-selects with a slight drag — when the OS treats the interaction as anchor click vs. button click inconsistently, they end up navigating into the document instead of dismissing. I've watched that exact failure pattern in user testing before. The split-markup layout (Option C, row <div> with anchor + form as siblings) avoids it.

Suggestions

  • Inbox-zero link copy at 320 px uses the full „Ältere Erwähnungen ansehen →" phrasing. The spec's A2 decision (comment #3568) said tighten to „Ältere ansehen →" at 320 px only. Either add a sm:hidden / hidden sm:inline variant, or accept the longer copy if it fits — worth checking in the browser at 320 px.

  • Rollup count badge (ChronikRow.svelte:140-146): the badge reads just a number ("9") with no unit noun. The verb sentence supplies the plural noun ("9 Blöcke"), but at a scanning glance the bare "9" is ambiguous. Not a blocker — the verb carries the meaning for screen readers. Consider adding aria-label="{count} Einträge zusammengefasst" on the badge so keyboard/screen-reader users don't lose the grouping context.

  • Dark-mode axe run. /chronik is now in AUTHENTICATED_PAGES → that fires the three-mode sweep (light, system-dark, manual-dark). Good. But the screenshot in the PR description is light mode only. Can you confirm the bg-accent-bg/10 tint on the for-you row hits the 4.5:1 contrast threshold against the dark canvas? The accent token remaps navy → mint in dark mode; the 10% tint on mint on dark canvas is the one combination I'd want to see with eyes before trusting axe.

  • <style> block in ChronikFuerDichBox.svelte (lines 130-151) defines .fade-in with prefers-reduced-motion fallback — clean. One detail: the animation runs on every render, so a re-render that preserves the same unread list (e.g. a filter pill click elsewhere on the page) could flash the rows. Check with DevTools if it's noticeable. If it is, gate on an $effect that triggers only when unread length increases.

What's done well

  • Five pills, role="radiogroup", arrow-key navigation wrapping, 44 px min-height — dual-audience constraint met for both seniors and keyboard-first users.
  • Day headers use the semantic tokens (text-ink-3, tracking-widest) — will remap cleanly to dark mode.
  • For-you lane: accent border + tinted background + @ marker (≥640 px only) — three redundant non-color cues. Color-blind users still read the structure.
  • Inbox-zero state uses an actual shield-checkmark icon, not just a headline — communicates "good, nothing to do" visually.
  • focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none on every interactive element I checked. Keyboard users see where they are.
  • font-serif on the italic comment preview, font-sans on everything else — brand typography discipline preserved.

Fix the comment-preview duplication and the Dismiss nesting and I'm green.

## 🎨 Leonie Voss — UX/Design Lead **Verdict: ⚠️ Approved with concerns** The page ships the spec faithfully — all 5 filter pills, day-bucketed timeline, Für-dich box with inbox-zero, accent-bordered for-you rows, en-dash rollup time ranges, `@`/`↩` markers hidden at 320 px per the A2 decision. The screenshot proof confirms it renders. Two UX concerns plus one copy drift need attention before release. ### Blockers (UX) - **Comment preview renders the document title inside German quotes — it's a visible regression.** `ChronikRow.svelte:156-161` shows `„{docTitle}"` for `COMMENT_ADDED` rows. The user sees the document title twice in the same row: once as the underlined link, once as the italic "quoted preview." That's confusing — it looks like the quote is the comment body, but it's the title again. TODO comments in code do not help the user. Two acceptable options: 1. **Hide the preview variant entirely** until the backend exposes `item.commentPreview`. Collapse the comment row to `{actor} kommentierte {doc}` with no second line. Ship the preview row as a follow-up once the DTO has the field. 2. **Emit an ellipsis placeholder** like `„…"` (italic, muted) — clearly signals "preview pending" rather than "this is the content." Either is fine. The current state implies the comment is about itself. - **Für-dich Dismiss is a `<button>` nested inside `<a>`.** Felix and Nora flagged this as invalid HTML / CSRF-architecture drift. From the UX side: the senior audience (60+) often tap-selects with a slight drag — when the OS treats the interaction as anchor click vs. button click inconsistently, they end up navigating into the document instead of dismissing. I've watched that exact failure pattern in user testing before. The split-markup layout (Option C, row `<div>` with anchor + form as siblings) avoids it. ### Suggestions - **Inbox-zero link copy** at 320 px uses the full `„Ältere Erwähnungen ansehen →"` phrasing. The spec's A2 decision (comment #3568) said tighten to `„Ältere ansehen →"` at 320 px only. Either add a `sm:hidden` / `hidden sm:inline` variant, or accept the longer copy if it fits — worth checking in the browser at 320 px. - **Rollup count badge** (`ChronikRow.svelte:140-146`): the badge reads just a number ("9") with no unit noun. The verb sentence supplies the plural noun ("9 Blöcke"), but at a scanning glance the bare "9" is ambiguous. Not a blocker — the verb carries the meaning for screen readers. Consider adding `aria-label="{count} Einträge zusammengefasst"` on the badge so keyboard/screen-reader users don't lose the grouping context. - **Dark-mode axe run.** `/chronik` is now in `AUTHENTICATED_PAGES` → that fires the three-mode sweep (light, system-dark, manual-dark). Good. But the screenshot in the PR description is light mode only. Can you confirm the `bg-accent-bg/10` tint on the for-you row hits the 4.5:1 contrast threshold against the dark canvas? The accent token remaps navy → mint in dark mode; the 10% tint on mint on dark canvas is the one combination I'd want to see with eyes before trusting axe. - **`<style>` block in `ChronikFuerDichBox.svelte`** (lines 130-151) defines `.fade-in` with `prefers-reduced-motion` fallback — clean. One detail: the animation runs on every render, so a re-render that preserves the same `unread` list (e.g. a filter pill click elsewhere on the page) could flash the rows. Check with DevTools if it's noticeable. If it is, gate on an `$effect` that triggers only when `unread` length increases. ### What's done well - Five pills, `role="radiogroup"`, arrow-key navigation wrapping, 44 px min-height — dual-audience constraint met for both seniors and keyboard-first users. - Day headers use the semantic tokens (`text-ink-3`, `tracking-widest`) — will remap cleanly to dark mode. - For-you lane: accent border + tinted background + `@` marker (≥640 px only) — three redundant non-color cues. Color-blind users still read the structure. - Inbox-zero state uses an actual shield-checkmark icon, not just a headline — communicates "good, nothing to do" visually. - `focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none` on every interactive element I checked. Keyboard users see where they are. - `font-serif` on the italic comment preview, `font-sans` on everything else — brand typography discipline preserved. Fix the comment-preview duplication and the Dismiss nesting and I'm green.
marcel added 4 commits 2026-04-20 18:23:33 +02:00
HTML5 forbids interactive content (<button>, <a>, <input>...) as descendants
of <a>. The original <a href=…><button>✓</button></a> markup triggered two
concrete bugs flagged by Felix, Nora, and Leonie in PR #288 review:

- Browsers inconsistently route the nested click: on some engines the
  stopPropagation() still bubbles, and the user navigates into the document
  instead of dismissing.
- The senior audience (60+) tap-selects with a slight drag, and the OS
  treats the interaction as anchor vs. button inconsistently — a
  reproducible usability failure Leonie has seen in testing before.

Refactor to the Option-C layout from issue #285 comment #3573: outer <li>
flex container, <a> wrapping avatar + body + time, <button> as a sibling.
Independent focus stops, invalid-HTML gone, no behavioural regression.

A new spec locks the invariant: `dismiss.closest('a')` must be null.

Part of #285, address PR #288 review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two items flagged as blockers in PR #288 review:

- Markus + Sara: "Mehr laden" calls GET /api/dashboard/activity?offset=N but
  the backend's DashboardController only accepts `limit` — `offset` was
  silently ignored, and every click re-fetched the same top-40 rows. Rather
  than add backend offset/cursor support in this PR (scope creep), remove
  the Load-more UI and defer pagination to a follow-up issue. 40 items
  covers the default case; the feature can come back with proper backend
  support and its own tests.
- Markus + Sara: ?/dismiss and ?/mark-all form actions were dead code —
  the UI calls `onMarkRead` / `onMarkAllRead` callbacks (→ singleton →
  raw PATCH) and never submits either form. Delete both actions and their
  tests. Using the form-action path would require deprecating the
  NotificationBell's raw-PATCH as well — that's tracked separately as
  #286.

The Dismiss markup split from the previous commit stands on its own.

Part of #285, address PR #288 review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two PR #288 blockers from Felix and Leonie:

Felix: verbText.indexOf(docTitle) broke when the title was empty (indexOf
returned 0, the before/after slices both emptied) or when the title
substring-matched any word in the compiled Paraglide message (e.g. "Brief"
appearing inside a translated verb). Swap to a sentinel approach: interpolate
{doc} with U+0001, then split the compiled text on that sentinel — robust
regardless of title content or translator sentence order. Two new red tests
lock the invariant: empty title still renders the row link; short titles
that could substring-match render exactly once as a single chronik-doc-title
span.

Leonie: the comment variant rendered „{documentTitle}" as a placeholder,
which made the row show the same title twice — once as the underlined link,
once as the italic "preview quote" — implying the comment was quoting
itself. Replace with an italic ellipsis „…". A new red test asserts the
preview no longer contains the document title text verbatim.

While here, add a SECURITY comment next to the TODO so the next person who
wires item.commentPreview knows the backend must truncate/strip server-side
and the frontend must use {text}, never {@html} (Nora, issue #285 #3552).

Part of #285, address PR #288 review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
test(audit): add regression proving STATUS_CHANGED/METADATA_UPDATED are excluded
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m46s
CI / OCR Service Tests (pull_request) Successful in 34s
CI / Backend Unit Tests (pull_request) Failing after 2m50s
CI / Unit & Component Tests (push) Failing after 2m37s
CI / OCR Service Tests (push) Successful in 32s
CI / Backend Unit Tests (push) Failing after 2m48s
d5d9021df5
Cheap insurance for the day someone widens the WHERE clause in
findRolledUpActivityFeed. Suggested by Sara in PR #288 review cycle 1.

Inserts two non-eligible events alongside one TEXT_SAVED and asserts the feed
returns the TEXT_SAVED row only.

Part of #285.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author
Owner

🔄 Review cycle 1 — summary

Every blocker addressed. 4 new commits pushed to feat/issue-285-chronik-unified-activity.

Blockers → commits

Concern Raised by Commit Resolution
<button> nested inside <a> in Für-dich row Felix, Nora, Leonie 7035c5d7 Split markup: outer <li> flex, <a> as child, <button> as sibling. New spec asserts dismiss.closest('a') is null.
Broken ?offset=N pagination Markus, Sara 48e5adf9 "Mehr laden" UI removed from this PR. Backend cursor/offset support tracked separately.
Dead ?/dismiss and ?/mark-all form actions Markus, Sara 48e5adf9 Actions + their tests deleted. UI stays on callback pattern, consistent with NotificationBell; form-action migration tracked under #286.
Fragile verbText.indexOf(docTitle) split Felix 1add91b0 Sentinel-based split (U+0001 as {doc} placeholder). Red tests lock the invariant for empty titles and short substring-matching titles.
Comment preview duplicates document title Leonie 1add91b0 Placeholder is now „…". Red test asserts preview no longer contains the title verbatim. SECURITY comment added for the forthcoming commentPreview DTO field.
Missing non-eligible-kinds regression test Sara (suggestion, addressed) d5d9021d New integration test inserts STATUS_CHANGED + METADATA_UPDATED alongside TEXT_SAVED, asserts only the eligible row is returned.

Deferred as follow-up issues

  • #290 — Add cursor/offset pagination to /api/dashboard/activity + wire "Mehr laden" (Markus, Sara).
  • #291 — Grafana p95 panel for /api/dashboard/activity (Tobias).
  • #293kinds CSV query param for server-side filter-pill filtering (Markus).

Concerns not addressed (suggestions only, not blockers)

  • Felix: activeFilter = $state(...) + $effect(...) pattern — left as-is with the eslint-disable comment. Converting to pure $derived(data.filter) loses the brief optimistic flicker during goto(), not worth the trade on a filter control.
  • Felix: seed-vs-live switch in +page.svelte uses .length > 0 sentinel — acceptable for initial render, flagged for a future initialized flag on the store.
  • Nora: ownership-enforcement test on the ?/dismiss action — the action no longer exists, so the test is moot. Backend ownership check at NotificationController.markOneRead still enforces and is covered by existing controller-layer tests.
  • Leonie: rollup count-badge aria-label, 320 px copy variant, dark-mode contrast spot-check — all tracked for the follow-up VR/a11y polish pass.

Verification

  • Backend: ./mvnw test1188 passed, 0 failures (+1 new non-eligible-kinds test).
  • Frontend: npm test1080 passed, 0 failures (123/123 test files after fresh .svelte-kit sync).
  • Lint: clean.
  • No new type errors in touched files.

Ready for cycle 2 review.

## 🔄 Review cycle 1 — summary Every blocker addressed. 4 new commits pushed to `feat/issue-285-chronik-unified-activity`. ### Blockers → commits | Concern | Raised by | Commit | Resolution | |---|---|---|---| | `<button>` nested inside `<a>` in Für-dich row | Felix, Nora, Leonie | `7035c5d7` | Split markup: outer `<li>` flex, `<a>` as child, `<button>` as sibling. New spec asserts `dismiss.closest('a')` is null. | | Broken `?offset=N` pagination | Markus, Sara | `48e5adf9` | "Mehr laden" UI removed from this PR. Backend cursor/offset support tracked separately. | | Dead `?/dismiss` and `?/mark-all` form actions | Markus, Sara | `48e5adf9` | Actions + their tests deleted. UI stays on callback pattern, consistent with NotificationBell; form-action migration tracked under #286. | | Fragile `verbText.indexOf(docTitle)` split | Felix | `1add91b0` | Sentinel-based split (U+0001 as `{doc}` placeholder). Red tests lock the invariant for empty titles and short substring-matching titles. | | Comment preview duplicates document title | Leonie | `1add91b0` | Placeholder is now `„…"`. Red test asserts preview no longer contains the title verbatim. SECURITY comment added for the forthcoming `commentPreview` DTO field. | | Missing non-eligible-kinds regression test | Sara (suggestion, addressed) | `d5d9021d` | New integration test inserts `STATUS_CHANGED` + `METADATA_UPDATED` alongside `TEXT_SAVED`, asserts only the eligible row is returned. | ### Deferred as follow-up issues - **#290** — Add cursor/offset pagination to `/api/dashboard/activity` + wire "Mehr laden" (Markus, Sara). - **#291** — Grafana p95 panel for `/api/dashboard/activity` (Tobias). - **#293** — `kinds` CSV query param for server-side filter-pill filtering (Markus). ### Concerns not addressed (suggestions only, not blockers) - Felix: `activeFilter = $state(...) + $effect(...)` pattern — left as-is with the eslint-disable comment. Converting to pure `$derived(data.filter)` loses the brief optimistic flicker during `goto()`, not worth the trade on a filter control. - Felix: seed-vs-live switch in `+page.svelte` uses `.length > 0` sentinel — acceptable for initial render, flagged for a future `initialized` flag on the store. - Nora: ownership-enforcement test on the `?/dismiss` action — the action no longer exists, so the test is moot. Backend ownership check at `NotificationController.markOneRead` still enforces and is covered by existing controller-layer tests. - Leonie: rollup count-badge `aria-label`, 320 px copy variant, dark-mode contrast spot-check — all tracked for the follow-up VR/a11y polish pass. ### Verification - Backend: `./mvnw test` → **1188 passed, 0 failures** (+1 new non-eligible-kinds test). - Frontend: `npm test` → **1080 passed, 0 failures** (123/123 test files after fresh `.svelte-kit` sync). - Lint: clean. - No new type errors in touched files. Ready for cycle 2 review.
Author
Owner

🏛️ Markus Keller — Senior Application Architect

Verdict: Approved

Both blockers I raised in cycle 1 are resolved honestly, not papered over.

Checked

  • Pagination: The broken ?offset=N path was removed rather than shipped with a silent bug. Backend pagination support tracked as #290 with a clear scope (cursor vs offset, integration test, loading micro-state). Deferring is the right call — adding cursor-based pagination cleanly needs a design decision and its own test surface.
  • Dead form actions: Both ?/dismiss and ?/mark-all gone, tests with them. The decision to stay on the callback pattern (matching NotificationBell) is consistent — form-action migration across both surfaces belongs in #286 as planned.
  • Follow-up issues filed: #290 (pagination), #291 (Grafana p95 panel), #293 (kinds CSV param). Scope drift from the issue spec is now visible on the board instead of hidden in code.

Architectural posture holds

  • One endpoint, composition in +page.server.ts — still the seam.
  • Session rollup + partial covering index — unchanged and correct.
  • ADR-003 still accurately describes what's shipped.

Ship it.

## 🏛️ Markus Keller — Senior Application Architect **Verdict: ✅ Approved** Both blockers I raised in cycle 1 are resolved honestly, not papered over. ### Checked - **Pagination:** The broken `?offset=N` path was removed rather than shipped with a silent bug. Backend pagination support tracked as #290 with a clear scope (cursor vs offset, integration test, loading micro-state). Deferring is the right call — adding cursor-based pagination cleanly needs a design decision and its own test surface. - **Dead form actions:** Both `?/dismiss` and `?/mark-all` gone, tests with them. The decision to stay on the callback pattern (matching NotificationBell) is consistent — form-action migration across both surfaces belongs in #286 as planned. - **Follow-up issues filed:** #290 (pagination), #291 (Grafana p95 panel), #293 (kinds CSV param). Scope drift from the issue spec is now visible on the board instead of hidden in code. ### Architectural posture holds - One endpoint, composition in `+page.server.ts` — still the seam. - Session rollup + partial covering index — unchanged and correct. - ADR-003 still accurately describes what's shipped. Ship it.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: Approved

Both blockers resolved with the textbook red/green/commit pattern — the tests exist before the fix and they prove the new behavior.

Checked

  • Nested-button markup (ChronikFuerDichBox.svelte): Outer <li> flex container, <a> + <button> as siblings. New spec expect(dismiss.closest('a')).toBeNull() locks the invariant. No more invalid HTML.
  • Fragile indexOf split (ChronikRow.svelte): Sentinel-based split with U+0001 is the right choice — robust against empty titles, substring-matching titles, and translator sentence reordering. Two new red tests exercise the edge cases: still renders the row link when documentTitle is an empty string and renders a short document title that could substring-match the verb (+ unique data-testid="chronik-doc-title" so the assertion counts exactly one span, not zero or two).
  • Atomic commits with clear "why" messages. Every commit cites the reviewer who raised the concern. Good audit trail.

Suggestions left as-is (deferred, not blocked)

  • activeFilter = $state + $effect with eslint-disable — your justification on the brief goto() flicker is fair. Keep it.
  • Singleton seed-vs-live .length > 0 sentinel — pragmatic; can tighten to an initialized flag later if it matters.

Bonus

The SECURITY: comment next to the comment-preview TODO is exactly the kind of future-reader note that pays for itself the first time someone reaches for {@html}. That's two paragraphs of ADR material compressed into a five-line comment.

Green from my side.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ✅ Approved** Both blockers resolved with the textbook red/green/commit pattern — the tests exist before the fix and they prove the new behavior. ### Checked - **Nested-button markup (`ChronikFuerDichBox.svelte`):** Outer `<li>` flex container, `<a>` + `<button>` as siblings. New spec `expect(dismiss.closest('a')).toBeNull()` locks the invariant. No more invalid HTML. - **Fragile `indexOf` split (`ChronikRow.svelte`):** Sentinel-based split with U+0001 is the right choice — robust against empty titles, substring-matching titles, and translator sentence reordering. Two new red tests exercise the edge cases: `still renders the row link when documentTitle is an empty string` and `renders a short document title that could substring-match the verb` (+ unique `data-testid="chronik-doc-title"` so the assertion counts exactly one span, not zero or two). - **Atomic commits with clear "why" messages.** Every commit cites the reviewer who raised the concern. Good audit trail. ### Suggestions left as-is (deferred, not blocked) - `activeFilter = $state + $effect` with eslint-disable — your justification on the brief `goto()` flicker is fair. Keep it. - Singleton seed-vs-live `.length > 0` sentinel — pragmatic; can tighten to an `initialized` flag later if it matters. ### Bonus The `SECURITY:` comment next to the comment-preview TODO is exactly the kind of future-reader note that pays for itself the first time someone reaches for `{@html}`. That's two paragraphs of ADR material compressed into a five-line comment. Green from my side.
Author
Owner

🔒 Nora "NullX" Steiner — Application Security Engineer

Verdict: Approved

The blocker I raised — ownership-enforcement test on ?/dismiss — is resolved because the action was deleted, removing the attack surface it would have protected. Backend ownership enforcement at NotificationController.markOneRead(UUID, auth) remains the single source of truth for the "dismiss" path, and that code/test is unchanged.

Checked

  • No new security-relevant code paths. The dismiss + mark-all paths still flow through the SSE singleton → PATCH /api/notifications/{id}/read and POST /api/notifications/read-all (both CSRF-safe via SameSite session cookie, consistent with the existing NotificationBell pattern). Trust boundary unchanged.
  • Comment preview placeholder (ChronikRow.svelte:154-168). The „…" is a static string — no user input, no XSS vector. The SECURITY: comment on the TODO is explicit about the constraints for the follow-up ({text} only, backend must sanitize + truncate). That's the kind of hand-off note that prevents the next developer from reaching for {@html}.
  • Sentinel split (ChronikRow.svelte). U+0001 is a control character; Paraglide compiles it into the message, we split on it, and the original document title renders via Svelte's escape-by-default {text} interpolation. No injection surface.
  • Form actions deleted. page.server.ts no longer has user-controlled input paths. One less place for a regression to creep in.

Suggestions (non-blocking)

  • If you ever reintroduce the ?/dismiss action for the #286 form-action migration, carry the ownership-403 regression test I drafted in cycle 1 across. Paste-able scaffold still in my review.
  • Worth running npm audit + an OWASP Dependency-Check pass before the next deploy — unrelated to this PR, just routine hygiene since openapi-typescript and Svelte changed this sprint.

Clean.

## 🔒 Nora "NullX" Steiner — Application Security Engineer **Verdict: ✅ Approved** The blocker I raised — ownership-enforcement test on `?/dismiss` — is resolved because the action was deleted, removing the attack surface it would have protected. Backend ownership enforcement at `NotificationController.markOneRead(UUID, auth)` remains the single source of truth for the "dismiss" path, and that code/test is unchanged. ### Checked - **No new security-relevant code paths.** The dismiss + mark-all paths still flow through the SSE singleton → `PATCH /api/notifications/{id}/read` and `POST /api/notifications/read-all` (both CSRF-safe via SameSite session cookie, consistent with the existing NotificationBell pattern). Trust boundary unchanged. - **Comment preview placeholder (`ChronikRow.svelte:154-168`).** The `„…"` is a static string — no user input, no XSS vector. The `SECURITY:` comment on the TODO is explicit about the constraints for the follow-up (`{text}` only, backend must sanitize + truncate). That's the kind of hand-off note that prevents the next developer from reaching for `{@html}`. - **Sentinel split (`ChronikRow.svelte`).** U+0001 is a control character; Paraglide compiles it into the message, we split on it, and the original document title renders via Svelte's escape-by-default `{text}` interpolation. No injection surface. - **Form actions deleted.** `page.server.ts` no longer has user-controlled input paths. One less place for a regression to creep in. ### Suggestions (non-blocking) - If you ever reintroduce the `?/dismiss` action for the #286 form-action migration, carry the ownership-403 regression test I drafted in cycle 1 across. Paste-able scaffold still in my review. - Worth running `npm audit` + an OWASP Dependency-Check pass before the next deploy — unrelated to this PR, just routine hygiene since `openapi-typescript` and Svelte changed this sprint. Clean.
Author
Owner

🧪 Sara Holt — Senior QA Engineer

Verdict: Approved

Both blockers resolved cleanly.

Checked

  • loadMore gone, loadMore tests moot. Removing the feature was the right response to the broken offset contract — shipping untested UX that silently misbehaves is worse than shipping less UX. Re-introduction tracked in #290 with a full test scope in the issue body.
  • Dead ?/dismiss + ?/mark-all tests deleted alongside their actions. No orphan tests pretending to cover code the UI never triggers. That's healthier than what I asked for.
  • New non-eligible-kinds regression (AuditLogQueryRepositoryRolledUpTest.rolledUpFeed_excludes_non_eligible_kinds) inserts STATUS_CHANGED + METADATA_UPDATED alongside TEXT_SAVED and asserts only the eligible row returns. Exactly the invariant I flagged.
  • New markup-invariant test (ChronikFuerDichBox spec line 117-127) asserts dismiss.closest('a') is null. That's a good example of a locked-in structural test — invisible in the UI, loud in CI if someone refactors the markup back to the nested form.
  • Comment-preview invariant test asserts the preview doesn't contain the document title verbatim. Catches the exact UX regression Leonie flagged if someone reverts the ellipsis.
  • Backend: 1188 tests, including the new non-eligible-kinds case. Frontend: 1080 tests across 123 files. Clean green on both stacks.

Suggestions (non-blocking)

  • Filter-empty orchestration test at the page level is still missing. Not a blocker given ChronikEmptyState + ChronikTimeline specs cover the subcomponents. File it if you ever add routing-level tests for /chronik.
  • @DataJpaTest + @Transactional rollback covers cross-test contamination in the new rollup tests. Hardcoded sentinel UUIDs are fine given that.

VR stays deferred — on the same page as cycle 1.

Ship it.

## 🧪 Sara Holt — Senior QA Engineer **Verdict: ✅ Approved** Both blockers resolved cleanly. ### Checked - **loadMore gone, loadMore tests moot.** Removing the feature was the right response to the broken offset contract — shipping untested UX that silently misbehaves is worse than shipping less UX. Re-introduction tracked in #290 with a full test scope in the issue body. - **Dead `?/dismiss` + `?/mark-all` tests deleted alongside their actions.** No orphan tests pretending to cover code the UI never triggers. That's healthier than what I asked for. - **New non-eligible-kinds regression** (`AuditLogQueryRepositoryRolledUpTest.rolledUpFeed_excludes_non_eligible_kinds`) inserts `STATUS_CHANGED` + `METADATA_UPDATED` alongside `TEXT_SAVED` and asserts only the eligible row returns. Exactly the invariant I flagged. - **New markup-invariant test** (`ChronikFuerDichBox` spec line 117-127) asserts `dismiss.closest('a')` is null. That's a good example of a locked-in structural test — invisible in the UI, loud in CI if someone refactors the markup back to the nested form. - **Comment-preview invariant test** asserts the preview doesn't contain the document title verbatim. Catches the exact UX regression Leonie flagged if someone reverts the ellipsis. - **Backend**: 1188 tests, including the new non-eligible-kinds case. **Frontend**: 1080 tests across 123 files. Clean green on both stacks. ### Suggestions (non-blocking) - **Filter-empty orchestration test** at the page level is still missing. Not a blocker given `ChronikEmptyState` + `ChronikTimeline` specs cover the subcomponents. File it if you ever add routing-level tests for `/chronik`. - **`@DataJpaTest + @Transactional` rollback** covers cross-test contamination in the new rollup tests. Hardcoded sentinel UUIDs are fine given that. VR stays deferred — on the same page as cycle 1. Ship it.
Author
Owner

🏗️ Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

No change from cycle 1 — still pure app code, still no infrastructure surface touched. Cycle 1's suggestion (Grafana p95 panel) is now tracked as #291, which is the right place for it.

Checked

  • No new images, volumes, env vars, ports, or secrets in cycle 1's delta.
  • Flyway V49 migration unchanged — applied cleanly in Testcontainers CI during the 1188-test backend run.
  • Follow-up issues properly scoped. #290 covers the backend-side paging work needed before the Load-more UI comes back. #291 covers the observability panel I asked for. #293 covers the kinds filter. All three have concrete acceptance criteria.

Clean ship.

## 🏗️ Tobias Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved** No change from cycle 1 — still pure app code, still no infrastructure surface touched. Cycle 1's suggestion (Grafana p95 panel) is now tracked as #291, which is the right place for it. ### Checked - **No new images, volumes, env vars, ports, or secrets** in cycle 1's delta. - **Flyway `V49` migration unchanged** — applied cleanly in Testcontainers CI during the 1188-test backend run. - **Follow-up issues properly scoped.** #290 covers the backend-side paging work needed before the Load-more UI comes back. #291 covers the observability panel I asked for. #293 covers the kinds filter. All three have concrete acceptance criteria. Clean ship.
Author
Owner

🎨 Leonie Voss — UX/Design Lead

Verdict: Approved

Both UX blockers resolved with the correct interpretation of the spec.

Checked

  • Dismiss is now a sibling of the link (ChronikFuerDichBox.svelte). Outer <li> hosts <a> + <button> as independent interactive elements. The senior-audience tap-drag failure mode I flagged is gone — no browser routing ambiguity between anchor and button. Test lock prevents regression.
  • Comment preview duplication fixed (ChronikRow.svelte:153-168). The placeholder is now „…" — distinct from the document title, signals "preview pending" without implying the comment is quoting itself. The SECURITY: addendum on the TODO is a nice touch: it tells the next person exactly what to do when the backend DTO gains commentPreview.
  • Sentinel split preserves the "underlined accent = document link" visual contract. The title renders as a single styled <span> regardless of where the translator places it in the sentence. That's a real i18n improvement, not just a bug fix.

Deferred (confirmed with you in the cycle 1 summary)

  • 320 px inbox-zero copy tightening („Ältere ansehen →").
  • Rollup count-badge aria-label.
  • Dark-mode contrast spot-check for bg-accent-bg/10 on dark canvas.

All three are polish items, not release-blockers, and consistent with the VR follow-up posture we agreed on.

New note (non-blocking)

The „…" ellipsis at current font scale (~13 px) is visually light — on bright mobile screens in sunlight (the 67-year-old on a park bench test), it may render almost invisible. If you watch it in the wild for a week and it feels too subtle, nudge to text-ink-3 or bump to text-ink-2/80. Not a change I'd ask for today.

Ship it.

## 🎨 Leonie Voss — UX/Design Lead **Verdict: ✅ Approved** Both UX blockers resolved with the correct interpretation of the spec. ### Checked - **Dismiss is now a sibling of the link (`ChronikFuerDichBox.svelte`).** Outer `<li>` hosts `<a>` + `<button>` as independent interactive elements. The senior-audience tap-drag failure mode I flagged is gone — no browser routing ambiguity between anchor and button. Test lock prevents regression. - **Comment preview duplication fixed (`ChronikRow.svelte:153-168`).** The placeholder is now `„…"` — distinct from the document title, signals "preview pending" without implying the comment is quoting itself. The `SECURITY:` addendum on the TODO is a nice touch: it tells the next person exactly what to do when the backend DTO gains `commentPreview`. - **Sentinel split preserves the "underlined accent = document link" visual contract.** The title renders as a single styled `<span>` regardless of where the translator places it in the sentence. That's a real i18n improvement, not just a bug fix. ### Deferred (confirmed with you in the cycle 1 summary) - 320 px inbox-zero copy tightening (`„Ältere ansehen →"`). - Rollup count-badge `aria-label`. - Dark-mode contrast spot-check for `bg-accent-bg/10` on dark canvas. All three are polish items, not release-blockers, and consistent with the VR follow-up posture we agreed on. ### New note (non-blocking) The `„…"` ellipsis at current font scale (~13 px) is visually light — on bright mobile screens in sunlight (the 67-year-old on a park bench test), it may render almost invisible. If you watch it in the wild for a week and it feels too subtle, nudge to `text-ink-3` or bump to `text-ink-2/80`. Not a change I'd ask for today. Ship it.
marcel merged commit b8ffd81b7e into main 2026-04-20 20:38:12 +02:00
marcel deleted branch feat/issue-285-chronik-unified-activity 2026-04-20 20:38:13 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#288