feat: add year/group headers in search results when sorted by date #220

Closed
opened 2026-04-12 08:28:55 +02:00 by marcel · 7 comments
Owner

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

  • Group by year only, or also by decade for large spans?
  • Should the headers be sticky while scrolling?
  • Should this also work on the person detail page's document lists?
## 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 - Group by year only, or also by decade for large spans? - Should the headers be sticky while scrolling? - Should this also work on the person detail page's document lists?
marcel added the featureui labels 2026-04-12 08:29:03 +02:00
Author
Owner

🎨 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

  • Date sort → year headers always (no decade switching, no threshold logic)
  • Sender/receiver sort → bare person name only (no initials chip, no document count)

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 ConversationTimeline year-divider pattern verbatim
The 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 ConversationTimeline reuse 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 both ConversationTimeline and the search results can use.

## 🎨 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** - Date sort → year headers always (no decade switching, no threshold logic) - Sender/receiver sort → bare person name only (no initials chip, no document count) **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 `ConversationTimeline` year-divider pattern verbatim** The 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 `ConversationTimeline` reuse 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 both `ConversationTimeline` and the search results can use.
Author
Owner

👨‍💻 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.svelte script, not inline in the template. The template should read a groupedDocuments derived value; the grouping algorithm should be extracted and tested independently via Vitest.

  • GroupDivider extraction: Leonie confirmed we reuse the ConversationTimeline divider pattern. That means extracting it into $lib/components/GroupDivider.svelte — a single prop label: string, same classes. Both ConversationTimeline and 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, and es.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.svelte in $lib/components/ with a single label prop — no styling decisions in the consumer.
  • Return type for the grouping $derived: Array<{ label: string; documents: Document[] }> — explicit, typed, testable.
  • Vitest unit tests for the grouping function: same-year documents produce one group, documents across three years produce three groups, undated documents land in a named group at the end.
## 👨‍💻 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.svelte` script, not inline in the template. The template should read a `groupedDocuments` derived value; the grouping algorithm should be extracted and tested independently via Vitest. - **`GroupDivider` extraction**: Leonie confirmed we reuse the `ConversationTimeline` divider pattern. That means extracting it into `$lib/components/GroupDivider.svelte` — a single prop `label: string`, same classes. Both `ConversationTimeline` and 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`, and `es.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.svelte` in `$lib/components/` with a single `label` prop — no styling decisions in the consumer. - Return type for the grouping `$derived`: `Array<{ label: string; documents: Document[] }>` — explicit, typed, testable. - Vitest unit tests for the grouping function: same-year documents produce one group, documents across three years produce three groups, undated documents land in a named group at the end.
Author
Owner

🏗️ 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 grouped Array<{ 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 GroupDivider component — 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.svelte is the correct home. ConversationTimeline should be updated to use it too in the same PR, otherwise the extraction is half-done.

Suggestions

  • Move sort state to a URL param (?sort=date) if it isn't already — grouping, back-navigation, and link-sharing all benefit.
  • Keep the server load function returning a flat Document[]. The grouped structure is a pure frontend transformation.
  • Update ConversationTimeline to use the new shared GroupDivider component in the same PR — don't leave two implementations in parallel.
## 🏗️ 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 grouped `Array<{ 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 `GroupDivider` component — 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.svelte` is the correct home. `ConversationTimeline` should be updated to use it too in the same PR, otherwise the extraction is half-done. ### Suggestions - Move sort state to a URL param (`?sort=date`) if it isn't already — grouping, back-navigation, and link-sharing all benefit. - Keep the server `load` function returning a flat `Document[]`. The grouped structure is a pure frontend transformation. - Update `ConversationTimeline` to use the new shared `GroupDivider` component in the same PR — don't leave two implementations in parallel.
Author
Owner

🧪 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-testid consistency: ConversationTimeline already uses data-testid="year-divider". Should the search results use the same testid? If GroupDivider is 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

  • Acceptance criterion: group headers appear only when the sorted result set spans at least 2 distinct group values.
  • Parameterized Vitest test for groupDocuments(): (documents, sortField, expectedGroups) covering date grouping, sender grouping, receiver grouping (with multi-receiver), and all-undated.
  • Explicit regression test: switch sort from "date" to "title" — assert zero data-testid="group-divider" elements in the DOM.
## 🧪 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-testid` consistency**: `ConversationTimeline` already uses `data-testid="year-divider"`. Should the search results use the same testid? If `GroupDivider` is 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 - Acceptance criterion: group headers appear only when the sorted result set spans at least 2 distinct group values. - Parameterized Vitest test for `groupDocuments()`: `(documents, sortField, expectedGroups)` covering date grouping, sender grouping, receiver grouping (with multi-receiver), and all-undated. - Explicit regression test: switch sort from "date" to "title" — assert zero `data-testid="group-divider"` elements in the DOM.
Author
Owner

🔐 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-side load function 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

  • Allowlist validation for the sort URL param in the server load function: const VALID_SORTS = ['date', 'sender', 'receiver', 'title'] as const — reject or default anything outside that set.
  • No other security action needed. This is a clean, low-risk feature from my angle.
## 🔐 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-side `load` function 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 - Allowlist validation for the sort URL param in the server `load` function: `const VALID_SORTS = ['date', 'sender', 'receiver', 'title'] as const` — reject or default anything outside that set. - No other security action needed. This is a clean, low-risk feature from my angle.
Author
Owner

🛠️ 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 $derived in the page script. Make sure it doesn't use any browser-only APIs (no window, document, localStorage). SvelteKit renders the page server-side first — if the grouping function references browser globals, it will throw on the server. $derived with 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 if svelte-check or 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

  • Confirm npm run check (svelte-check) catches missing Paraglide translation keys in CI — if it does, i18n gaps won't reach production.
  • No platform work required for this feature. Ship it.
## 🛠️ 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 `$derived` in the page script. Make sure it doesn't use any browser-only APIs (no `window`, `document`, `localStorage`). SvelteKit renders the page server-side first — if the grouping function references browser globals, it will throw on the server. `$derived` with 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 if `svelte-check` or 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 - Confirm `npm run check` (svelte-check) catches missing Paraglide translation keys in CI — if it does, i18n gaps won't reach production. - No platform work required for this feature. Ship it.
Author
Owner

Implemented in PR #236 on branch feat/issue-220-group-headers.

What was built:

  • GroupDivider.svelte — shared component (extracted from ConversationTimeline): 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 group
  • DocumentList.svelte — now accepts sort prop; renders one GroupDivider per group, but only when 2+ distinct groups exist
  • +page.server.ts — sort param validated against allowlist ['DATE','TITLE','SENDER','RECEIVER','UPLOAD_DATE']
  • i18ndocs_group_undated (Undatiert/Undated/Sin fecha) and docs_group_unknown (Unbekannt/Unknown/Desconocido) in de/en/es
  • ConversationTimeline.svelte — refactored to use shared GroupDivider (no visual change)

Commits: 34a97cb · 69bcb3f · ce2bbf4 · a9aa1ec · e302d3d · 67c03da

Tests: 77 files · 743 tests — all green

Implemented in PR #236 on branch `feat/issue-220-group-headers`. **What was built:** - **`GroupDivider.svelte`** — shared component (extracted from `ConversationTimeline`): 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 group - **`DocumentList.svelte`** — now accepts `sort` prop; renders one `GroupDivider` per group, but only when 2+ distinct groups exist - **`+page.server.ts`** — sort param validated against allowlist `['DATE','TITLE','SENDER','RECEIVER','UPLOAD_DATE']` - **i18n** — `docs_group_undated` (Undatiert/Undated/Sin fecha) and `docs_group_unknown` (Unbekannt/Unknown/Desconocido) in de/en/es - **`ConversationTimeline.svelte`** — refactored to use shared `GroupDivider` (no visual change) **Commits:** `34a97cb` · `69bcb3f` · `ce2bbf4` · `a9aa1ec` · `e302d3d` · `67c03da` **Tests:** 77 files · 743 tests — all green
Sign in to join this conversation.
No Label feature ui
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#220