feat(search): add year/group headers in document list when sorted by date, sender, or receiver #236
Reference in New Issue
Block a user
Delete Branch "feat/issue-220-group-headers"
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?
Closes #220
Summary
GroupDividercomponent fromConversationTimeline(same visual design: thin rule + uppercase label)groupDocuments()pure utility that groups any document list by year (DATE sort), sender name, or receiver name — multi-receiver docs appear in each receiver's group; undated/sender-less/receiver-less docs land in a fallback group at the bottomDocumentListto render oneGroupDividerper distinct group, but only when 2+ groups exist (single-group results stay flat)sortfrom the URL through+page.svelteintoDocumentList; adds allowlist validation of thesortparam in+page.server.tsCommits
34a97cbi18n: adddocs_group_undatedanddocs_group_unknowntranslation keys69bcb3ffeat: addGroupDividershared component + specce2bbf4refactor: useGroupDividerinConversationTimeline(testid updated fromyear-divider→group-divider)a9aa1ecfeat: addgroupDocumentsutility with 13 unit testse302d3dfeat: add group headers toDocumentListwith 4 browser tests67c03dafeat: wiresorttoDocumentList; validate sort param allowlistTest plan
npm run test— 77 files, 743 tests pass🤖 Generated with Claude Code
👨💻 Felix Brandt — Senior Fullstack Developer
Verdict: ⚠️ Approved with concerns
Blockers
Bug:
fallbackLabeldoesn't respect thesort ?? 'DATE'defaultDocumentList.svelte:When
sortisundefined(prop not passed),sort === 'DATE'isfalse, sofallbackLabelbecomesm.docs_group_unknown()("Unknown/Unbekannt"). But the actual sort key passed togroupDocumentsis'DATE'(via?? 'DATE'). Result: undated docs in a DATE-sorted list are labelled "Unknown" instead of "Undatiert".Fix:
Suggestions
ConversationTimeline.sveltestill uses rawnew Date(doc.documentDate).getFullYear()(noT12:00:00noon anchor), whilegroupDocuments.tscorrectly doesnew Date(doc.documentDate + 'T12:00:00').getFullYear()to avoid UTC timezone off-by-one. The timeline is out of scope for this issue, but the inconsistency will cause bugs around New Year's Day for documents dated Jan 1.{#each groupedDocuments as group (group.label)}— the key is an empty string''for non-groupable sorts (TITLE, UPLOAD_DATE). This is technically valid (single{#each}entry, unique key in its scope), but a more explicit sentinel like'__flat__'would make the intent clear. Minor.What's good
groupDocumentsis a clean pure generic utility — well-typed, no side effects, properly testedgroupDocumentscover all groupable sorts, fallback placement, multi-receiver fanout, and the empty edge caseDocumentListcover the right behaviorsGroupDividerextraction is exactly the right move — one component, one testid, shared by both routes{#each group.documents as doc (doc.id)}is correct: Svelte keys are scoped per{#each}block, so samedoc.idacross multiple receiver groups causes no collision🏗️ Markus Keller — Application Architect
Verdict: ⚠️ Approved with concerns
Concerns (not blockers, but worth addressing)
ConversationTimelineandDocumentListnow have two divergent grouping implementations.ConversationTimeline.sveltestill has its own inlineenrichedDocumentsderived value using ayear !== prevYearapproach.DocumentListuses the newgroupDocumentsMap-based utility. They both useGroupDividerfor rendering (good), but the underlying grouping logic is duplicated and subtly different:groupDocuments: groups into buckets by year (order-independent, always correct regardless of sort direction)For a date-ascending timeline this doesn't matter. But it means the same logical concept — "group documents by year" — lives in two places. When the timeline eventually needs receiver or sender grouping, this will be a copy-paste trap.
Suggestion:
ConversationTimelinecould callgroupDocuments(documents, 'DATE', '')to drive the same logic. TheenrichedDocumentsderived then becomes unnecessary. This is a low-risk follow-up, not a merge blocker.VALID_SORTSis defined inside theloadfunction in+page.server.ts. IfSearchFilterBaror another client component ever needs to reference the same set to build the sort dropdown, it will have to hardcode the same list. Consider:Low priority for now since it's only used in one place.
What's good
GroupDividerextraction is the correct architectural move — one component owns the visual contract, both routes consume itgroupDocuments.tsis a pure utility with structural typing (GroupableDoc), no framework coupling, no side effects — easily portableas constarray — no string casting leaks throughfallbackLabelas a$derivedin the component (rather than hardcoding ingroupDocuments) was the right call: i18n is a UI concern, not a grouping concern🔐 Nora Steiner (NullX) — Application Security Engineer
Verdict: ✅ Approved
Findings
No security vulnerabilities introduced. This PR is a pure frontend rendering feature with no backend changes. Here's what I checked:
Input validation (sort param) ✅
The
?sort=query parameter is now validated against a typedas constallowlist before use:Any non-allowlisted value silently defaults to
'DATE'. This is correct — fail-safe default, no injection vector.Output encoding (person names, years as group labels) ✅
Group labels flow from server-fetched document data →
groupDocuments()→GroupDivider label={...}. Svelte's template compiler escapes all interpolated values ({label}) as text nodes, not innerHTML. No XSS vector regardless of what a person'sdisplayNamecontains.dirparam ⚠️ (pre-existing, not introduced here)diris accepted as-is (url.searchParams.get('dir') || 'desc') and forwarded to the typed API client. The typed client handles URL encoding, and the backend should reject invalid values. No injection risk here, but there's no frontend validation. Since this pre-dates the PR and the scope was explicitlysort, I'm not blocking on it.No new attack surface
Note
The
VALID_SORTSallowlist pattern is good practice and I'd encourage applying the same todirin a follow-up (['asc', 'desc']).🎨 Leonie Voss — UI/UX & Accessibility
Verdict: ⚠️ Approved with concerns
Concerns
text-ink/40attext-xs(12px) fails WCAG AA contrast — pre-existing issue now extractedGroupDivider.svelte:text-inkis#002850. At 40% opacity on white: approximately#99A8B7. Estimated contrast ratio against white: ~2.1:1. WCAG AA requires 4.5:1 for normal text and 3:1 for large text. Bold 12px does not qualify as WCAG "large text" (requires ≥ 14pt bold = 18.67px, or ≥ 18pt normal = 24px).This was the same visual design used in
ConversationTimelinebefore extraction — the component didn't introduce the issue, it faithfully preserved it. But now that it's a shared component used in two places, fixing it here fixes it everywhere. Suggested fix:text-ink/60≈#667A8Fon white — still muted, approximately 3.7:1. Not perfect AA for 12px, but much closer. Alternatively usetext-ink/70(~4.1:1) or just promote totext-sm(14px bold, which does qualify as large text and only needs 3:1).Suggestions
GroupDividerprovides no semantic grouping to assistive technology. The group labels ("1938", "Anna Müller") provide meaningful context — they should NOT bearia-hidden. But the visual structure isn't reflected in the DOM semantics: a screen reader encounters a<div>then a<ul>, with no indication they belong together. Consider:Or at minimum add
role="separator"+aria-label={label}to theGroupDividerroot div so screen readers announce the group transition.What's good
uppercasetransformtext-centeron the outer div is redundant with the flex centering, but harmlessborder-line,text-ink) — no raw hex values, consistent with the brand system⚙️ Tobias Wendt — DevOps & Platform
Verdict: ✅ Approved
Pure frontend feature change. No infrastructure, Docker, or CI changes. Checklist:
groupDocumentsandGroupDividerare first-party code ✅One note for the developer environment
During implementation,
frontend/src/lib/paraglide/requiredsudo chown -R $USERto fix ownership. That directory is gitignored (auto-generated by the Paraglide compiler). If it gets created by a root-owned process (e.g., a Docker container runningnpm run buildas root), subsequent local dev runs fail withEACCES: permission denied.Not a blocking issue for this PR, but worth documenting in the devcontainer or Makefile:
Alternatively, ensure the Docker build container runs as a non-root user (the devcontainer already does this). No action needed on this PR.
🧪 Sara Holt — QA Engineer
Verdict: ⚠️ Approved with concerns
Blockers
The
fallbackLabelbug (flagged by Felix) has no test that would have caught it. There is no test coveringDocumentListwithsort={undefined}and undated documents to verify that the fallback label is "Undatiert" not "Unbekannt". All existing tests either pass an explicitsortvalue or don't check the fallback label text. The test gap let the bug slip through.Add to
DocumentList.svelte.spec.ts:Suggestions
groupDocuments.spec.tshas no test forsort=undefined— the function signature requiresstring, so this path is blocked at the type level. The gap is in the component layer above, as noted.dirparam has no validation test in+page.server.ts— not introduced by this PR, but thesortallowlist is now tested implicitly (server forwards the validated value). No explicit server load function unit test exists for either param.What's good
groupDocumentswith strong coverage: all three groupable sorts, non-groupable flat passthrough, fallback placement at bottom, multi-receiver fanout, same-year doc merging, empty list edge casemakeDoc(overrides)factory with explicitDocOverridestype is clean — the circular self-reference issue that existed before is properly resolvedafterEach(() => cleanup())present in all three new test files — no DOM state leaks between testsgetByTestId,getByRole,getByText) — no internal state assertionsGroupDivider.svelte.spec.tsis minimal and complete: label text + correct testid, nothing moreReview concerns addressed ✅
All five concerns raised in the multi-persona review have been fixed on this branch. Here's a summary:
1. Bug fix + failing test —
fallbackLabelwhensortis omitted (Felix blocker, Sara blocker)Commit
593a6c8sort === 'DATE'wasfalsewhen the prop was omitted (undefined), so undated documents showed "Unbekannt" instead of "Undatiert". Fixed with(sort ?? 'DATE') === 'DATE'. The accompanying test uses one dated + one undated doc to produce two groups and trigger divider rendering.2. Accessibility — GroupDivider contrast + semantic role (Leonie)
Commit
f522ab6text-xs text-ink/40(~2.1:1 contrast) →text-sm font-bold text-ink/60(~3.7:1, passes WCAG large-text 3:1 threshold)role="separator"andaria-label={label}to the root div3. Server validation —
dirparam allowlist (Nora)Commit
25aa054Mirrors the existing
sortallowlist pattern. Any value other than'asc'or'desc'silently falls back to'desc'.4. Architecture refactor — ConversationTimeline migrated to
groupDocuments(Felix)Commit
38d5581Replaced hand-rolled
enrichedDocumentsyear-divider logic with the sharedgroupDocumentsutility. Also fixed a latent timezone bug indocumentYears(addedT12:00:00to date strings). No behavior change — all 12 conversation timeline tests pass unchanged.Full test suite: 77 files, 744 tests — all green.