feat(ui): Korrespondenz redesign — compact strip, log cards, single-person mode #162
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?
Context
The current "Gespräche" page (
/conversations) uses a large filter card and a chat-bubble timeline. The final design spec (docs/specs/korrespondenz-redesign-spec.html— open in a browser) replaces both with a compact 2-row strip and a chronological correspondence log. The page is also renamed to "Korrespondenz" and the route moves to/korrespondenz.This issue is a complete, self-contained handoff. Every behavioural rule is derived directly from the spec.
Summary of changes vs. current code
/conversations/korrespondenzconv_heading→ "Gespräche"p-8card, 2-column gridKept unchanged:
PersonTypeahead,restrictToCorrespondentsOf, swap button behaviour,goto()-based navigation, URL param shape (senderId,receiverId,from,to,dir),canWritenew-document link, year-divider logic.1 · Route rename
Move the SvelteKit route directory:
Add a redirect in
+layout.server.tsor a+page.server.tsat the old path so that any bookmarked/conversationsURLs redirect to/korrespondenzwith a 301. All internal links (+layout.sveltenav,ConversationTimelinenew-doc link, anyhref="/conversations"references) must be updated.2 · i18n changes
Edit
frontend/messages/de.json,en.json,es.json.Update existing keys
conv_heading"Gespräche""Korrespondenz"conv_subtitle"Briefwechsel einer Person durchsuchen — mit oder ohne Korrespondent."conv_label_person_b"Person B""Korrespondent"conv_empty_heading"Korrespondenz durchsuchen"conv_empty_text"Wähle eine Person aus dem Archiv um deren Briefe zu sehen — mit oder ohne Korrespondent."Add new keys
Add equivalent translations in
en.jsonandes.json.3 ·
+page.server.tsSingle-person mode API call
Currently the load function only calls
/api/documents/conversationwhen bothsenderIdandreceiverIdare set. Add a single-person branch:Check whether
GET /api/documentssupports asenderIdfilter. If not, add the query param to the backendDocumentController/DocumentServicebefore implementing this. A placeholder that returns[]in single-person mode is acceptable as a first step — mark it with a// TODOcomment and open a follow-up issue.canWritederivationKeep existing logic — no change needed.
Return shape
Add
canWriteto the returned object if it is not already there (check —+page.sveltereadsdata.canWrite).4 ·
+page.svelteRemove the "both persons required" gate
Delete the entire
{#if !senderId || !receiverId}block (lines 72–88 of the current file).Replace the conditional rendering with:
Remove the page header block
Delete the
<div class="mb-8 border-b ...">heading section (lines 51–56). The strip is the page identity now — no separate title heading needed.Remove the
mx-auto max-w-5xl px-4 py-10wrapper paddingThe strip must be full-width (flush to the viewport edges), then the log content below can be
max-w-5xl mx-auto px-4. Adjust the outer layout accordingly.New props to pass down
Pass
documents={data.documents}and the count toConversationFilterBarso Row 2 can show the live count.5 ·
ConversationFilterBar.svelte— full redesignReplace the current
p-8card with a two-row sticky strip. The strip is always rendered; Row 2 is dimmed when no person is selected.Props (add to existing)
Row 1 — person inputs
bg-white border-b border-[#EAE7E0] px-4 sm:px-[18px] py-[9px] flex items-end gap-[9px]"Person", requiredsenderIdis empty:"Korrespondent"+ italic suffix"— optional"senderIdis set:"Korrespondent"(no suffix — it's now meaningful)<input>/PersonTypeahead:"Alle Korrespondenten"(not "Name eingeben…")border-dashedwhen empty,border-solidwhen a value is setbackground: #F9F8F6when empty (visually different from the primary field)restrictToCorrespondentsOf={senderId || undefined}— keep existing propopacity-0 pointer-events-nonewhen only one person is set; visible when both are setSuggestions dropdown (when person B input is focused and
senderIdis set)Show a dropdown below the person B field using the results already available from
restrictToCorrespondentsOf. ThePersonTypeaheadcomponent may already render a dropdown — check if it can be extended to show a pre-populated list before the user types.If
PersonTypeaheaddoes not support pre-populated suggestions, add a separate<div>positionedabsolute top-full left-0 right-0 z-30that appears when the input is focused andsenderIdis set:PersonTypeaheadAPI call or callGET /api/personswithrestrictToCorrespondentsOf— whichever is simpler16×16, navy bg) + name + mini bar (proportional to letter count) + count labelreceiverId, closes dropdown, callsonapplyFilters()receiverId(sets to''), callsonapplyFilters()Row 2 — date range + count + sort
bg-[#F7F5F2] border-b border-[#E0DDD6] px-4 sm:px-[18px] py-[5px] flex items-center gap-[10px]opacity-40 pointer-events-noneon the whole Row 222px, width80px, border1.5px solid #D1D5DB,border-radius: 3px"Von…"/"Bis…"(font-style italic, color#AAA)border-color: #002850, text#333, not italicdateor text with German formatting (consistent with the rest of the app — use whatever the edit form uses)bind:value={fromDate}/bind:value={toDate},onchange={() => onapplyFilters()margin-left: auto,font-size: 8px,font-weight: 700, color#888normally,#002850when date filter is active (i.e.fromDate || toDate); value is{documentCount} BriefesortDir === 'DESC' ? 'Neueste ↓' : 'Älteste ↑'height: 22px, border, rounded3px,font-size: 8px font-weight 700border-color: #002850 color: #002850onclick={ontoggleSort}6 ·
ConversationTimeline.svelte— full redesignRemove
<div class="relative overflow-hidden ... bg-surface shadow-sm">chat container<div class="absolute ... w-px ... bg-muted">.justify-end / .justify-start,.rounded-br-none,.rounded-bl-none)New prop
Asymmetry bar — only when both persons set
Render above the log when
senderId && receiverId:Add
senderNameandreceiverNameas props (passed from+page.svelteviadata.initialValues).Log structure
Replace the
{#each enrichedDocuments}chat block with:isOutderivationyearDocCounthelperotherPartyNamehelper (single-person mode)Note:
receiversis not currently in theConversationTimelinedocument type. Add it to the type definition; theDocumententity already hasreceivers.Status dot colours
canWritenew-document linkMove from the summary bar to the bottom of the log list, after the last row, right-aligned:
7 ·
CorrespondenzEmptyState.svelte— new componentCreate
frontend/src/routes/korrespondenz/CorrespondenzEmptyState.svelte.Layout:
Recent persons (localStorage):
korrespondenz_recent_persons— array of{ id: string; firstName: string; lastName: string }, max 3 entriessenderId(in+page.svelte'sapplyFilters)onSelectPerson(id)which setssenderIdand callsapplyFilters()"Person suchen" input behaviour:
Clicking it should
focus()the Person A typeahead in the strip above. Use a forwarded ref or a custom event — whichever is simpler.8 · Single-person hint bar
Shown in
+page.sveltewhensenderId && !receiverIdanddata.documents.length > 0:Position this between the filter strip and the log area (inside the layout flow, not overlaid).
9 · Mobile layout
Strip Row 1
At
< 768px, the two person fields stack vertically (full width), or sit side-by-side with the swap button between them (as shown in the spec for bilateral mobile). Use the bilateral layout at mobile too:Both fields are
height: 36pxon mobile. Touch target for swap: minimum 44×44px (add padding).Strip Row 2
Compress on mobile: remove "Zeitraum" label, reduce date widths to
55px, abbreviate count to"N Br.", sort label to"Neu ↓".Log rows
Each log row must be
min-height: 44pxfor touch. The full card tap navigates to the document.10 · Accessibility
pointer-events-noneon focusable inputs without also settingaria-disabled="true"andtabindex="-1"on each control.aria-label="Sortierung umkehren",aria-pressedbased on direction.role="listbox"on the container,role="option"on each row,aria-selectedon the active one, keyboard navigation (↑/↓/Enter/Escape).<a>tags — they already have implicit link semantics. Addaria-label="{title}, {date}"to each for screen readers.role="img"witharia-label="Briefverteilung: {outCount} von {senderName}, {inCount} von {receiverName}".aria-label="Person suchen".11 · Tests
The spec file at
frontend/src/routes/conversations/page.svelte.spec.ts(will move tokorrespondenz/) tests bilateral mode. Update:data-testid="year-divider"— already used, keep itdata-testid="conv-summary"— remove (count moves to strip Row 2, no longer a separate element with this testid)data-testid="conv-swap-btn"— keepdata-testid="conv-new-doc-link"— keep (moved to bottom of log)Add new tests:
senderIdsenderIdset,receiverIdemptysenderIdset andreceiverIdemptyopacity-40class whensenderIdis emptysenderIdis setOut of scope for this issue
/api/documents/conversation(single-person query) — only a// TODOplaceholder needed if the endpoint does not support it yetdark:Tailwind classes — defer to a follow-up)PersonTypeaheaddoes not expose letter counts, show the names without the bar for now and add a// TODOAcceptance criteria
/korrespondenzworks;/conversationsredirects to it with 301opacity-40) with no person set and fully interactive once any person is setDocumentStatus(5 colours)svelte-checkpasses with no new type errors/proofshotat 375px and ≥768px in empty, single-person, and bilateral statesReference
Full annotated spec:
docs/specs/korrespondenz-redesign-spec.html— open in a browser.Key existing files:
src/routes/conversations/+page.svelte(→ will move tokorrespondenz/)src/routes/conversations/ConversationFilterBar.sveltesrc/routes/conversations/ConversationTimeline.sveltesrc/routes/conversations/+page.server.tssrc/routes/conversations/page.svelte.spec.tssrc/lib/components/PersonTypeahead.svelte— reuse as-is👨💻 Felix Brandt — Senior Fullstack Developer
Component decomposition
The issue describes
ConversationFilterBar.svelteas a single component, but I count at least three visually distinct, nameable regions inside it. Bundling them violates the visual boundary rule:FilterStripRow1.svelte— person inputs + swap buttonFilterStripRow2.svelte— date range, count, sort toggleCorrespondentSuggestionsDropdown.svelte— the pre-populated dropdownSimilarly, the hint bar in §8 (
{#if senderId && !receiverId}) lives inline in+page.svelte. That's a distinct visual region — make itSinglePersonHintBar.svelte. The page should be an orchestrator, not a mix of layout and conditional markup.O(n²) in
yearDocCountThe
yearDocCount(year)helper is called inside{#each enrichedDocuments}— once per row, not once per year band. It iterates the fulldocumentsarray each time. On a log with 200 entries across 10 years this runs 200 × n iterations. Precompute it as a$derivedMap:statusDotClasstype safetyThe function signature is
(status: string)butDocumentStatusis a typed enum on the generated API types. Use it:This catches invalid status values at compile time instead of falling through to
'bg-gray-300'silently.otherPartyNamereturns a hardcoded'—'That em-dash should come from an i18n key (
conv_no_party), not be hardcoded in the function. Consistent with how the rest of the project handles missing-value display.localStorageread inonMountis a side effect patternThe issue says to read
korrespondenz_recent_personsinCorrespondenzEmptyStateon mount. In Svelte 5,onMountis fine for this specific case (SSR doesn't havelocalStorage), but the result should be$state, not derived insideonMountwith a manual assignment. Also: guard againstJSON.parsethrowing on corrupt data.Questions
senderName/receiverNameinConversationTimeline? The issue says pass them viadata.initialValues— butinitialValuesis only populated when the person fetch succeeds. What renders if the person fetch fails (e.g. person was deleted)?receiverIdprop onConversationTimelineis described asstring(may be'') — but the existing type saysstringrequired. Should this bestring | undefinedto be explicit?🏗️ Markus Keller — Application Architect
The single-person mode API gap is the most important open question
The issue defers the backend work with "Option A / Option B / or return
[]" — but this is the feature's load-bearing piece. Without it, single-person mode shows an empty log, making the whole UX change feel broken on first use. I'd recommend resolving this before the frontend work begins, not in parallel or after.My recommendation: extend
/api/documents/conversationto accept an optionalreceiverId. When omitted, the query should return all documents where the given person is sender or receiver. This is the semantically correct query — not justsenderId =which misses documents where the person received a letter. The service layer change is small; the endpoint contract change is additive (backward-compatible sincereceiverIdwas previously required but adding optional support doesn't break existing callers).The
senderId-only query misses incoming lettersThe issue's
otherPartyNamehelper handles the "incoming" case, implying the API will return documents wheresender.id != senderId. ButGET /api/documents?senderId=...returns only documents by sender. The single-person query needs to be "all documents involving person X" — which issender.id = X OR receivers contains X. Flag this explicitly so the backend implementation doesn't silently return half the data.localStoragefor recent persons causes an SSR hydration flashSvelteKit renders
CorrespondenzEmptyStateon the server withoutlocalStorage. The server renders no chips; the client hydrates and injects chips. This produces a visible layout shift on every page load when history exists. Options:{#if mounted}— simplest but noticeableA cookie is the right call here. Max-age of 30 days,
SameSite=Strict, no sensitive data.The 301 redirect belongs in a SvelteKit hook, not a page
A
+page.server.tsat/conversationsthat throwsredirect(301, '/korrespondenz')works, but the redirect fires after SvelteKit has parsed the route, loaded the layout, and run hooks. A cleaner approach is ahandlehook insrc/hooks.server.ts:This preserves query params (
?senderId=…) in the redirect, which a page-level redirect may not.Asymmetry bar semantics
The bar calculates from the currently loaded (filtered) document set. A date filter of 1940–1943 shows a different asymmetry than the full correspondence. This is correct per the spec — but the
aria-labeland visual label should make the scope clear: "Briefverteilung in diesem Zeitraum" rather than implying it covers all time.No data model concern
No schema migration needed — all data is already there. This is a pure frontend + API surface change. ✓
🧪 Sara Holt — QA Engineer & Test Strategist
The load function has no tests listed — that's the gap I care most about
Section 11 lists component rendering tests but
+page.server.tschanges are the highest-value test target here. The load function is plain TypeScript — import and call it directly with a mockedfetch:Missing test cases for the load function:
should return empty documents and senderName when only senderId is set(once API supports it)should return empty documents array when single-person API returns 404(deleted person edge case)should preserve all filter params in the returned filters object(regression fordir,from,to)should return canWrite true when user has WRITE_ALL permission(ifcanWriteis being added to return shape)Missing test cases in section 11
The listed tests cover happy-path state, but these edge cases aren't mentioned:
outCount === documents.length, the teal segment haswidth: 0%. Doesoverflow-hidden+flexbreak the border-radius? Needs a visual test.receiverIdempty string vs.undefined:{#if !receiverId}evaluates true for both, but the URL param handling in+page.server.tstreats them differently. Test that the component behaves identically for both.localStoragewith corrupt data:JSON.parse('not json')throws. Thetry/catchproposed by Felix should be a tested behaviour — renderCorrespondenzEmptyStatewith a corruptkorrespondenz_recent_personskey and assert no chips render (not an error).applyFilters()— doessortDirsurvive? Add a test.data-testid="conv-strip-count"is missingThe issue removes
data-testid="conv-summary"but doesn't assign a testid to the new count in Row 2. Without it, the "date filter: count updates live" acceptance criterion is untestable in a component test. Please adddata-testid="conv-strip-count"to the count element.E2E coverage is not mentioned
The acceptance criteria include
/proofshotbut no Playwright journey. The minimum E2E path I'd want:/korrespondenz→ empty state visible/documents/{id}This covers the happy path end-to-end and should be added to the existing
page.svelte.spec.tsas a Playwright test alongside the Vitest component tests, or in a dedicatedkorrespondenz.spec.ts.Load test note
The single-person mode will introduce a new backend query pattern (sender OR receiver). If this runs against a full-text index the existing endpoint doesn't use, the p95 latency for the document search could increase. Worth a smoke test after implementation.
Small question
Does the redirect test live in the component spec or does it need a separate Playwright test to verify the actual HTTP 301 is issued? Redirect behaviour isn't testable with
@testing-library/svelte— needs Playwright or a load function test that mocks the hook.🔒 Nora "NullX" Steiner — Application Security Engineer
Authorization gap on
senderId/receiverIdparams — Medium riskThe URL params
senderIdandreceiverIdare user-controlled UUIDs. The current bilateral endpoint presumably enforces that the current user has access to view correspondence (via@RequirePermission). The issue doesn't explicitly say that the new single-person mode API call inherits the same permission check.Risk: If the single-person endpoint (
/api/documents/conversation?senderId=Xwith no receiverId) doesn't check whether the current user is allowed to view person X's correspondence, any authenticated user can enumerate all letters involving any person in the archive by guessing UUIDs.Recommendation: Explicitly state in the backend implementation note that
senderId(andreceiverId) must be validated against the caller's permissions before the query runs. IfREAD_ALLis the gate, document that. Don't leave this to the implementer to infer.localStoragechip data is XSS-safe but deserves a noteThe
korrespondenz_recent_personsstore holds{ id, firstName, lastName }— resolved from the backend. Svelte auto-escapes{name}in templates, so a crafted name like<script>alert(1)</script>won't execute. ✓However: the data written to localStorage comes from
data.initialValues.senderName(a concatenated string from the server). If this ever changes to include unvalidated user input, the auto-escape guarantee is the only protection. Worth a comment in the code pointing this out, so a future refactor doesn't accidentally introduce{@html name}.URL construction in new-doc link — low risk, easy fix
senderIdandreceiverIdare backend-resolved UUIDs so injection is unlikely, but this pattern is unsafe by construction — it bypassesURLSearchParamsencoding. A crafted UUIDabc&role=adminwould inject an extra param. Use:Suggestions dropdown — data enumeration risk (informational)
The dropdown pre-populates with all of person A's correspondents via
restrictToCorrespondentsOf. This means that any logged-in user who knows person A's UUID can discover every person in the archive who has a correspondence with them — without ever seeing the actual letters.This is probably acceptable for a family archive (all users are trusted family members), but if the permission model ever becomes granular (some users can only see certain persons), this endpoint needs access control at the correspondent-list level too. Worth a comment in the code.
CSRF note — no concern here
The page uses
goto()-based navigation with URL params — no form submissions, no state mutations. No CSRF surface introduced. ✓One question
Does the backend's
/api/documents/conversationendpoint currently log thesenderIdandreceiverIdvalues? If so, ensure those are logged as opaque UUIDs, not resolved to person names, to avoid leaking PII into application logs.🎨 Leonie Voss — UX Lead & Accessibility Strategist
Critical: font sizes are below the 12px minimum floor
Several sizes specified in this issue will be illegal in production:
text-[9px]text-[7.5px]text-[8px]font-size: 8pxfont-size: 8pxfont-size: 8pxtext-[7.5px]The spec HTML uses these micro sizes because it's rendering scaled-down wireframe mockups at ~60% actual size. The implemented component must use real accessible sizes. My recommendation:
text-sm(14px)text-xs(12px)text-xs(12px)text-lg(18px), counttext-xs(12px)text-xs(12px)High: Row 2 date inputs at 22px height fail touch target requirements
height: 22pxon a date input is unusable on mobile and significantly below the 44×44px touch target minimum. On desktop this is acceptable as a compact secondary control (like a toolbar), but the height must scale up at mobile breakpoints:Or: keep
22pxvisual height but wrap in a<label>with44pxinvisible tap area using padding.High:
opacity-40on Row 2 makes already-small text invisibleopacity-40ontext-xsgray text (#AAA) on a#F7F5F2background: effective contrast ≈ 1.3:1. That's not just WCAG-failing — it's genuinely invisible to anyone with reduced vision. For a disabled/inactive state I'd useopacity-50at minimum, or better: swap to a slightly muted palette rather than dropping opacity, so the structure remains visible while signalling non-interactivity.Medium: Color-only direction cue in the log
The left border color (navy = sent, teal = received) paired with the
→/←symbol works when both are visible. But on a screen printed in grayscale, or for a deuteranopia user, navy and teal collapse to near-identical grays. The arrow symbol is the accessible differentiator here — make sure it has sufficient size and contrast.text-[9px]arrows are too small to read. Increase totext-xsminimum, or use the full word "Von" / "An" as a visually hidden but screen-reader-accessible label alongside the arrow.Medium: Focus management for the empty state search input
The issue says clicking the "Person suchen" input should
focus()the Person A typeahead above. This is a keyboard focus teleport — the user's focus position jumps from the center of the page to the top. For sighted users this is visible; for screen reader users, announce the transition with anaria-liveregion:Low: Recent chips are fine, but need a visible "remove" affordance eventually
Today chips are read-only (tap to select). If users accumulate stale names from deleted persons, there's no way to clear them. Not a blocker for this issue — but add a
// TODO: allow clearing recent historycomment so it's on the radar.Questions
text-[9px]log titles — are those wireframe sizes or production sizes? I need confirmation before implementation starts that these will be scaled up."Neueste ↓") uses a Unicode arrow as the direction indicator. Is this an icon glyph or a<svg>? Unicode arrows have inconsistent rendering across operating systems and may not be legible at small sizes. A proper<svg>chevron witharia-hidden="true"and a screen-reader text alternative is the right approach.⚙️ Tobias Wendt — DevOps & Platform Engineer
Route rename has no infrastructure impact — but the redirect does
The SvelteKit route move from
conversations/tokorrespondenz/is a pure source change. No Docker, no Compose, no Caddy rewrite needed — the SvelteKit build will pick it up. ✓The redirect is a different story. Markus's suggestion to put it in
hooks.server.tsis the right call. However: if we ever put Caddy in front (production), the same redirect should also live in the Caddy config as a fallback, so it works even if SvelteKit is temporarily down:This is belt-and-suspenders — not required for this issue, but worth noting for the production deployment runbook.
localStorageis SSR-safe as-is, but watch foradapter-staticlocalStorageis client-only. In the currentadapter-nodesetup, SSR runs on the server and won't calllocalStorage—onMountguards this correctly. No change needed.However: if anyone ever runs
vite buildwithadapter-static(for a preview or static export), theonMountguard still protects against crashes but the page will have no recent chips on first paint. Fine for now, just document the assumption.Single-person API query — index check before shipping
When the backend team adds the "sender OR receiver" query for single-person mode, verify that PostgreSQL can satisfy it with an index. The current bilateral query likely uses
sender_id = ? AND receiver_id = ?which is indexed. The newsender_id = ? OR receivers contains ?query may trigger a sequential scan on large archives.Check with
EXPLAIN ANALYZEbefore merging the backend change. If needed, a partial index or a separatedocument_personsjoin table solves this cleanly.No new environment variables, no secrets, no config changes
This is frontend-only for now (with a deferred backend addition). No
.envchanges, no Docker Compose changes, no Caddy changes. Gitea Actions CI should just need the spec test file rename — check that the CI workflow doesn't hardcodeconversations/paths anywhere.Observability — nothing to add
No new endpoints today. When single-person mode lands: the new backend query path will show up in Spring Boot's
http.server.requestsPrometheus metrics automatically (if actuator/prometheus is configured). No instrumentation needed on our end.Resolution — Implementation decisions
Phase 0 — Backend prerequisites (must merge before frontend work begins)
The frontend depends on two backend changes that do not exist yet:
0a. Extend
/api/documents/conversationto support optionalreceiverIdreceiverIdbecomes an optional query paramsender_id = :personId OR :personId ∈ receivers— not just documents by the person as sender; incoming letters must be included@RequirePermission(READ_ALL)must cover both code paths explicitly — do not leave this to inferenceEXPLAIN ANALYZEon the new OR-query before merging; add an index if a sequential scan is detectedreceiverIdare unaffected0b. Fix
PersonTypeaheadempty/whitespace query — security bugThe current behaviour where a space character triggers a match-all response on the persons search endpoint is unintended. The backend must trim and reject queries with fewer than 1 non-whitespace character. This prevents person enumeration via empty searches and unblocks the safe suggestions-on-focus implementation in Phase 5.
Route rename (§1)
Move
src/routes/conversations/→src/routes/korrespondenz/. No redirect is needed — all internal links will be updated in the same commit and we have no external bookmarks to preserve at this stage.Update all internal
href="/conversations"references: nav in+layout.svelte, the new-doc link inConversationTimeline, anygoto('/conversations')calls.Component decomposition (§5 + §8)
ConversationFilterBar.svelteis replaced by three focused components, each owning one visual region:CorrespondenzPersonBar.svelteCorrespondenzFilterControls.svelteCorrespondentSuggestionsDropdown.svelteThe hint bar (§8) becomes
SinglePersonHintBar.svelte.+page.svelteis an orchestrator only — no conditional display markup inline.PersonTypeahead suggestions on focus (§5)
The existing space-to-show-all behaviour is a bug (see Phase 0b) and must not be used as the trigger. The safe replacement:
When the Korrespondent input receives focus and
senderIdis set,CorrespondentSuggestionsDropdownmakes an explicit API call toGET /api/persons?restrictToCorrespondentsOf={senderId}. This is intentional, scoped, and auditable — not a side effect of an empty search.PersonTypeaheadgets a new propsuggestionsFor?: string; when set, it fires this fetch on focus instead of waiting for user input.Recent persons (§7)
Using
onMount+$statewith atry/catchguard aroundJSON.parse:The SSR hydration flash is accepted for now. Cookie-based approach deferred.
Font sizes (§5, §6)
Spec pixel values are wireframe sizes rendered at reduced scale — not production targets. Implementation must use:
text-sm(14px)text-xs(12px)text-base(16px)Addressing reviewer points
Felix Brandt:
yearDocCountO(n²): fix to$derivedMap — adoptedstatusDotClass: type againstcomponents['schemas']['DocumentStatus']— adoptedotherPartyNameem-dash: move to i18n keyconv_no_party— adoptedlocalStoragepattern: adopted with$state+try/catchas shown abovereceiverIdprop type: change tostring | undefined(not empty string) for explicit semantics — adoptedMarkus Keller:
aria-label: scope to current filter period —"Briefverteilung in diesem Zeitraum: {outCount} von {senderName}, {inCount} von {receiverName}"— adoptedSara Holt:
+page.server.tsis plain TypeScript, test directly with mockedfetchdata-testid="conv-strip-count": add toCorrespondenzFilterControlscount element — adoptedkorrespondenz.spec.tsNora Steiner:
localStorageXSS: Svelte auto-escapes; add a code comment warning against future{@html}use — adoptedURLSearchParams— adoptedLeonie Voss:
height: 22px: addmin-h-[44px] sm:min-h-0for mobile touch targets — adoptedopacity-40on Row 2: accepted for now on desktop; review contrast on mobile — follow-up issue if neededtext-xsminimum — adoptedaria-live="polite"announcement — adopted// TODO: allow clearing recent historycomment — adopted<svg>witharia-hidden="true"+ screen-reader text alternative, not Unicode arrows — adoptedTobias Wendt:
localStorageSSR safety:onMountguard is correct foradapter-nodeconversations/paths in Gitea Actions workflow filesImplementation complete ✅
All 9 tasks implemented on branch
feat/issue-162-korrespondenz-redesign.Commits
f88371e252881bf352058e94269948286b93addc724f5f8251b95d9449f6b0aWhat was implemented
Backend
DocumentRepository.findSinglePersonCorrespondence— new JPQL query returning all docs where a person is sender OR receiverDocumentService.getConversationFiltered— branches on null receiverId to use the single-person queryDocumentController—receiverIdis now@RequestParam(required = false)PersonService.findAll— blank/whitespace query now returns empty list instead of all personsFrontend
/conversations→/korrespondenz(server route + nav + all internal links)CorrespondenzPersonBar(Person A + swap + Korrespondent) andCorrespondenzFilterControls(date range + letter count + sort toggle)SinglePersonHintBar— amber bar shown in single-person modeCorrespondentSuggestionsDropdown— keyboard-navigable listbox fetching from/api/persons/{id}/correspondentsCorrespondenzEmptyState— icon + heading + search button + recent person chips from localStorageConversationTimelinerewritten — log card rows with direction arrows, colored left borders, year dividers, asymmetry bar in bilateral mode, other-party name in single-person mode+page.svelte— orchestrates all components, persists recent persons tokorrespondenz_recent_personsin localStoragei18n
messages/de.json,en.json,es.jsonsrc/lib/messages-extra.tsshim for keys not yet in root-owned paraglide compiled filesTests
Next step
Open a PR from
feat/issue-162-korrespondenz-redesign→main.