feat(chronik): add cursor/offset pagination to /api/dashboard/activity + wire "Mehr laden" #290
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?
Background
Deferred from PR #288 during review cycle 1 (Markus Keller + Sara Holt).
The first iteration of
/chronikshipped a "Mehr laden" button that calledGET /api/dashboard/activity?limit=40&offset=N, but the backendDashboardController.getActivityonly acceptslimit—offsetwas silently ignored, causing every click to re-fetch and duplicate the top-40 rows. The UI was removed from PR #288 rather than ship broken behaviour.Concern
No pagination past 40 activity items. The spec (
docs/specs/chronik-spec.html§02 state 10) shows a "Mehr laden" button with an accompanying loading micro-state (3 static skeleton rows,aria-busy, focus preservation,aria-live="polite"announcement).Scope
Backend
offset(orbefore: OffsetDateTimecursor) query param toDashboardController.getActivity.DashboardService.getActivity→AuditLogQueryService.findActivityFeed→AuditLogQueryRepository.findRolledUpActivityFeed.before=timestamp) is more robust against live inserts; offset is simpler. Recommend cursor since the feed is time-ordered DESC.Frontend
src/routes/chronik/+page.svelte:<button>witharia-busyduring fetch, focus preserved aftertick().aria-live="polite"region announces "{count} weitere Einträge geladen" (chronik_load_more_announcementkey already exists).before(oroffset) from the last row'shappenedAt(or the current merged feed length).+page.svelte.spec.tscovering: append on success, aria-busy toggling, skeleton render, focus preservation, no-op when already loading.Acceptance
Reference
docs/specs/chronik-spec.html§02 state 10🏛️ Markus Keller — Senior Application Architect
Observations
V49__add_audit_log_rollup_index.sqlis(actor_id, document_id, kind, happened_at DESC)with a partial WHERE clause matching the rollup kinds. Cursor-basedWHERE happened_at < :beforequeries walk it in index order with zero sort cost.NotificationController.getNotificationsat line 51–57 already uses the Spring Datapage/sizeconvention withPageRequest.of(...). Using the same pattern here would be consistent — but that pattern is offset-based under the hood and has the live-insert drift problem.ORDER BY ag.happened_at DESC LIMIT :limitsits after the CTE aggregation. AddingWHERE ag.happened_at < :beforeto the final SELECT is the cleanest cursor insertion point — the CTEs don't need to change.happened_atisMIN(s.happened_at)(start-of-session). Two sessions with the exact same start timestamp are possible in theory (two actors transcribe the same document in the same millisecond) but vanishingly unlikely in practice. Still: the composite cursor(happened_at, document_id, kind)is tie-break-safe.Recommendations
WHERE happened_at < :before ORDER BY happened_at DESC LIMIT :limitas a forward-only range scan. Offset forces a scan+skip that degrades with page depth. Cursor also survives live SSE inserts without skipping/duplicating rows.(beforeHappenedAt, beforeDocumentId, beforeKind). Controller accepts three query params; the repo WHERE becomes(happened_at, document_id, kind) < (:beforeHappenedAt, :beforeDocumentId, :beforeKind)using Postgres's row-comparison operator. Trivially supported, no ORDER BY change needed.Pageablehere — it buys nothing for cursor pagination and couples this controller to the page/size mental model the notifications controller uses. Plain@RequestParamwith explicit names documents the contract clearly.lastRowCursor, notoffsetor page number. After each fetch, store the(happenedAt, documentId, kind)of the last row. Next "Mehr laden" sends those three params.< limitrows when there's no more. Frontend hides the Load-more button. No need for a separate "totalCount" orhasNextPagefield.Open Decisions
beforeAt,beforeDocId,beforeKind) — explicit, easy to debug, compiles straight to@RequestParam. Option B: single opaquecursorparam (base64-encoded tuple) — hides implementation detail, easier to evolve later, harder to tweak via curl. Recommend A for this codebase — it's a family archive, not a public API, and debuggability wins.👨💻 Felix Brandt — Senior Fullstack Developer
Observations
findRolledUpActivityFeedis a 4-CTE pipeline that ends withORDER BY ag.happened_at DESC LIMIT :limit. Adding a cursor clause means one newWHEREin the final SELECT and three new@Parambindings in the repo method — no other changes.DashboardController.getActivityisMath.min(limit, 40)— that cap stays. The cursor just decides where the 40 starts.ActivityFeedItemDTOalready hashappenedAt+documentId+kindfields — the frontend can build the cursor for the next request without new DTO surface.Recommendations
rolledUpFeed_cursor_returns_rows_before_the_cursor_timestamp— insert 50 events, fetch first 40 without cursor, then fetch with cursor = last row's(happenedAt, documentId, kind), assert 10 non-overlapping rows.rolledUpFeed_cursor_returns_empty_when_no_older_rows— fetch with cursor set past the oldest row, assertList.of().rolledUpFeed_cursor_tie_break_is_stable— two sessions with the samehappened_aton different documents. Cursor-with-docId must not skip or duplicate.activity_clamps_limit_to_40already exists; addactivity_threads_before_params_through_to_service.$state+$derived, no$effectloops.loadMore()is an async function that:isLoadingMore || atEndisLoadingMore = truelastRow.happenedAt / documentId / kindquery params< 40returned: setsatEnd = trueawait tick(),loadMoreBtn?.focus()+page.svelte.spec.tswith mockedfetch— assert:aria-busy="true"while loading.data-testid="chronik-skeleton-row"attribute — reuse the pattern from the original #288 implementation. Three<li>with static height, no shimmer.bind:this={loadMoreBtn}requireslet loadMoreBtn = $state<HTMLButtonElement | null>(null). Forgetting the$state(...)wrapper is the bug that tripped me up in #288; the svelte linter will flag it but CI won't block on warnings — worth writing it correctly first time.Open Decisions
🔒 Nora "NullX" Steiner — Application Security Engineer
Observations
@Param("currentUserId")+@Param("limit"). Adding@Param("beforeAt")/@Param("beforeDocId")/@Param("beforeKind")continues the pattern — no injection surface./api/dashboard/activityis gated by@RequirePermission(Permission.READ_ALL)at theDashboardControllerclass level. Cursor params don't change the authorization story.youMentionedflag is computed from:currentUserId. It's a display hint, not an access control — any user withREAD_ALLsees all rows withyouMentioned=falsefor events that mention others. That's existing behavior, not a new concern from this issue.Recommendations
AuditKindenum at the controller boundary. Spring's enum-as-query-param binding does this by default; just type the param asAuditKind(notString). If a caller sends?beforeKind=<script>, Spring returns 400 before the query runs.INVALID_CURSOR)./api/dashboard/activityshould cover this; confirm it's set conservatively (e.g., 60 req/min per user) and doesn't have a generous "API burst" bucket that lets a scraper drain the feed in minutes.happenedAtfrom untrusted sources ever reaches the query as a string. Using Spring'sOffsetDateTimebinding means malformed dates → 400 before SQL. Do not accept an epoch-millis integer alternative in the same endpoint — one format, one parser.Open Decisions
🧪 Sara Holt — Senior QA Engineer
Observations
AuditLogQueryRepositoryRolledUpTestalready runs against realpostgres:16-alpinevia Testcontainers and has 6 rollup tests. Extending it with cursor cases inherits the right infrastructure — no new@Importneeded.+page.sveltecurrently has no co-located spec file (the chronik page composition was covered by component-level specs). This issue is the right time to add+page.svelte.spec.ts.vitest-browser-svelte+vitest/browserare already in the project (seeChronikFuerDichBox.svelte.spec.ts) — load-more assertions like focus preservation work best in real browser mode.Recommendations
count=50, gapSeconds=120so each event seeds its own session (>120 min gap not triggered, but cursor works on aggregated rows).[].happened_aton different(document_id, kind)→ composite cursor disambiguates; neither row is duplicated or skipped across page boundary.audit_log.document_idhasON DELETE CASCADE, the rows are gone entirely. The cursor still paginates the remaining rows correctly. Write this test to lock the behavior./chronikto the existing axe-playwright sweep once the Load-more button is back — the skeleton rows must also pass contrast checks when rendered.Open Decisions
🏗️ Tobias Wendt — DevOps & Platform Engineer
Observations
V49partial covering index is(actor_id, document_id, kind, happened_at DESC)WHERE kind IN (...). Cursor queries usingWHERE happened_at < :beforehit the index on thehappened_at DESCcomponent directly — index-only range scan, no heap fetch for the tuple comparison.Recommendations
/api/dashboard/activitygets called more often per user session (each Load-more click is another request). The Grafana p95 panel from #291 is the trip-wire that tells us if a bad index or a regression slows it down. Without that panel, a slow cursor query surfaces as a user complaint first.Open Decisions
🎨 Leonie Voss — UX/Design Lead
Observations
docs/specs/chronik-spec.html§02 state 10 specifies: button label swaps "Mehr laden" → "Lädt …",aria-busy="true", 3 static skeleton rows below the button, focus stays on the button after load,aria-live="polite"announceschronik_load_more_announcementwith count. All of that exists as Paraglide keys already (added in PR #288).sm:and above. The spec shows a centered pill, not a full-width bar.Recommendations
„Das war alles — älter als {first visible day}."This is a dignity thing for seniors: an empty button area where something used to be feels broken; an explicit "you've seen everything" is reassuring. Copy can be an i18n keychronik_end_of_feeddeferred to this issue's scope.{count} weitere Einträge geladenreads fine in German but at screen-reader speed it's verbose. Consider also announcing the end-of-feed state — "Keine älteren Einträge" — when we transition. Silence when the feed ends is a worse UX than the announcement.<body>.prefers-reduced-motion: skeleton rows are already specified as static (no shimmer) per Marcel's #285 resolution. Keep it — it's the senior-friendly default. No animation gating needed since there's no animation to gate.Open Decisions
„Das war alles"— direct, warm. Option B:„Keine älteren Einträge"— neutral, matches the "Keine neuen Erwähnungen" inbox-zero style. Option C: no message at all, just hide the button. Recommend A — the archive is the family history, finishing the list deserves a human phrasing, not a blank space.🗳️ Decision Queue — Action Required
2 decisions need your input before implementation starts.
API Design
beforeAt,beforeDocId,beforeKind. Explicit, debuggable via curl, compiles straight to@RequestParamwith Spring's built-in type binding (and security validation ofbeforeKindvia theAuditKindenum).cursorparam (base64-encoded tuple). Hides implementation detail, easier to evolve server-side without breaking clients.UX
„Das war alles"— direct, warm.„Keine älteren Einträge"— neutral, matches„Keine neuen Erwähnungen"style.