feat: show top conversation pairs on briefwechsel entry state #224

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

Problem

The briefwechsel page requires the user to select a person before anything is shown. Users who don't know who corresponded with whom have no starting point for exploration.

Proposal

On the entry/empty state of the briefwechsel page (before a person is selected), show a list of the most active conversation pairs ranked by document count. E.g.:

  • Opa ↔ Oma — 87 Dokumente
  • Opa ↔ Finanzamt — 12 Dokumente
  • Tante Else ↔ Oma — 8 Dokumente

Clicking a pair pre-fills both sender and receiver and loads the conversation timeline.

Open questions

  • Does the backend need a new endpoint for aggregated conversation pairs, or can this be derived from existing data?
  • How many pairs to show? Top 10? Top 20?
  • Should the current "recent persons" suggestions (from localStorage) coexist with this, or does this replace them?
  • Should the list also show the date range of the conversation (e.g. "1921–1948")?
## Problem The briefwechsel page requires the user to select a person before anything is shown. Users who don't know who corresponded with whom have no starting point for exploration. ## Proposal On the entry/empty state of the briefwechsel page (before a person is selected), show a list of the most active conversation pairs ranked by document count. E.g.: - Opa ↔ Oma — 87 Dokumente - Opa ↔ Finanzamt — 12 Dokumente - Tante Else ↔ Oma — 8 Dokumente Clicking a pair pre-fills both sender and receiver and loads the conversation timeline. ### Open questions - Does the backend need a new endpoint for aggregated conversation pairs, or can this be derived from existing data? - How many pairs to show? Top 10? Top 20? - Should the current "recent persons" suggestions (from localStorage) coexist with this, or does this replace them? - Should the list also show the date range of the conversation (e.g. "1921–1948")?
marcel added the conversationfeature labels 2026-04-12 08:43:56 +02:00
Author
Owner

🎨 Leonie Voss — UI/UX Design Lead

Design discussion covering layout, mobile behavior, accessibility, and interaction patterns for the top pairs entry state.


Resolved

1. Empty state structure
Three tiers in CorrespondenzHero: typeahead (full width, max-w-sm) stays at the top. Below it, recent persons and top pairs sit side-by-side on desktop (max-w-2xl wrapper), stacked on mobile (recent persons on top, top pairs below). The current hero wrapper is solid — widen only the bottom section.

2. Mobile layout — pills for all name lengths
Use the existing abbreviation pill pattern (e.g. TH for Tante Hildegard) for all pair items regardless of name length. No length-based fallback to full names — consistency over edge-case handling. Long single-word names like "Opa" get the same pill treatment.

3. Accessibility
Each pair pill needs an aria-label with full names and document count, e.g. aria-label="Tante Hildegard und Finanzamt Hamburg, 12 Dokumente". Use "und" instead of the ↔ symbol in the label — it reads better aloud. Implementation note: the current selectPerson() only fills the sender field — pair clicks need a new code path that sets both sender and receiver simultaneously.

4. Interaction feedback on pair click
Snap transition is fine — consistent with the existing single-person flow. No animation needed. The filter bar in the results view immediately shows the pre-filled pair, which is sufficient feedback.

5. Coexistence of recent persons and top pairs
Both sections are shown simultaneously. Neither replaces the other. On first visit (empty localStorage) only the top pairs section is visible below the typeahead; once the user has history, both columns appear side by side.

6. List length — top 5 pairs
Cap at 5 pairs, matching the existing recent persons cap. Enough signal for discovery without overwhelming the entry state. The typeahead handles deeper exploration.

7. Loading state — server-side
Fetch the top pairs in +page.server.ts alongside the existing filter data. No client-side fetch, no skeleton, no spinner. The page renders complete.


Not raised / deferred

  • Date ranges per pair (e.g. "1921–1948"): not discussed — left to implementer judgement given the pill format leaves little room for secondary metadata.
  • Backend endpoint shape: out of scope for UI review, deferred to Felix.

Overall the feature fits naturally into the existing hero structure. The side-by-side layout with a widened max-width for the lower section is the main structural change — everything else is additive.

## 🎨 Leonie Voss — UI/UX Design Lead Design discussion covering layout, mobile behavior, accessibility, and interaction patterns for the top pairs entry state. --- ### Resolved **1. Empty state structure** Three tiers in `CorrespondenzHero`: typeahead (full width, `max-w-sm`) stays at the top. Below it, recent persons and top pairs sit side-by-side on desktop (`max-w-2xl` wrapper), stacked on mobile (recent persons on top, top pairs below). The current hero wrapper is solid — widen only the bottom section. **2. Mobile layout — pills for all name lengths** Use the existing abbreviation pill pattern (e.g. TH for Tante Hildegard) for all pair items regardless of name length. No length-based fallback to full names — consistency over edge-case handling. Long single-word names like "Opa" get the same pill treatment. **3. Accessibility** Each pair pill needs an `aria-label` with full names and document count, e.g. `aria-label="Tante Hildegard und Finanzamt Hamburg, 12 Dokumente"`. Use "und" instead of the ↔ symbol in the label — it reads better aloud. Implementation note: the current `selectPerson()` only fills the sender field — pair clicks need a new code path that sets both sender and receiver simultaneously. **4. Interaction feedback on pair click** Snap transition is fine — consistent with the existing single-person flow. No animation needed. The filter bar in the results view immediately shows the pre-filled pair, which is sufficient feedback. **5. Coexistence of recent persons and top pairs** Both sections are shown simultaneously. Neither replaces the other. On first visit (empty localStorage) only the top pairs section is visible below the typeahead; once the user has history, both columns appear side by side. **6. List length — top 5 pairs** Cap at 5 pairs, matching the existing recent persons cap. Enough signal for discovery without overwhelming the entry state. The typeahead handles deeper exploration. **7. Loading state — server-side** Fetch the top pairs in `+page.server.ts` alongside the existing filter data. No client-side fetch, no skeleton, no spinner. The page renders complete. --- ### Not raised / deferred - Date ranges per pair (e.g. "1921–1948"): not discussed — left to implementer judgement given the pill format leaves little room for secondary metadata. - Backend endpoint shape: out of scope for UI review, deferred to Felix. --- Overall the feature fits naturally into the existing hero structure. The side-by-side layout with a widened max-width for the lower section is the main structural change — everything else is additive.
Author
Owner

🧑‍💻 Felix Brandt — Senior Fullstack Developer

Questions & Observations

  • Top pairs query shape — The SQL needs to count bidirectional pairs without double-counting: Opa→Oma and Oma→Opa are the same pair. The existing findCorrespondents() in PersonRepository shows the per-person pattern, but a global "all pairs" query is different — it needs a LEAST/GREATEST trick or a UNION-based deduplication. This is the most complex new piece of code in this feature. What's the intended query approach?

  • Service ownership — The pair query touches documents, document_receivers, and persons. Since it's fundamentally about document volume between people, DocumentService is the honest owner. If it lands in PersonService, the service starts knowing about document table structure, which crosses domain lines.

  • New code path: selectPair()selectPerson(id) in +page.svelte only sets the sender. A pair click needs a distinct selectPair(senderId, receiverId) that sets both and calls applyFilters(). This is not a modification of the existing function — it's a new one. Worth naming clearly.

  • CorrespondenzHero.svelte component size — Currently 93 lines handling one section. Adding top pairs (different layout, different data source, different click behavior) will exceed the 60-line splitting threshold. Suggest extracting RecentPersonsSection.svelte and TopPairsSection.svelte before the hero file gets too large to name cleanly.

  • Abbreviation utility — The initials computation (TH for Tante Hildegard) is already used on the person card. Is there a shared formatInitials() utility, or is it inline? If inline in multiple places, extract it before adding a third consumer.

  • DTO shape for a pair — Suggest: { senderId, senderDisplayName, receiverId, receiverDisplayName, documentCount }. Initials derived on the frontend from displayName, not computed backend-side — keeps the DTO thin.

Suggestions

  • Unit test the pair query with: exactly 2 persons who exchanged documents, verify only 1 pair returned (not 2)
  • Component test: clicking a pair pill calls the callback with both senderId and receiverId — not just one
## 🧑‍💻 Felix Brandt — Senior Fullstack Developer ### Questions & Observations - **Top pairs query shape** — The SQL needs to count bidirectional pairs without double-counting: Opa→Oma and Oma→Opa are the same pair. The existing `findCorrespondents()` in `PersonRepository` shows the per-person pattern, but a global "all pairs" query is different — it needs a `LEAST/GREATEST` trick or a `UNION`-based deduplication. This is the most complex new piece of code in this feature. What's the intended query approach? - **Service ownership** — The pair query touches `documents`, `document_receivers`, and `persons`. Since it's fundamentally about document volume between people, `DocumentService` is the honest owner. If it lands in `PersonService`, the service starts knowing about document table structure, which crosses domain lines. - **New code path: `selectPair()`** — `selectPerson(id)` in `+page.svelte` only sets the sender. A pair click needs a distinct `selectPair(senderId, receiverId)` that sets both and calls `applyFilters()`. This is not a modification of the existing function — it's a new one. Worth naming clearly. - **`CorrespondenzHero.svelte` component size** — Currently 93 lines handling one section. Adding top pairs (different layout, different data source, different click behavior) will exceed the 60-line splitting threshold. Suggest extracting `RecentPersonsSection.svelte` and `TopPairsSection.svelte` before the hero file gets too large to name cleanly. - **Abbreviation utility** — The initials computation (TH for Tante Hildegard) is already used on the person card. Is there a shared `formatInitials()` utility, or is it inline? If inline in multiple places, extract it before adding a third consumer. - **DTO shape for a pair** — Suggest: `{ senderId, senderDisplayName, receiverId, receiverDisplayName, documentCount }`. Initials derived on the frontend from `displayName`, not computed backend-side — keeps the DTO thin. ### Suggestions - Unit test the pair query with: exactly 2 persons who exchanged documents, verify only 1 pair returned (not 2) - Component test: clicking a pair pill calls the callback with both `senderId` and `receiverId` — not just one
Author
Owner

🏛️ Markus Keller — Application Architect

Questions & Observations

  • Service ownership — The top pairs query spans documents, document_receivers, and persons. Domain rule: the service that owns the data being aggregated should own the query. Document volume is a DocumentService concern. If PersonService owns this query, it gains a dependency on document table structure, muddying the domain boundary. Recommend: new method on DocumentService, returning a ConversationPairDTO.

  • New DTO locationConversationPairDTO is an API response type. It belongs in dto/. It's produced by DocumentService and consumed by PersonController (or a new endpoint) and the frontend. This is the standard pattern — no cross-domain coupling risk.

  • Query plan concern — A global "top pairs" query joining documents + document_receivers + persons without a predicate can be expensive on a cold cache. For a family archive this is fine, but the query should be reviewed: ensure it uses the existing indexes on sender_id and person_id in document_receivers. A LIMIT 5 at the DB level (not in Java) keeps it efficient.

  • Endpoint placement — Where does the new endpoint live? Options:

    • GET /api/conversations/top-pairs (cleanest — owns its own namespace)
    • GET /api/documents/top-pairs (under DocumentService's controller)
    • GET /api/persons/top-pairs (under PersonController — awkward, it's not about persons)
      The first option is forward-compatible if conversation features expand.
  • Frontend data flow — Top pairs in +page.server.ts → prop to CorrespondenzHero → rendered. This respects the existing SvelteKit server-load pattern. Clean, no concerns.

Suggestions

  • No new architectural boundaries needed beyond the service ownership clarification above.
  • If the SQL uses a LEAST/GREATEST deduplication pattern, add a brief comment explaining the approach — that idiom is not obvious on first read.
## 🏛️ Markus Keller — Application Architect ### Questions & Observations - **Service ownership** — The top pairs query spans `documents`, `document_receivers`, and `persons`. Domain rule: the service that owns the data being aggregated should own the query. Document volume is a `DocumentService` concern. If `PersonService` owns this query, it gains a dependency on document table structure, muddying the domain boundary. Recommend: new method on `DocumentService`, returning a `ConversationPairDTO`. - **New DTO location** — `ConversationPairDTO` is an API response type. It belongs in `dto/`. It's produced by `DocumentService` and consumed by `PersonController` (or a new endpoint) and the frontend. This is the standard pattern — no cross-domain coupling risk. - **Query plan concern** — A global "top pairs" query joining documents + document_receivers + persons without a predicate can be expensive on a cold cache. For a family archive this is fine, but the query should be reviewed: ensure it uses the existing indexes on `sender_id` and `person_id` in `document_receivers`. A `LIMIT 5` at the DB level (not in Java) keeps it efficient. - **Endpoint placement** — Where does the new endpoint live? Options: - `GET /api/conversations/top-pairs` (cleanest — owns its own namespace) - `GET /api/documents/top-pairs` (under DocumentService's controller) - `GET /api/persons/top-pairs` (under PersonController — awkward, it's not about persons) The first option is forward-compatible if conversation features expand. - **Frontend data flow** — Top pairs in `+page.server.ts` → prop to `CorrespondenzHero` → rendered. This respects the existing SvelteKit server-load pattern. Clean, no concerns. ### Suggestions - No new architectural boundaries needed beyond the service ownership clarification above. - If the SQL uses a `LEAST/GREATEST` deduplication pattern, add a brief comment explaining the approach — that idiom is not obvious on first read.
Author
Owner

🧪 Sara Holt — QA Engineer

Questions & Observations

The issue has no functional acceptance criteria. Leonie's design comment covers UI behavior, but these functional ACs are still needed before implementation:

  • Given documents exist between two or more persons, the entry state shows up to 5 pairs ranked by document count
  • Given fewer than 5 pairs exist, all available pairs are shown with no placeholder for missing ones
  • Given no pairs exist (empty archive), the top pairs section is not rendered at all
  • Given a pair pill is clicked, both sender and receiver are pre-filled in the filter bar and the timeline loads
  • Given the pair query returns pairs ranked by count, the pair with the highest count appears first

Edge cases not addressed in the issue or existing comments:

  • Fewer than 5 pairs — What renders if the archive only has 2 conversation pairs? The section should show 2 pills, not 5 with 3 empty slots. Is the hero robust to a short list?
  • Zero pairs — Empty archive, no documents. The top pairs section should be absent entirely. Does CorrespondenzHero handle topPairs = [] or null gracefully?
  • Pair click → 0 results — A user clicks a pair, but those documents have since been deleted. They land on the "no results" empty state. This should be handled gracefully — the existing conv_no_results_heading state covers it, but worth verifying the behavior is intentional.
  • Pair count definition — The UI shows "87 Dokumente". Is this sent + received (bidirectional), or only sent? Should match the definition used in documentCount on PersonSummaryDTO. Needs to be explicit in the implementation.
  • Duplicate pairs — If the SQL deduplication logic has a bug, the same pair could appear twice. A test with symmetric data (A→B and B→A documents) should verify only one pair entry is returned.

Suggestions

  • Backend test: seed 3 pairs with known counts — assert endpoint returns them in descending order, capped at 5
  • Component test: CorrespondenzHero with topPairs=[] renders no pairs section; with 3 pairs renders 3 pills
  • Integration test: pair click triggers navigation with both senderId and receiverId in the URL
## 🧪 Sara Holt — QA Engineer ### Questions & Observations The issue has no functional acceptance criteria. Leonie's design comment covers UI behavior, but these functional ACs are still needed before implementation: - Given documents exist between two or more persons, the entry state shows up to 5 pairs ranked by document count - Given fewer than 5 pairs exist, all available pairs are shown with no placeholder for missing ones - Given no pairs exist (empty archive), the top pairs section is not rendered at all - Given a pair pill is clicked, both sender and receiver are pre-filled in the filter bar and the timeline loads - Given the pair query returns pairs ranked by count, the pair with the highest count appears first **Edge cases not addressed in the issue or existing comments:** - **Fewer than 5 pairs** — What renders if the archive only has 2 conversation pairs? The section should show 2 pills, not 5 with 3 empty slots. Is the hero robust to a short list? - **Zero pairs** — Empty archive, no documents. The top pairs section should be absent entirely. Does `CorrespondenzHero` handle `topPairs = []` or `null` gracefully? - **Pair click → 0 results** — A user clicks a pair, but those documents have since been deleted. They land on the "no results" empty state. This should be handled gracefully — the existing `conv_no_results_heading` state covers it, but worth verifying the behavior is intentional. - **Pair count definition** — The UI shows "87 Dokumente". Is this sent + received (bidirectional), or only sent? Should match the definition used in `documentCount` on `PersonSummaryDTO`. Needs to be explicit in the implementation. - **Duplicate pairs** — If the SQL deduplication logic has a bug, the same pair could appear twice. A test with symmetric data (A→B and B→A documents) should verify only one pair entry is returned. ### Suggestions - Backend test: seed 3 pairs with known counts — assert endpoint returns them in descending order, capped at 5 - Component test: `CorrespondenzHero` with `topPairs=[]` renders no pairs section; with 3 pairs renders 3 pills - Integration test: pair click triggers navigation with both `senderId` and `receiverId` in the URL
Author
Owner

🔐 Nora Steiner — Application Security

This feature is low-risk. Notes on what I checked:

  • Read-only, authenticated — The top pairs endpoint reads existing document relationships for authenticated users. No new permissions required. No write surface introduced.

  • No new data exposure — Pair counts are computed from documents and document_receivers — tables the authenticated user can already access and browse. Returning aggregated counts doesn't reveal anything beyond what's already visible through normal document search.

  • localStorage scope — Recent persons are stored in localStorage as {id, name}[]. No session tokens, credentials, or sensitive metadata. Top pairs come from the server, not localStorage — no client-side integrity concern. The two data sources don't mix in a way that creates a trust issue.

  • No new input vectors — Clicking a pair sets two UUIDs in the URL params. These are passed to the existing GET /api/conversations endpoint which already handles senderId and receiverId as query params. No new input validation surface.

  • Endpoint access control — Confirm the new top pairs endpoint is covered by the global authentication filter in SecurityConfig (not permit-all). Given that all other /api/** endpoints are authenticated by default, this should be automatic — but worth a deliberate check when wiring up the new route.

Suggestions

  • No security-blocking concerns. One checkpoint: verify the new endpoint appears in the anyRequest().authenticated() chain, not accidentally in a permit-all block.
## 🔐 Nora Steiner — Application Security This feature is low-risk. Notes on what I checked: - **Read-only, authenticated** — The top pairs endpoint reads existing document relationships for authenticated users. No new permissions required. No write surface introduced. - **No new data exposure** — Pair counts are computed from `documents` and `document_receivers` — tables the authenticated user can already access and browse. Returning aggregated counts doesn't reveal anything beyond what's already visible through normal document search. - **localStorage scope** — Recent persons are stored in localStorage as `{id, name}[]`. No session tokens, credentials, or sensitive metadata. Top pairs come from the server, not localStorage — no client-side integrity concern. The two data sources don't mix in a way that creates a trust issue. - **No new input vectors** — Clicking a pair sets two UUIDs in the URL params. These are passed to the existing `GET /api/conversations` endpoint which already handles `senderId` and `receiverId` as query params. No new input validation surface. - **Endpoint access control** — Confirm the new top pairs endpoint is covered by the global authentication filter in `SecurityConfig` (not permit-all). Given that all other `/api/**` endpoints are authenticated by default, this should be automatic — but worth a deliberate check when wiring up the new route. ### Suggestions - No security-blocking concerns. One checkpoint: verify the new endpoint appears in the `anyRequest().authenticated()` chain, not accidentally in a permit-all block.
Author
Owner

🎨 Leonie Voss — UI/UX Design Lead

The layout, pill pattern, mobile stacking, interaction feedback, list cap, and loading approach are all settled in my earlier design comment. A few remaining points not covered there:

  • Section headings — The two side-by-side columns need distinct labels. Suggest:

    • Recent persons column: reuse the existing conv_empty_recent_label key
    • Top pairs column: new key, e.g. conv_hero_top_pairs_label → "Häufige Briefwechsel" (de) / "Frequent correspondences" (en) / "Correspondencias frecuentes" (es)
      These are needed in all three languages before the component ships.
  • Pill separator for pairs — The pair pill shows two persons. How is the relationship visually indicated between the two abbreviations inside one pill? Options: TH · FH, TH — FH, or two adjacent mini-avatars with no text separator. The should not appear in the visual pill (only in the aria-label) — it's a unicode character that renders inconsistently across fonts.

  • Degraded state — If the server-side top pairs call returns an error (network issue, backend down), the hero should render without the top pairs section rather than crashing. The typeahead and recent persons should still be functional. A graceful topPairs = [] fallback in +page.server.ts handles this.

  • i18n keys summary — New keys needed:

    • conv_hero_top_pairs_label (column heading)
    • Document count label within a pair pill (e.g. "12 Dok." for compact display, or reuse existing person_card_doc_count_many)
  • "Or" divider — The current hero has a horizontal rule with "oder" between the typeahead and recent persons. In the new layout, the two-column section sits below the typeahead with no divider between the columns themselves. Confirm: the existing "oder" divider is removed or repositioned, since it no longer separates two distinct options.

## 🎨 Leonie Voss — UI/UX Design Lead The layout, pill pattern, mobile stacking, interaction feedback, list cap, and loading approach are all settled in my earlier design comment. A few remaining points not covered there: - **Section headings** — The two side-by-side columns need distinct labels. Suggest: - Recent persons column: reuse the existing `conv_empty_recent_label` key - Top pairs column: new key, e.g. `conv_hero_top_pairs_label` → "Häufige Briefwechsel" (de) / "Frequent correspondences" (en) / "Correspondencias frecuentes" (es) These are needed in all three languages before the component ships. - **Pill separator for pairs** — The pair pill shows two persons. How is the relationship visually indicated between the two abbreviations inside one pill? Options: `TH · FH`, `TH — FH`, or two adjacent mini-avatars with no text separator. The `↔` should not appear in the visual pill (only in the `aria-label`) — it's a unicode character that renders inconsistently across fonts. - **Degraded state** — If the server-side top pairs call returns an error (network issue, backend down), the hero should render without the top pairs section rather than crashing. The typeahead and recent persons should still be functional. A graceful `topPairs = []` fallback in `+page.server.ts` handles this. - **i18n keys summary** — New keys needed: - `conv_hero_top_pairs_label` (column heading) - Document count label within a pair pill (e.g. "12 Dok." for compact display, or reuse existing `person_card_doc_count_many`) - **"Or" divider** — The current hero has a horizontal rule with "oder" between the typeahead and recent persons. In the new layout, the two-column section sits below the typeahead with no divider between the columns themselves. Confirm: the existing "oder" divider is removed or repositioned, since it no longer separates two distinct options.
Author
Owner

⚙️ Tobias Wendt — DevOps & Platform

No infrastructure changes required. Confirming what I checked:

  • No new services or containers — This is a new query + endpoint + frontend component. Docker Compose is untouched.
  • No migration — The top pairs query reads existing tables (documents, document_receivers, persons). No schema change, no Flyway migration.
  • No new env vars — The endpoint cap (top 5) is a hardcoded constant in application code, not a config value. Fine for this scale.
  • CI pipeline — Existing test jobs cover the new code. No new workflow steps needed.

One performance note worth flagging:

The top pairs query runs on every page load of the briefwechsel entry state (server-side load). If the query is a full-table join without a predicate, it runs against all documents on every visit. For a family archive this is negligible, but it's a different cost profile from the existing person/document list queries which are filter-driven.

If response time becomes noticeable: a 5-minute materialized view or a simple application-level cache (@Cacheable on the service method) would eliminate the query cost entirely. Not needed now — just good to know it's the straightforward fix if it ever matters.

## ⚙️ Tobias Wendt — DevOps & Platform No infrastructure changes required. Confirming what I checked: - **No new services or containers** — This is a new query + endpoint + frontend component. Docker Compose is untouched. - **No migration** — The top pairs query reads existing tables (`documents`, `document_receivers`, `persons`). No schema change, no Flyway migration. - **No new env vars** — The endpoint cap (top 5) is a hardcoded constant in application code, not a config value. Fine for this scale. - **CI pipeline** — Existing test jobs cover the new code. No new workflow steps needed. **One performance note worth flagging:** The top pairs query runs on every page load of the briefwechsel entry state (server-side load). If the query is a full-table join without a predicate, it runs against all documents on every visit. For a family archive this is negligible, but it's a different cost profile from the existing person/document list queries which are filter-driven. If response time becomes noticeable: a 5-minute materialized view or a simple application-level cache (`@Cacheable` on the service method) would eliminate the query cost entirely. Not needed now — just good to know it's the straightforward fix if it ever matters.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#224