feat: show top conversation pairs on briefwechsel entry state #224
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
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.:
Clicking a pair pre-fills both sender and receiver and loads the conversation timeline.
Open questions
🎨 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-2xlwrapper), 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-labelwith 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 currentselectPerson()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.tsalongside the existing filter data. No client-side fetch, no skeleton, no spinner. The page renders complete.Not raised / deferred
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.
🧑💻 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()inPersonRepositoryshows the per-person pattern, but a global "all pairs" query is different — it needs aLEAST/GREATESTtrick or aUNION-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, andpersons. Since it's fundamentally about document volume between people,DocumentServiceis the honest owner. If it lands inPersonService, the service starts knowing about document table structure, which crosses domain lines.New code path:
selectPair()—selectPerson(id)in+page.svelteonly sets the sender. A pair click needs a distinctselectPair(senderId, receiverId)that sets both and callsapplyFilters(). This is not a modification of the existing function — it's a new one. Worth naming clearly.CorrespondenzHero.sveltecomponent 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 extractingRecentPersonsSection.svelteandTopPairsSection.sveltebefore 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 fromdisplayName, not computed backend-side — keeps the DTO thin.Suggestions
senderIdandreceiverId— not just one🏛️ Markus Keller — Application Architect
Questions & Observations
Service ownership — The top pairs query spans
documents,document_receivers, andpersons. Domain rule: the service that owns the data being aggregated should own the query. Document volume is aDocumentServiceconcern. IfPersonServiceowns this query, it gains a dependency on document table structure, muddying the domain boundary. Recommend: new method onDocumentService, returning aConversationPairDTO.New DTO location —
ConversationPairDTOis an API response type. It belongs indto/. It's produced byDocumentServiceand consumed byPersonController(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_idandperson_idindocument_receivers. ALIMIT 5at 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 toCorrespondenzHero→ rendered. This respects the existing SvelteKit server-load pattern. Clean, no concerns.Suggestions
LEAST/GREATESTdeduplication pattern, add a brief comment explaining the approach — that idiom is not obvious on first read.🧪 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:
Edge cases not addressed in the issue or existing comments:
CorrespondenzHerohandletopPairs = []ornullgracefully?conv_no_results_headingstate covers it, but worth verifying the behavior is intentional.documentCountonPersonSummaryDTO. Needs to be explicit in the implementation.Suggestions
CorrespondenzHerowithtopPairs=[]renders no pairs section; with 3 pairs renders 3 pillssenderIdandreceiverIdin the URL🔐 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
documentsanddocument_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/conversationsendpoint which already handlessenderIdandreceiverIdas 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
anyRequest().authenticated()chain, not accidentally in a permit-all block.🎨 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:
conv_empty_recent_labelkeyconv_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 thearia-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.tshandles this.i18n keys summary — New keys needed:
conv_hero_top_pairs_label(column heading)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.
⚙️ Tobias Wendt — DevOps & Platform
No infrastructure changes required. Confirming what I checked:
documents,document_receivers,persons). No schema change, no Flyway migration.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 (
@Cacheableon 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.