feat: add year/group headers in search results when sorted by date #220
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?
Problem
When sorting search results by date, documents appear as a flat list. There's no visual grouping that helps users orient themselves chronologically. The conversation/briefwechsel page already has a timeline feel, but the main search results don't.
Proposal
When the user sorts by date, insert group headers (e.g. year headers like "1938", "1939") between results to break up the list and give a sense of chronological structure. This is a lightweight alternative to a full timeline view.
Open questions
🎨 Leonie Voss — UI/UX Design Lead
Design decisions for the group headers feature — all open questions resolved, including a scope extension to sender and receiver sort options.
Resolved
1. Grouping granularity by sort type
2. Scope extended: sender and receiver sort
Grouping headers apply to date, sender, and receiver sort options — not date only as originally written.
3. Multi-receiver documents → appear in all matching groups
A document with three receivers appears under each receiver's group header. Discoverability wins over avoiding apparent duplication.
4. Visual design → reuse
ConversationTimelineyear-divider pattern verbatimThe conversations page already has the right pattern: horizontal rule + centered label + horizontal rule (
border-line,text-ink/40,font-sans text-xs font-bold tracking-widest uppercase). No new design needed — same component, different content.5. Sticky headers → no
Plain inline dividers only. Avoids z-index coordination with the existing sticky global nav and keeps the mobile experience clean.
6. Undated / sender-less documents → named group at the bottom
Documents with no value for the active sort field land in a named group: "Undatiert" (date sort) or "Unbekannt" (sender/receiver sort), always at the bottom of the list.
7. Sort switch behavior → instant, no transition
Headers appear and disappear immediately when the sort selection changes. No animation, no special treatment.
8. Person detail page → out of scope
Grouping applies to main search results only. Person detail page document lists are unchanged.
The
ConversationTimelinereuse is the key call here — it keeps the visual language consistent across the app and means zero new design tokens or component variants. Implement the divider as a shared component that bothConversationTimelineand the search results can use.👨💻 Felix Brandt — Senior Fullstack Developer
Questions & Observations
Composite key collision in multi-receiver groups: when a document appears in multiple receiver groups, using
(doc.id)as the{#each}key will produce duplicate keys in the same loop. Svelte will warn and reconciliation breaks. The key needs to be composite —(groupKey + '-' + doc.id)or similar — so each rendered item is unique within the list.Grouping logic placement: the group transformation (flat list → grouped structure with dividers) belongs in a
$derived.by()in the+page.sveltescript, not inline in the template. The template should read agroupedDocumentsderived value; the grouping algorithm should be extracted and tested independently via Vitest.GroupDividerextraction: Leonie confirmed we reuse theConversationTimelinedivider pattern. That means extracting it into$lib/components/GroupDivider.svelte— a single proplabel: string, same classes. BothConversationTimelineand the search results page use the shared component. If we don't extract it, we have two copies of the same markup drifting apart over time.i18n strings: "Undatiert" and "Unbekannt" need to be Paraglide translation keys in
de.json,en.json, andes.json— not hardcoded strings in the template.Sender vs receiver sort — same component, different data shape: sender sort produces one group per document (a document has one sender); receiver sort can produce N groups per document. The grouping function should handle both cleanly. Worth a single function
groupDocuments(documents, sortField)with a clear return type rather than duplicated logic for each sort type.Suggestions
GroupDivider.sveltein$lib/components/with a singlelabelprop — no styling decisions in the consumer.$derived:Array<{ label: string; documents: Document[] }>— explicit, typed, testable.🏗️ Markus Keller — Application Architect
Questions & Observations
Pure frontend change — no backend involvement: the grouping is a client-side transformation of already-fetched document data. No new endpoints, no query changes, no schema changes. The backend already returns the sort field values needed (documentDate, sender, receivers). Confirming this is the intended scope — if someone later proposes a backend-driven grouping endpoint, that would be premature complexity.
URL state and sort param: the grouping activates automatically based on the active sort selection. Does the sort option already live in a URL search param (e.g.
?sort=date)? If so, grouping piggybacks on it for free — back button and link sharing work without extra state. If sort state currently lives only in component state, this feature is a good prompt to move it to a URL param.Grouped data structure is a view concern: the flat
Document[]from the server load is the canonical data. The groupedArray<{ label: string; documents: Document[] }>is a derived view of that data. This distinction matters for where the logic lives —$derived.by()in the page script is correct; the server load function should not be changed to return a pre-grouped structure.Shared
GroupDividercomponent — location: Felix flagged extracting this into$lib/components/. I'd confirm that's the right location — it's a generic, reusable visual primitive with no domain coupling.$lib/components/GroupDivider.svelteis the correct home.ConversationTimelineshould be updated to use it too in the same PR, otherwise the extraction is half-done.Suggestions
?sort=date) if it isn't already — grouping, back-navigation, and link-sharing all benefit.loadfunction returning a flatDocument[]. The grouped structure is a pure frontend transformation.ConversationTimelineto use the new sharedGroupDividercomponent in the same PR — don't leave two implementations in parallel.🧪 Sara Holt — QA Engineer
Questions & Observations
Edge case: all documents in the same year — does a single group header appear at the top, or no header at all? A lone header with no context above it looks odd. Worth an explicit acceptance criterion: show the header only when there are at least two distinct groups (i.e. the header adds orientation value).
Edge case: corpus with only undated documents — the entire list is "Undatiert". Does a single group header appear? Same question as above, same recommendation.
Edge case: single document — one document in the entire result set. No grouping needed. Same "at least two groups" rule applies.
Multi-receiver grouping test: a document with receivers [Anna, Karl] must appear in Anna's group AND Karl's group. This needs an explicit integration test or Vitest unit test for the grouping function — it's the most unusual behavior in this feature and the most likely to regress silently.
Regression: flat sort modes — when sorted by title or status (non-grouped sorts), the result list must be fully flat with zero group headers. Needs an explicit test case.
data-testidconsistency:ConversationTimelinealready usesdata-testid="year-divider". Should the search results use the same testid? IfGroupDivideris extracted as a shared component (as Felix suggested), the testid should be on the component itself — one testid, reused everywhere. That means existing Playwright/Vitest tests for the conversations page continue to work without changes.Suggestions
groupDocuments():(documents, sortField, expectedGroups)covering date grouping, sender grouping, receiver grouping (with multi-receiver), and all-undated.data-testid="group-divider"elements in the DOM.🔐 Nora Steiner (NullX) — Security Engineer
Questions & Observations
Person names in divider headers: the group label for sender/receiver sort is a person's display name rendered directly into the DOM. Svelte auto-escapes template expressions, so a name containing
<script>or HTML tags will be rendered as text, not executed. No XSS risk here — just confirming this is covered by the framework, not something that needs an explicit sanitization step.Sort parameter in URL: if the active sort option moves to a URL param (as Markus suggested, e.g.
?sort=date), ensure the server-sideloadfunction validates it against an allowlist of accepted sort values before using it in a query. A crafted URL with?sort='; DROP TABLE--should return an error or fall back to a safe default, not be passed to the database. This is standard input validation at the system boundary.No new authorization surface: this feature adds no new API endpoints and no new permission checks. The existing document read authorization is unchanged — users see exactly the documents they could already see, just visually grouped. No security concern here.
No new data exposure: group headers show year values and person names that are already visible in the document cards themselves. Grouping doesn't surface any data the user couldn't already see.
Suggestions
loadfunction:const VALID_SORTS = ['date', 'sender', 'receiver', 'title'] as const— reject or default anything outside that set.🛠️ Tobias Wendt — DevOps & Platform Engineer
Questions & Observations
Pure frontend change — no infrastructure impact: no new services, no new volumes, no environment variable changes, no Flyway migrations. Deploy is a standard
docker compose pull && docker compose up -d. Nothing to do on the platform side.SSR compatibility: the grouping logic runs as a
$derivedin the page script. Make sure it doesn't use any browser-only APIs (nowindow,document,localStorage). SvelteKit renders the page server-side first — if the grouping function references browser globals, it will throw on the server.$derivedwith pure data transformations (array operations, date parsing) is fine.i18n completeness before deploy: "Undatiert" and "Unbekannt" need entries in all three language files (
de.json,en.json,es.json) before the feature ships. A missing translation key in production renders the raw key string to users. This is a CI check ifsvelte-checkor the Paraglide build catches missing keys — worth confirming the CI pipeline covers it.No new observability needed: this feature has no backend footprint. No new log lines, no new metrics, no query plan changes to monitor.
Suggestions
npm run check(svelte-check) catches missing Paraglide translation keys in CI — if it does, i18n gaps won't reach production.Implemented in PR #236 on branch
feat/issue-220-group-headers.What was built:
GroupDivider.svelte— shared component (extracted fromConversationTimeline): horizontal rule with uppercase label,data-testid="group-divider"groupDocuments.ts— pure generic utility; groups by year (DATE), sender displayName, or receiver displayName; multi-receiver docs appear in each receiver's group; undated/anonymous docs land in a fallback group at the bottom; non-groupable sorts (TITLE, UPLOAD_DATE) return a single flat groupDocumentList.svelte— now acceptssortprop; renders oneGroupDividerper group, but only when 2+ distinct groups exist+page.server.ts— sort param validated against allowlist['DATE','TITLE','SENDER','RECEIVER','UPLOAD_DATE']docs_group_undated(Undatiert/Undated/Sin fecha) anddocs_group_unknown(Unbekannt/Unknown/Desconocido) in de/en/esConversationTimeline.svelte— refactored to use sharedGroupDivider(no visual change)Commits:
34a97cb·69bcb3f·ce2bbf4·a9aa1ec·e302d3d·67c03daTests: 77 files · 743 tests — all green