feat(ui): Korrespondenz redesign — compact strip, log cards, single-person mode #162

Closed
opened 2026-03-30 10:54:57 +02:00 by marcel · 8 comments
Owner

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

Area Current After
Route /conversations /korrespondenz
Nav label i18n conv_heading → "Gespräche" → "Korrespondenz"
Filter UI Large p-8 card, 2-column grid Compact 2-row strip, always visible
Receiver field Required (gate blocks results) Optional — single-person mode works without it
Suggestions in person B Not shown Top correspondents when person A is set
Timeline renderer Chat bubbles, central line Correspondence log cards with direction + year bands
Asymmetry bar Does not exist Shown only in bilateral mode
Empty state "Select both persons" gate Search prompt + recent chips (localStorage)
Single-person hint Does not exist Amber bar when A set, B empty
Count display Summary bar above timeline Live in Row 2 of the strip

Kept unchanged: PersonTypeahead, restrictToCorrespondentsOf, swap button behaviour, goto()-based navigation, URL param shape (senderId, receiverId, from, to, dir), canWrite new-document link, year-divider logic.


1 · Route rename

Move the SvelteKit route directory:

frontend/src/routes/conversations/  →  frontend/src/routes/korrespondenz/

Add a redirect in +layout.server.ts or a +page.server.ts at the old path so that any bookmarked /conversations URLs redirect to /korrespondenz with a 301. All internal links (+layout.svelte nav, ConversationTimeline new-doc link, any href="/conversations" references) must be updated.


2 · i18n changes

Edit frontend/messages/de.json, en.json, es.json.

Update existing keys

Key Old value (de) New value (de)
conv_heading "Gespräche" "Korrespondenz"
conv_subtitle (existing) "Briefwechsel einer Person durchsuchen — mit oder ohne Korrespondent."
conv_label_person_b "Person B" "Korrespondent"
conv_empty_heading (existing) "Korrespondenz durchsuchen"
conv_empty_text (existing) "Wähle eine Person aus dem Archiv um deren Briefe zu sehen — mit oder ohne Korrespondent."

Add new keys

// de.json additions
"conv_label_correspondent_optional": "Korrespondent",   // label when field is optional (no person A yet)
"conv_hint_single_person": "Alle Briefe von {name} — wähle einen Korrespondenten oben um einzugrenzen",
"conv_hint_single_person_filtered": "Alle Briefe von {name} · {from}–{to} · {sortLabel}",
"conv_strip_period": "Zeitraum",
"conv_strip_from_placeholder": "Von…",
"conv_strip_to_placeholder": "Bis…",
"conv_strip_all_correspondents": "Alle Korrespondenten",
"conv_strip_sort_newest": "Neueste ↓",
"conv_strip_sort_oldest": "Älteste ↑",
"conv_suggestions_heading": "Häufigste Korrespondenten",
"conv_suggestions_all_label": "Alle Korrespondenten von {name}",
"conv_letters_count": "{count} Briefe",
"conv_empty_search_placeholder": "Person suchen…",
"conv_empty_recent_label": "Zuletzt geöffnet",
"conv_asym_sent": "{count} von {name} →",
"conv_asym_received": "{count} von {name} ←"

Add equivalent translations in en.json and es.json.


3 · +page.server.ts

Single-person mode API call

Currently the load function only calls /api/documents/conversation when both senderId and receiverId are set. Add a single-person branch:

if (senderId && !receiverId) {
    // Fetch all documents where person is sender or receiver
    // Option A: extend /api/documents/conversation to allow null receiverId (backend change needed — see below)
    // Option B: use GET /api/documents?senderId=... (check if this filter exists)
    // Use whichever works; note which approach was chosen in a comment
}

Check whether GET /api/documents supports a senderId filter. If not, add the query param to the backend DocumentController / DocumentService before implementing this. A placeholder that returns [] in single-person mode is acceptable as a first step — mark it with a // TODO comment and open a follow-up issue.

canWrite derivation

Keep existing logic — no change needed.

Return shape

Add canWrite to the returned object if it is not already there (check — +page.svelte reads data.canWrite).


4 · +page.svelte

Remove the "both persons required" gate

Delete the entire {#if !senderId || !receiverId} block (lines 72–88 of the current file).

Replace the conditional rendering with:

{#if !senderId}
  <!-- EmptyState component -->
  <CorrespondenzEmptyState onSelectPerson={(id) => { senderId = id; applyFilters(); }} />
{:else if data.documents.length === 0}
  <!-- no-results state — keep existing markup -->
{:else}
  <ConversationTimeline ... />
{/if}

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-10 wrapper padding

The 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 to ConversationFilterBar so Row 2 can show the live count.


5 · ConversationFilterBar.svelte — full redesign

Replace the current p-8 card with a two-row sticky strip. The strip is always rendered; Row 2 is dimmed when no person is selected.

Props (add to existing)

documentCount?: number;   // for live count in Row 2

Row 1 — person inputs

[Person field (flex:1)] [⇄ swap button (28×28)] [Korrespondent field (flex:1)]
  • Container: bg-white border-b border-[#EAE7E0] px-4 sm:px-[18px] py-[9px] flex items-end gap-[9px]
  • Person field label: "Person", required
  • Korrespondent field label:
    • When senderId is empty: "Korrespondent" + italic suffix "— optional"
    • When senderId is set: "Korrespondent" (no suffix — it's now meaningful)
  • Korrespondent <input> / PersonTypeahead:
    • Placeholder: "Alle Korrespondenten" (not "Name eingeben…")
    • Border style: border-dashed when empty, border-solid when a value is set
    • background: #F9F8F6 when empty (visually different from the primary field)
    • restrictToCorrespondentsOf={senderId || undefined}keep existing prop
  • Swap button: opacity-0 pointer-events-none when only one person is set; visible when both are set

Suggestions dropdown (when person B input is focused and senderId is set)

Show a dropdown below the person B field using the results already available from restrictToCorrespondentsOf. The PersonTypeahead component may already render a dropdown — check if it can be extended to show a pre-populated list before the user types.

If PersonTypeahead does not support pre-populated suggestions, add a separate <div> positioned absolute top-full left-0 right-0 z-30 that appears when the input is focused and senderId is set:

┌──────────────────────────────────────────┐
│ HÄUFIGSTE KORRESPONDENTEN                │
│ [avatar] Maria Weber    ████░░ 32 Briefe │
│ [avatar] Klaus Fischer  ██░░░░ 11 Briefe │
│ [avatar] Helene Müller  █░░░░░  4 Briefe │
├──────────────────────────────────────────┤
│  oder ohne Einschränkung                 │
│ [—] Alle Korrespondenten Ottos  47 Br.   │
└──────────────────────────────────────────┘
  • Fetch: use the same PersonTypeahead API call or call GET /api/persons with restrictToCorrespondentsOf — whichever is simpler
  • Each row: avatar (initials, 16×16, navy bg) + name + mini bar (proportional to letter count) + count label
  • Clicking a row: sets receiverId, closes dropdown, calls onapplyFilters()
  • "Alle Korrespondenten" row: clears receiverId (sets to ''), calls onapplyFilters()
  • Dismiss: click outside, Escape, or selection

Row 2 — date range + count + sort

[Zeitraum label] [Von… input] [–] [Bis… input]   [N Briefe] [Neueste ↓]
  • Container: bg-[#F7F5F2] border-b border-[#E0DDD6] px-4 sm:px-[18px] py-[5px] flex items-center gap-[10px]
  • Always rendered in the DOM
  • When no person selected: opacity-40 pointer-events-none on the whole Row 2
  • Date inputs:
    • Height 22px, width 80px, border 1.5px solid #D1D5DB, border-radius: 3px
    • Placeholder text "Von…" / "Bis…" (font-style italic, color #AAA)
    • When value set: border-color: #002850, text #333, not italic
    • Input type date or 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()
  • Count: margin-left: auto, font-size: 8px, font-weight: 700, color #888 normally, #002850 when date filter is active (i.e. fromDate || toDate); value is {documentCount} Briefe
  • Sort button:
    • Label: sortDir === 'DESC' ? 'Neueste ↓' : 'Älteste ↑'
    • Style: height: 22px, border, rounded 3px, font-size: 8px font-weight 700
    • Active (date filter or non-default sort): border-color: #002850 color: #002850
    • onclick={ontoggleSort}

6 · ConversationTimeline.svelte — full redesign

Remove

  • The <div class="relative overflow-hidden ... bg-surface shadow-sm"> chat container
  • The central timeline line <div class="absolute ... w-px ... bg-muted">
  • All chat bubble markup (.justify-end / .justify-start, .rounded-br-none, .rounded-bl-none)
  • The avatar circles next to bubbles
  • The summary bar at the top (count moves to filter strip Row 2)

New prop

senderId: string;
receiverId: string;   // now optional — may be empty string in single-person mode

Asymmetry bar — only when both persons set

Render above the log when senderId && receiverId:

{#if senderId && receiverId}
  {@const outCount = documents.filter(d => d.sender?.id === senderId).length}
  {@const inCount = documents.length - outCount}
  {@const outPct = documents.length > 0 ? (outCount / documents.length) * 100 : 0}
  <div class="border-b border-[#E8E4DF] bg-[#F7F5F2] px-[18px] py-2 flex flex-col gap-1">
    <div class="flex justify-between text-[7.5px] font-bold">
      <span class="text-[#002850]">{outCount} von {senderName}</span>
      <span class="text-[#0F5755]">{inCount} von {receiverName}</span>
    </div>
    <div class="h-[5px] rounded-full bg-[#E0DDD6] overflow-hidden flex">
      <div class="h-full bg-[#002850]" style="width: {outPct}%"></div>
      <div class="h-full bg-[#A6DAD8]" style="width: {100 - outPct}%"></div>
    </div>
  </div>
{/if}

Add senderName and receiverName as props (passed from +page.svelte via data.initialValues).

Log structure

Replace the {#each enrichedDocuments} chat block with:

<div class="bg-white border border-[#E0DDD6] rounded-sm overflow-hidden">
  {#each enrichedDocuments as { doc, year, showYearDivider, isOut } (doc.id)}
    {#if showYearDivider}
      <!-- Year band -->
      <div class="px-[14px] py-[6px] bg-[#F0EDE6] border-t-2 border-[#C8C4BE] border-b border-[#D8D4CE] flex items-baseline gap-2"
           data-testid="year-divider">
        <span class="text-[15px] font-black text-[#002850] tracking-tight">{year}</span>
        <span class="text-[7.5px] font-bold text-[#AAA]">{yearDocCount(year)} Briefe</span>
      </div>
    {/if}

    <!-- Log row -->
    <a href="/documents/{doc.id}"
       class="flex items-start gap-[9px] px-[14px] py-[10px] border-b border-[#EDEBE4] last:border-b-0
              border-l-[3px] {isOut ? 'border-l-[#002850]' : 'border-l-[#A6DAD8]'}
              hover:bg-[#F7F5F2] transition-colors cursor-pointer group">

      <!-- Direction arrow -->
      <span class="text-[9px] font-black pt-[1px] w-[14px] shrink-0 {isOut ? 'text-[#002850]' : 'text-[#0F5755]'}">
        {isOut ? '→' : '←'}
      </span>

      <!-- Body -->
      <div class="flex-1 min-w-0">
        <div class="text-[9px] font-bold text-[#0D2240] mb-[2px] truncate">
          {doc.title || doc.originalFilename}
        </div>
        <div class="flex items-center gap-[5px] text-[7.5px] text-[#888]">
          <span>{formatDate(doc.documentDate)}</span>
          {#if doc.location}
            <span class="text-[#D1CCC8]">·</span>
            <span>{doc.location}</span>
          {/if}
          <!-- In single-person mode: show the other party name -->
          {#if !receiverId}
            <span class="text-[#D1CCC8]">·</span>
            <span>{otherPartyName(doc, senderId)}</span>
          {/if}
          <!-- Status dot -->
          <span class="w-[6px] h-[6px] rounded-full shrink-0 ml-[3px] {statusDotClass(doc.status)}"></span>
        </div>
      </div>

      <!-- Hover chevron -->
      <span class="opacity-0 group-hover:opacity-100 transition-opacity text-[#888] text-[13px] shrink-0"></span>
    </a>
  {/each}
</div>

isOut derivation

const enrichedDocuments = $derived(
    documents.map((doc, i) => {
        const year = doc.documentDate ? new Date(doc.documentDate).getFullYear() : null;
        const prevYear = i > 0 && documents[i - 1].documentDate
            ? new Date(documents[i - 1].documentDate!).getFullYear() : null;
        const isOut = doc.sender?.id === senderId;
        return { doc, year, showYearDivider: year !== null && year !== prevYear, isOut };
    })
);

yearDocCount helper

function yearDocCount(year: number) {
    return documents.filter(d => d.documentDate && new Date(d.documentDate).getFullYear() === year).length;
}

otherPartyName helper (single-person mode)

function otherPartyName(doc: Doc, senderId: string): string {
    if (doc.sender?.id === senderId) {
        // outgoing — show first receiver if available
        return doc.receivers?.[0] ? `${doc.receivers[0].firstName} ${doc.receivers[0].lastName}` : '—';
    } else {
        // incoming — show sender
        return doc.sender ? `${doc.sender.firstName} ${doc.sender.lastName}` : '—';
    }
}

Note: receivers is not currently in the ConversationTimeline document type. Add it to the type definition; the Document entity already has receivers.

Status dot colours

function statusDotClass(status: string): string {
    return {
        PLACEHOLDER: 'bg-yellow-400',
        UPLOADED:    'bg-green-500',
        TRANSCRIBED: 'bg-blue-500',
        REVIEWED:    'bg-purple-500',
        ARCHIVED:    'bg-gray-500',
    }[status] ?? 'bg-gray-300';
}

Move from the summary bar to the bottom of the log list, after the last row, right-aligned:

{#if canWrite}
  <div class="flex justify-end px-[14px] py-[6px] border-t border-[#E8E4DF]">
    <a href="/documents/new?senderId={senderId}{receiverId ? '&receiverId=' + receiverId : ''}"
       data-testid="conv-new-doc-link"
       class="inline-flex items-center gap-1 text-[8.5px] font-bold text-[#002850]/50 hover:text-[#002850] transition-colors">
      <svg width="12" height="12" ...><!-- plus icon --></svg>
      {m.conv_new_doc_link()}
    </a>
  </div>
{/if}

7 · CorrespondenzEmptyState.svelte — new component

Create frontend/src/routes/korrespondenz/CorrespondenzEmptyState.svelte.

Props:
  onSelectPerson: (id: string) => void

Layout:

[✉ icon 52×52, rounded-full, bg-[#F0EDE6]]
[h2: "Korrespondenz durchsuchen"  font-serif 14px font-black color #0D2240]
[p: "Wähle eine Person aus dem Archiv..."  10.5px color #888 max-w-[280px]]
[Search input (28×28 focus ring, 260px wide) — focuses the strip Person A field on click]
[— oder —]
[ZULETZT GEÖFFNET label]
[Recent person chips  (only if localStorage history exists)]

Recent persons (localStorage):

  • Key: korrespondenz_recent_persons — array of { id: string; firstName: string; lastName: string }, max 3 entries
  • Write on every navigation that sets senderId (in +page.svelte's applyFilters)
  • Read on mount in the empty state component
  • Desktop chip: avatar (initials, 24×24) + full name; mobile: avatar + first name only
  • Clicking a chip: calls onSelectPerson(id) which sets senderId and calls applyFilters()

"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.svelte when senderId && !receiverId and data.documents.length > 0:

{#if senderId && !receiverId}
  <div class="flex items-center gap-[5px] border-b border-[#FDBA74] bg-[#FFF7ED] px-[18px] py-[6px] text-[8px] text-[#92400E]">
    <span class="text-[11px]">📋</span>
    {#if fromDate || toDate}
      <span><strong>{senderName}</strong> · {fromDate}{toDate} · {sortLabel}</span>
    {:else}
      <span><strong>Alle Briefe von {senderName}</strong> — wähle einen Korrespondenten oben um einzugrenzen</span>
    {/if}
  </div>
{/if}

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:

[Person A (flex:1)] [⇄ (24×24)] [Korrespondent (flex:1)]

Both fields are height: 36px on 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: 44px for touch. The full card tap navigates to the document.


10 · Accessibility

  • The filter strip must not use pointer-events-none on focusable inputs without also setting aria-disabled="true" and tabindex="-1" on each control.
  • Sort button: aria-label="Sortierung umkehren", aria-pressed based on direction.
  • Suggestions dropdown: role="listbox" on the container, role="option" on each row, aria-selected on the active one, keyboard navigation (↑/↓/Enter/Escape).
  • Log rows are <a> tags — they already have implicit link semantics. Add aria-label="{title}, {date}" to each for screen readers.
  • Asymmetry bar: role="img" with aria-label="Briefverteilung: {outCount} von {senderName}, {inCount} von {receiverName}".
  • Empty state search input: aria-label="Person suchen".

11 · Tests

The spec file at frontend/src/routes/conversations/page.svelte.spec.ts (will move to korrespondenz/) tests bilateral mode. Update:

  • data-testid="year-divider" — already used, keep it
  • data-testid="conv-summary"remove (count moves to strip Row 2, no longer a separate element with this testid)
  • data-testid="conv-swap-btn" — keep
  • data-testid="conv-new-doc-link" — keep (moved to bottom of log)

Add new tests:

  • Renders empty state when no senderId
  • Renders single-person log when senderId set, receiverId empty
  • Single-person hint bar appears when senderId set and receiverId empty
  • Asymmetry bar not rendered in single-person mode
  • Asymmetry bar rendered with correct percentages in bilateral mode
  • Row 2 has opacity-40 class when senderId is empty
  • Row 2 is fully interactive when senderId is set

Out of scope for this issue

  • Backend changes to /api/documents/conversation (single-person query) — only a // TODO placeholder needed if the endpoint does not support it yet
  • Dark mode for the filter strip and log (the current project uses dark: Tailwind classes — defer to a follow-up)
  • Suggestions dropdown letter-count bar (proportional width) — if PersonTypeahead does not expose letter counts, show the names without the bar for now and add a // TODO

Acceptance criteria

  • Route /korrespondenz works; /conversations redirects to it with 301
  • Nav label shows "Korrespondenz" in all three languages
  • Empty state renders with search prompt and recent chips (localStorage) when no person selected
  • Strip Row 2 is dimmed (opacity-40) with no person set and fully interactive once any person is set
  • Single-person mode: log renders all documents for person A; hint bar visible; asymmetry bar hidden
  • Bilateral mode: log renders bilateral documents; asymmetry bar visible with correct percentages
  • Date filter: Row 2 count updates live; date inputs show navy border when set; sort label updates
  • Suggestions dropdown appears when person B is focused and person A is set; selecting a row updates filters
  • Log cards: direction arrow + left border colour (navy = sent, teal = received) correct
  • Log cards: status dot colour matches DocumentStatus (5 colours)
  • In single-person mode, other-party name shown in meta row; in bilateral mode it is omitted
  • Year bands: correct year label + letter count
  • Touch targets ≥ 44px on mobile for all strip controls and log rows
  • Existing tests pass (with updated selectors); new tests listed above pass
  • svelte-check passes with no new type errors
  • Visual proof: run /proofshot at 375px and ≥768px in empty, single-person, and bilateral states

Reference

Full annotated spec: docs/specs/korrespondenz-redesign-spec.html — open in a browser.

Key existing files:

  • src/routes/conversations/+page.svelte (→ will move to korrespondenz/)
  • src/routes/conversations/ConversationFilterBar.svelte
  • src/routes/conversations/ConversationTimeline.svelte
  • src/routes/conversations/+page.server.ts
  • src/routes/conversations/page.svelte.spec.ts
  • src/lib/components/PersonTypeahead.svelte — reuse as-is
## 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 | Area | Current | After | |---|---|---| | Route | `/conversations` | `/korrespondenz` | | Nav label i18n | `conv_heading` → "Gespräche" | → "Korrespondenz" | | Filter UI | Large `p-8` card, 2-column grid | Compact 2-row strip, always visible | | Receiver field | Required (gate blocks results) | Optional — single-person mode works without it | | Suggestions in person B | Not shown | Top correspondents when person A is set | | Timeline renderer | Chat bubbles, central line | Correspondence log cards with direction + year bands | | Asymmetry bar | Does not exist | Shown only in bilateral mode | | Empty state | "Select both persons" gate | Search prompt + recent chips (localStorage) | | Single-person hint | Does not exist | Amber bar when A set, B empty | | Count display | Summary bar above timeline | Live in Row 2 of the strip | **Kept unchanged:** `PersonTypeahead`, `restrictToCorrespondentsOf`, swap button behaviour, `goto()`-based navigation, URL param shape (`senderId`, `receiverId`, `from`, `to`, `dir`), `canWrite` new-document link, year-divider logic. --- ## 1 · Route rename Move the SvelteKit route directory: ``` frontend/src/routes/conversations/ → frontend/src/routes/korrespondenz/ ``` Add a redirect in `+layout.server.ts` or a `+page.server.ts` at the old path so that any bookmarked `/conversations` URLs redirect to `/korrespondenz` with a 301. All internal links (`+layout.svelte` nav, `ConversationTimeline` new-doc link, any `href="/conversations"` references) must be updated. --- ## 2 · i18n changes Edit `frontend/messages/de.json`, `en.json`, `es.json`. ### Update existing keys | Key | Old value (de) | New value (de) | |---|---|---| | `conv_heading` | `"Gespräche"` | `"Korrespondenz"` | | `conv_subtitle` | _(existing)_ | `"Briefwechsel einer Person durchsuchen — mit oder ohne Korrespondent."` | | `conv_label_person_b` | `"Person B"` | `"Korrespondent"` | | `conv_empty_heading` | _(existing)_ | `"Korrespondenz durchsuchen"` | | `conv_empty_text` | _(existing)_ | `"Wähle eine Person aus dem Archiv um deren Briefe zu sehen — mit oder ohne Korrespondent."` | ### Add new keys ```jsonc // de.json additions "conv_label_correspondent_optional": "Korrespondent", // label when field is optional (no person A yet) "conv_hint_single_person": "Alle Briefe von {name} — wähle einen Korrespondenten oben um einzugrenzen", "conv_hint_single_person_filtered": "Alle Briefe von {name} · {from}–{to} · {sortLabel}", "conv_strip_period": "Zeitraum", "conv_strip_from_placeholder": "Von…", "conv_strip_to_placeholder": "Bis…", "conv_strip_all_correspondents": "Alle Korrespondenten", "conv_strip_sort_newest": "Neueste ↓", "conv_strip_sort_oldest": "Älteste ↑", "conv_suggestions_heading": "Häufigste Korrespondenten", "conv_suggestions_all_label": "Alle Korrespondenten von {name}", "conv_letters_count": "{count} Briefe", "conv_empty_search_placeholder": "Person suchen…", "conv_empty_recent_label": "Zuletzt geöffnet", "conv_asym_sent": "{count} von {name} →", "conv_asym_received": "{count} von {name} ←" ``` Add equivalent translations in `en.json` and `es.json`. --- ## 3 · `+page.server.ts` ### Single-person mode API call Currently the load function only calls `/api/documents/conversation` when **both** `senderId` and `receiverId` are set. Add a single-person branch: ```typescript if (senderId && !receiverId) { // Fetch all documents where person is sender or receiver // Option A: extend /api/documents/conversation to allow null receiverId (backend change needed — see below) // Option B: use GET /api/documents?senderId=... (check if this filter exists) // Use whichever works; note which approach was chosen in a comment } ``` Check whether `GET /api/documents` supports a `senderId` filter. If not, add the query param to the backend `DocumentController` / `DocumentService` before implementing this. A placeholder that returns `[]` in single-person mode is acceptable as a first step — mark it with a `// TODO` comment and open a follow-up issue. ### `canWrite` derivation Keep existing logic — no change needed. ### Return shape Add `canWrite` to the returned object if it is not already there (check — `+page.svelte` reads `data.canWrite`). --- ## 4 · `+page.svelte` ### Remove the "both persons required" gate Delete the entire `{#if !senderId || !receiverId}` block (lines 72–88 of the current file). Replace the conditional rendering with: ```svelte {#if !senderId} <!-- EmptyState component --> <CorrespondenzEmptyState onSelectPerson={(id) => { senderId = id; applyFilters(); }} /> {:else if data.documents.length === 0} <!-- no-results state — keep existing markup --> {:else} <ConversationTimeline ... /> {/if} ``` ### 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-10` wrapper padding The 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 to `ConversationFilterBar` so Row 2 can show the live count. --- ## 5 · `ConversationFilterBar.svelte` — full redesign Replace the current `p-8` card with a two-row sticky strip. The strip is **always rendered**; Row 2 is dimmed when no person is selected. ### Props (add to existing) ```typescript documentCount?: number; // for live count in Row 2 ``` ### Row 1 — person inputs ``` [Person field (flex:1)] [⇄ swap button (28×28)] [Korrespondent field (flex:1)] ``` - Container: `bg-white border-b border-[#EAE7E0] px-4 sm:px-[18px] py-[9px] flex items-end gap-[9px]` - Person field label: `"Person"`, required - Korrespondent field label: - When `senderId` is empty: `"Korrespondent"` + italic suffix `"— optional"` - When `senderId` is set: `"Korrespondent"` (no suffix — it's now meaningful) - Korrespondent `<input>` / `PersonTypeahead`: - Placeholder: `"Alle Korrespondenten"` (not "Name eingeben…") - Border style: `border-dashed` when empty, `border-solid` when a value is set - `background: #F9F8F6` when empty (visually different from the primary field) - `restrictToCorrespondentsOf={senderId || undefined}` — **keep existing prop** - Swap button: `opacity-0 pointer-events-none` when only one person is set; visible when both are set ### Suggestions dropdown (when person B input is focused and `senderId` is set) Show a dropdown **below the person B field** using the results already available from `restrictToCorrespondentsOf`. The `PersonTypeahead` component may already render a dropdown — check if it can be extended to show a pre-populated list before the user types. If `PersonTypeahead` does not support pre-populated suggestions, add a separate `<div>` positioned `absolute top-full left-0 right-0 z-30` that appears when the input is focused and `senderId` is set: ``` ┌──────────────────────────────────────────┐ │ HÄUFIGSTE KORRESPONDENTEN │ │ [avatar] Maria Weber ████░░ 32 Briefe │ │ [avatar] Klaus Fischer ██░░░░ 11 Briefe │ │ [avatar] Helene Müller █░░░░░ 4 Briefe │ ├──────────────────────────────────────────┤ │ oder ohne Einschränkung │ │ [—] Alle Korrespondenten Ottos 47 Br. │ └──────────────────────────────────────────┘ ``` - Fetch: use the same `PersonTypeahead` API call or call `GET /api/persons` with `restrictToCorrespondentsOf` — whichever is simpler - Each row: avatar (initials, `16×16`, navy bg) + name + mini bar (proportional to letter count) + count label - Clicking a row: sets `receiverId`, closes dropdown, calls `onapplyFilters()` - "Alle Korrespondenten" row: clears `receiverId` (sets to `''`), calls `onapplyFilters()` - Dismiss: click outside, Escape, or selection ### Row 2 — date range + count + sort ``` [Zeitraum label] [Von… input] [–] [Bis… input] [N Briefe] [Neueste ↓] ``` - Container: `bg-[#F7F5F2] border-b border-[#E0DDD6] px-4 sm:px-[18px] py-[5px] flex items-center gap-[10px]` - **Always rendered in the DOM** - When no person selected: `opacity-40 pointer-events-none` on the whole Row 2 - Date inputs: - Height `22px`, width `80px`, border `1.5px solid #D1D5DB`, `border-radius: 3px` - Placeholder text `"Von…"` / `"Bis…"` (font-style italic, color `#AAA`) - When value set: `border-color: #002850`, text `#333`, not italic - Input type `date` or 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()` - Count: `margin-left: auto`, `font-size: 8px`, `font-weight: 700`, color `#888` normally, `#002850` when date filter is active (i.e. `fromDate || toDate`); value is `{documentCount} Briefe` - Sort button: - Label: `sortDir === 'DESC' ? 'Neueste ↓' : 'Älteste ↑'` - Style: `height: 22px`, border, rounded `3px`, `font-size: 8px font-weight 700` - Active (date filter or non-default sort): `border-color: #002850 color: #002850` - `onclick={ontoggleSort}` --- ## 6 · `ConversationTimeline.svelte` — full redesign ### Remove - The `<div class="relative overflow-hidden ... bg-surface shadow-sm">` chat container - The central timeline line `<div class="absolute ... w-px ... bg-muted">` - All chat bubble markup (`.justify-end / .justify-start`, `.rounded-br-none`, `.rounded-bl-none`) - The avatar circles next to bubbles - The summary bar at the top (count moves to filter strip Row 2) ### New prop ```typescript senderId: string; receiverId: string; // now optional — may be empty string in single-person mode ``` ### Asymmetry bar — only when both persons set Render **above** the log when `senderId && receiverId`: ```svelte {#if senderId && receiverId} {@const outCount = documents.filter(d => d.sender?.id === senderId).length} {@const inCount = documents.length - outCount} {@const outPct = documents.length > 0 ? (outCount / documents.length) * 100 : 0} <div class="border-b border-[#E8E4DF] bg-[#F7F5F2] px-[18px] py-2 flex flex-col gap-1"> <div class="flex justify-between text-[7.5px] font-bold"> <span class="text-[#002850]">{outCount} von {senderName} →</span> <span class="text-[#0F5755]">{inCount} von {receiverName} ←</span> </div> <div class="h-[5px] rounded-full bg-[#E0DDD6] overflow-hidden flex"> <div class="h-full bg-[#002850]" style="width: {outPct}%"></div> <div class="h-full bg-[#A6DAD8]" style="width: {100 - outPct}%"></div> </div> </div> {/if} ``` Add `senderName` and `receiverName` as props (passed from `+page.svelte` via `data.initialValues`). ### Log structure Replace the `{#each enrichedDocuments}` chat block with: ```svelte <div class="bg-white border border-[#E0DDD6] rounded-sm overflow-hidden"> {#each enrichedDocuments as { doc, year, showYearDivider, isOut } (doc.id)} {#if showYearDivider} <!-- Year band --> <div class="px-[14px] py-[6px] bg-[#F0EDE6] border-t-2 border-[#C8C4BE] border-b border-[#D8D4CE] flex items-baseline gap-2" data-testid="year-divider"> <span class="text-[15px] font-black text-[#002850] tracking-tight">{year}</span> <span class="text-[7.5px] font-bold text-[#AAA]">{yearDocCount(year)} Briefe</span> </div> {/if} <!-- Log row --> <a href="/documents/{doc.id}" class="flex items-start gap-[9px] px-[14px] py-[10px] border-b border-[#EDEBE4] last:border-b-0 border-l-[3px] {isOut ? 'border-l-[#002850]' : 'border-l-[#A6DAD8]'} hover:bg-[#F7F5F2] transition-colors cursor-pointer group"> <!-- Direction arrow --> <span class="text-[9px] font-black pt-[1px] w-[14px] shrink-0 {isOut ? 'text-[#002850]' : 'text-[#0F5755]'}"> {isOut ? '→' : '←'} </span> <!-- Body --> <div class="flex-1 min-w-0"> <div class="text-[9px] font-bold text-[#0D2240] mb-[2px] truncate"> {doc.title || doc.originalFilename} </div> <div class="flex items-center gap-[5px] text-[7.5px] text-[#888]"> <span>{formatDate(doc.documentDate)}</span> {#if doc.location} <span class="text-[#D1CCC8]">·</span> <span>{doc.location}</span> {/if} <!-- In single-person mode: show the other party name --> {#if !receiverId} <span class="text-[#D1CCC8]">·</span> <span>{otherPartyName(doc, senderId)}</span> {/if} <!-- Status dot --> <span class="w-[6px] h-[6px] rounded-full shrink-0 ml-[3px] {statusDotClass(doc.status)}"></span> </div> </div> <!-- Hover chevron --> <span class="opacity-0 group-hover:opacity-100 transition-opacity text-[#888] text-[13px] shrink-0">›</span> </a> {/each} </div> ``` ### `isOut` derivation ```typescript const enrichedDocuments = $derived( documents.map((doc, i) => { const year = doc.documentDate ? new Date(doc.documentDate).getFullYear() : null; const prevYear = i > 0 && documents[i - 1].documentDate ? new Date(documents[i - 1].documentDate!).getFullYear() : null; const isOut = doc.sender?.id === senderId; return { doc, year, showYearDivider: year !== null && year !== prevYear, isOut }; }) ); ``` ### `yearDocCount` helper ```typescript function yearDocCount(year: number) { return documents.filter(d => d.documentDate && new Date(d.documentDate).getFullYear() === year).length; } ``` ### `otherPartyName` helper (single-person mode) ```typescript function otherPartyName(doc: Doc, senderId: string): string { if (doc.sender?.id === senderId) { // outgoing — show first receiver if available return doc.receivers?.[0] ? `${doc.receivers[0].firstName} ${doc.receivers[0].lastName}` : '—'; } else { // incoming — show sender return doc.sender ? `${doc.sender.firstName} ${doc.sender.lastName}` : '—'; } } ``` Note: `receivers` is not currently in the `ConversationTimeline` document type. Add it to the type definition; the `Document` entity already has `receivers`. ### Status dot colours ```typescript function statusDotClass(status: string): string { return { PLACEHOLDER: 'bg-yellow-400', UPLOADED: 'bg-green-500', TRANSCRIBED: 'bg-blue-500', REVIEWED: 'bg-purple-500', ARCHIVED: 'bg-gray-500', }[status] ?? 'bg-gray-300'; } ``` ### `canWrite` new-document link Move from the summary bar to the **bottom of the log list**, after the last row, right-aligned: ```svelte {#if canWrite} <div class="flex justify-end px-[14px] py-[6px] border-t border-[#E8E4DF]"> <a href="/documents/new?senderId={senderId}{receiverId ? '&receiverId=' + receiverId : ''}" data-testid="conv-new-doc-link" class="inline-flex items-center gap-1 text-[8.5px] font-bold text-[#002850]/50 hover:text-[#002850] transition-colors"> <svg width="12" height="12" ...><!-- plus icon --></svg> {m.conv_new_doc_link()} </a> </div> {/if} ``` --- ## 7 · `CorrespondenzEmptyState.svelte` — new component Create `frontend/src/routes/korrespondenz/CorrespondenzEmptyState.svelte`. ``` Props: onSelectPerson: (id: string) => void ``` **Layout:** ``` [✉ icon 52×52, rounded-full, bg-[#F0EDE6]] [h2: "Korrespondenz durchsuchen" font-serif 14px font-black color #0D2240] [p: "Wähle eine Person aus dem Archiv..." 10.5px color #888 max-w-[280px]] [Search input (28×28 focus ring, 260px wide) — focuses the strip Person A field on click] [— oder —] [ZULETZT GEÖFFNET label] [Recent person chips (only if localStorage history exists)] ``` **Recent persons (localStorage):** - Key: `korrespondenz_recent_persons` — array of `{ id: string; firstName: string; lastName: string }`, max 3 entries - Write on every navigation that sets `senderId` (in `+page.svelte`'s `applyFilters`) - Read on mount in the empty state component - Desktop chip: avatar (initials, 24×24) + full name; mobile: avatar + first name only - Clicking a chip: calls `onSelectPerson(id)` which sets `senderId` and calls `applyFilters()` **"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.svelte` when `senderId && !receiverId` and `data.documents.length > 0`: ```svelte {#if senderId && !receiverId} <div class="flex items-center gap-[5px] border-b border-[#FDBA74] bg-[#FFF7ED] px-[18px] py-[6px] text-[8px] text-[#92400E]"> <span class="text-[11px]">📋</span> {#if fromDate || toDate} <span><strong>{senderName}</strong> · {fromDate}–{toDate} · {sortLabel}</span> {:else} <span><strong>Alle Briefe von {senderName}</strong> — wähle einen Korrespondenten oben um einzugrenzen</span> {/if} </div> {/if} ``` 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: ``` [Person A (flex:1)] [⇄ (24×24)] [Korrespondent (flex:1)] ``` Both fields are `height: 36px` on 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: 44px` for touch. The full card tap navigates to the document. --- ## 10 · Accessibility - The filter strip must not use `pointer-events-none` on focusable inputs without also setting `aria-disabled="true"` and `tabindex="-1"` on each control. - Sort button: `aria-label="Sortierung umkehren"`, `aria-pressed` based on direction. - Suggestions dropdown: `role="listbox"` on the container, `role="option"` on each row, `aria-selected` on the active one, keyboard navigation (↑/↓/Enter/Escape). - Log rows are `<a>` tags — they already have implicit link semantics. Add `aria-label="{title}, {date}"` to each for screen readers. - Asymmetry bar: `role="img"` with `aria-label="Briefverteilung: {outCount} von {senderName}, {inCount} von {receiverName}"`. - Empty state search input: `aria-label="Person suchen"`. --- ## 11 · Tests The spec file at `frontend/src/routes/conversations/page.svelte.spec.ts` (will move to `korrespondenz/`) tests bilateral mode. Update: - `data-testid="year-divider"` — already used, keep it - `data-testid="conv-summary"` — **remove** (count moves to strip Row 2, no longer a separate element with this testid) - `data-testid="conv-swap-btn"` — keep - `data-testid="conv-new-doc-link"` — keep (moved to bottom of log) Add new tests: - [ ] Renders empty state when no `senderId` - [ ] Renders single-person log when `senderId` set, `receiverId` empty - [ ] Single-person hint bar appears when `senderId` set and `receiverId` empty - [ ] Asymmetry bar not rendered in single-person mode - [ ] Asymmetry bar rendered with correct percentages in bilateral mode - [ ] Row 2 has `opacity-40` class when `senderId` is empty - [ ] Row 2 is fully interactive when `senderId` is set --- ## Out of scope for this issue - Backend changes to `/api/documents/conversation` (single-person query) — only a `// TODO` placeholder needed if the endpoint does not support it yet - Dark mode for the filter strip and log (the current project uses `dark:` Tailwind classes — defer to a follow-up) - Suggestions dropdown letter-count bar (proportional width) — if `PersonTypeahead` does not expose letter counts, show the names without the bar for now and add a `// TODO` --- ## Acceptance criteria - [ ] Route `/korrespondenz` works; `/conversations` redirects to it with 301 - [ ] Nav label shows "Korrespondenz" in all three languages - [ ] Empty state renders with search prompt and recent chips (localStorage) when no person selected - [ ] Strip Row 2 is dimmed (`opacity-40`) with no person set and fully interactive once any person is set - [ ] Single-person mode: log renders all documents for person A; hint bar visible; asymmetry bar hidden - [ ] Bilateral mode: log renders bilateral documents; asymmetry bar visible with correct percentages - [ ] Date filter: Row 2 count updates live; date inputs show navy border when set; sort label updates - [ ] Suggestions dropdown appears when person B is focused and person A is set; selecting a row updates filters - [ ] Log cards: direction arrow + left border colour (navy = sent, teal = received) correct - [ ] Log cards: status dot colour matches `DocumentStatus` (5 colours) - [ ] In single-person mode, other-party name shown in meta row; in bilateral mode it is omitted - [ ] Year bands: correct year label + letter count - [ ] Touch targets ≥ 44px on mobile for all strip controls and log rows - [ ] Existing tests pass (with updated selectors); new tests listed above pass - [ ] `svelte-check` passes with no new type errors - [ ] Visual proof: run `/proofshot` at 375px and ≥768px in empty, single-person, and bilateral states --- ## Reference Full annotated spec: `docs/specs/korrespondenz-redesign-spec.html` — open in a browser. Key existing files: - `src/routes/conversations/+page.svelte` (→ will move to `korrespondenz/`) - `src/routes/conversations/ConversationFilterBar.svelte` - `src/routes/conversations/ConversationTimeline.svelte` - `src/routes/conversations/+page.server.ts` - `src/routes/conversations/page.svelte.spec.ts` - `src/lib/components/PersonTypeahead.svelte` — reuse as-is
marcel added the conversationfeatureui labels 2026-03-30 10:55:03 +02:00
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Component decomposition

The issue describes ConversationFilterBar.svelte as 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 button
  • FilterStripRow2.svelte — date range, count, sort toggle
  • CorrespondentSuggestionsDropdown.svelte — the pre-populated dropdown

Similarly, the hint bar in §8 ({#if senderId && !receiverId}) lives inline in +page.svelte. That's a distinct visual region — make it SinglePersonHintBar.svelte. The page should be an orchestrator, not a mix of layout and conditional markup.

O(n²) in yearDocCount

The yearDocCount(year) helper is called inside {#each enrichedDocuments} — once per row, not once per year band. It iterates the full documents array each time. On a log with 200 entries across 10 years this runs 200 × n iterations. Precompute it as a $derived Map:

const countsByYear = $derived(
    documents.reduce((acc, d) => {
        if (d.documentDate) {
            const y = new Date(d.documentDate).getFullYear();
            acc.set(y, (acc.get(y) ?? 0) + 1);
        }
        return acc;
    }, new Map<number, number>())
);

statusDotClass type safety

The function signature is (status: string) but DocumentStatus is a typed enum on the generated API types. Use it:

function statusDotClass(status: components['schemas']['DocumentStatus']): string { ... }

This catches invalid status values at compile time instead of falling through to 'bg-gray-300' silently.

otherPartyName returns 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.

localStorage read in onMount is a side effect pattern

The issue says to read korrespondenz_recent_persons in CorrespondenzEmptyState on mount. In Svelte 5, onMount is fine for this specific case (SSR doesn't have localStorage), but the result should be $state, not derived inside onMount with a manual assignment. Also: guard against JSON.parse throwing on corrupt data.

// in CorrespondenzEmptyState.svelte
let recentPersons = $state<RecentPerson[]>([]);

onMount(() => {
    try {
        recentPersons = JSON.parse(localStorage.getItem('korrespondenz_recent_persons') ?? '[]');
    } catch {
        recentPersons = [];
    }
});

Questions

  • Who owns senderName / receiverName in ConversationTimeline? The issue says pass them via data.initialValues — but initialValues is only populated when the person fetch succeeds. What renders if the person fetch fails (e.g. person was deleted)?
  • The receiverId prop on ConversationTimeline is described as string (may be '') — but the existing type says string required. Should this be string | undefined to be explicit?
## 👨‍💻 Felix Brandt — Senior Fullstack Developer ### Component decomposition The issue describes `ConversationFilterBar.svelte` as 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 button - `FilterStripRow2.svelte` — date range, count, sort toggle - `CorrespondentSuggestionsDropdown.svelte` — the pre-populated dropdown Similarly, the hint bar in §8 (`{#if senderId && !receiverId}`) lives inline in `+page.svelte`. That's a distinct visual region — make it `SinglePersonHintBar.svelte`. The page should be an orchestrator, not a mix of layout and conditional markup. ### O(n²) in `yearDocCount` The `yearDocCount(year)` helper is called inside `{#each enrichedDocuments}` — once per row, not once per year band. It iterates the full `documents` array each time. On a log with 200 entries across 10 years this runs 200 × n iterations. Precompute it as a `$derived` Map: ```typescript const countsByYear = $derived( documents.reduce((acc, d) => { if (d.documentDate) { const y = new Date(d.documentDate).getFullYear(); acc.set(y, (acc.get(y) ?? 0) + 1); } return acc; }, new Map<number, number>()) ); ``` ### `statusDotClass` type safety The function signature is `(status: string)` but `DocumentStatus` is a typed enum on the generated API types. Use it: ```typescript function statusDotClass(status: components['schemas']['DocumentStatus']): string { ... } ``` This catches invalid status values at compile time instead of falling through to `'bg-gray-300'` silently. ### `otherPartyName` returns 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. ### `localStorage` read in `onMount` is a side effect pattern The issue says to read `korrespondenz_recent_persons` in `CorrespondenzEmptyState` on mount. In Svelte 5, `onMount` is fine for this specific case (SSR doesn't have `localStorage`), but the result should be `$state`, not derived inside `onMount` with a manual assignment. Also: guard against `JSON.parse` throwing on corrupt data. ```typescript // in CorrespondenzEmptyState.svelte let recentPersons = $state<RecentPerson[]>([]); onMount(() => { try { recentPersons = JSON.parse(localStorage.getItem('korrespondenz_recent_persons') ?? '[]'); } catch { recentPersons = []; } }); ``` ### Questions - Who owns `senderName` / `receiverName` in `ConversationTimeline`? The issue says pass them via `data.initialValues` — but `initialValues` is only populated when the person fetch succeeds. What renders if the person fetch fails (e.g. person was deleted)? - The `receiverId` prop on `ConversationTimeline` is described as `string` (may be `''`) — but the existing type says `string` required. Should this be `string | undefined` to be explicit?
Author
Owner

🏗️ 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/conversation to accept an optional receiverId. When omitted, the query should return all documents where the given person is sender or receiver. This is the semantically correct query — not just senderId = which misses documents where the person received a letter. The service layer change is small; the endpoint contract change is additive (backward-compatible since receiverId was previously required but adding optional support doesn't break existing callers).

The senderId-only query misses incoming letters

The issue's otherPartyName helper handles the "incoming" case, implying the API will return documents where sender.id != senderId. But GET /api/documents?senderId=... returns only documents by sender. The single-person query needs to be "all documents involving person X" — which is sender.id = X OR receivers contains X. Flag this explicitly so the backend implementation doesn't silently return half the data.

localStorage for recent persons causes an SSR hydration flash

SvelteKit renders CorrespondenzEmptyState on the server without localStorage. 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:

  1. Store recent persons in a cookie (readable server-side in the load function) — cleanest
  2. Accept the flash and suppress it with {#if mounted} — simplest but noticeable

A 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.ts at /conversations that throws redirect(301, '/korrespondenz') works, but the redirect fires after SvelteKit has parsed the route, loaded the layout, and run hooks. A cleaner approach is a handle hook in src/hooks.server.ts:

export const handle: Handle = async ({ event, resolve }) => {
    if (event.url.pathname.startsWith('/conversations')) {
        return new Response(null, {
            status: 301,
            headers: { Location: event.url.pathname.replace('/conversations', '/korrespondenz') + event.url.search }
        });
    }
    return resolve(event);
};

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-label and 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. ✓

## 🏗️ 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/conversation` to accept an optional `receiverId`. When omitted, the query should return all documents where the given person is sender **or** receiver. This is the semantically correct query — not just `senderId =` which misses documents where the person received a letter. The service layer change is small; the endpoint contract change is additive (backward-compatible since `receiverId` was previously required but adding optional support doesn't break existing callers). ### The `senderId`-only query misses incoming letters The issue's `otherPartyName` helper handles the "incoming" case, implying the API will return documents where `sender.id != senderId`. But `GET /api/documents?senderId=...` returns only documents by sender. The single-person query needs to be "all documents involving person X" — which is `sender.id = X OR receivers contains X`. Flag this explicitly so the backend implementation doesn't silently return half the data. ### `localStorage` for recent persons causes an SSR hydration flash SvelteKit renders `CorrespondenzEmptyState` on the server without `localStorage`. 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: 1. Store recent persons in a **cookie** (readable server-side in the load function) — cleanest 2. Accept the flash and suppress it with `{#if mounted}` — simplest but noticeable A 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.ts` at `/conversations` that throws `redirect(301, '/korrespondenz')` works, but the redirect fires after SvelteKit has parsed the route, loaded the layout, and run hooks. A cleaner approach is a `handle` hook in `src/hooks.server.ts`: ```typescript export const handle: Handle = async ({ event, resolve }) => { if (event.url.pathname.startsWith('/conversations')) { return new Response(null, { status: 301, headers: { Location: event.url.pathname.replace('/conversations', '/korrespondenz') + event.url.search } }); } return resolve(event); }; ``` 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-label` and 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. ✓
Author
Owner

🧪 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.ts changes are the highest-value test target here. The load function is plain TypeScript — import and call it directly with a mocked fetch:

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 for dir, from, to)
  • should return canWrite true when user has WRITE_ALL permission (if canWrite is 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:

  • Asymmetry bar at 0%/100%: when outCount === documents.length, the teal segment has width: 0%. Does overflow-hidden + flex break the border-radius? Needs a visual test.
  • receiverId empty string vs. undefined: {#if !receiverId} evaluates true for both, but the URL param handling in +page.server.ts treats them differently. Test that the component behaves identically for both.
  • localStorage with corrupt data: JSON.parse('not json') throws. The try/catch proposed by Felix should be a tested behaviour — render CorrespondenzEmptyState with a corrupt korrespondenz_recent_persons key and assert no chips render (not an error).
  • Suggestions dropdown keyboard navigation: Escape closes it and returns focus to the input. This is in the AC but has no corresponding test entry.
  • Sort direction persists after person swap: swapping persons calls applyFilters() — does sortDir survive? Add a test.

data-testid="conv-strip-count" is missing

The 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 add data-testid="conv-strip-count" to the count element.

E2E coverage is not mentioned

The acceptance criteria include /proofshot but no Playwright journey. The minimum E2E path I'd want:

  1. Land on /korrespondenz → empty state visible
  2. Click a recent chip (or type in search) → strip Person A fills → log appears
  3. Set date range → count updates → sort toggles → log reorders
  4. Click a log row → navigate to /documents/{id}

This covers the happy path end-to-end and should be added to the existing page.svelte.spec.ts as a Playwright test alongside the Vitest component tests, or in a dedicated korrespondenz.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.

## 🧪 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.ts` changes are the highest-value test target here. The load function is plain TypeScript — import and call it directly with a mocked `fetch`: 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 for `dir`, `from`, `to`) - `should return canWrite true when user has WRITE_ALL permission` (if `canWrite` is 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: - **Asymmetry bar at 0%/100%**: when `outCount === documents.length`, the teal segment has `width: 0%`. Does `overflow-hidden` + `flex` break the border-radius? Needs a visual test. - **`receiverId` empty string vs. `undefined`**: `{#if !receiverId}` evaluates true for both, but the URL param handling in `+page.server.ts` treats them differently. Test that the component behaves identically for both. - **`localStorage` with corrupt data**: `JSON.parse('not json')` throws. The `try/catch` proposed by Felix should be a tested behaviour — render `CorrespondenzEmptyState` with a corrupt `korrespondenz_recent_persons` key and assert no chips render (not an error). - **Suggestions dropdown keyboard navigation**: Escape closes it and returns focus to the input. This is in the AC but has no corresponding test entry. - **Sort direction persists after person swap**: swapping persons calls `applyFilters()` — does `sortDir` survive? Add a test. ### `data-testid="conv-strip-count"` is missing The 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 add `data-testid="conv-strip-count"` to the count element. ### E2E coverage is not mentioned The acceptance criteria include `/proofshot` but no Playwright journey. The minimum E2E path I'd want: 1. Land on `/korrespondenz` → empty state visible 2. Click a recent chip (or type in search) → strip Person A fills → log appears 3. Set date range → count updates → sort toggles → log reorders 4. Click a log row → navigate to `/documents/{id}` This covers the happy path end-to-end and should be added to the existing `page.svelte.spec.ts` as a Playwright test alongside the Vitest component tests, or in a dedicated `korrespondenz.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.
Author
Owner

🔒 Nora "NullX" Steiner — Application Security Engineer

Authorization gap on senderId / receiverId params — Medium risk

The URL params senderId and receiverId are 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=X with 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 (and receiverId) must be validated against the caller's permissions before the query runs. If READ_ALL is the gate, document that. Don't leave this to the implementer to infer.

localStorage chip data is XSS-safe but deserves a note

The korrespondenz_recent_persons store 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}.

href="/documents/new?senderId={senderId}{receiverId ? '&receiverId=' + receiverId : ''}"

senderId and receiverId are backend-resolved UUIDs so injection is unlikely, but this pattern is unsafe by construction — it bypasses URLSearchParams encoding. A crafted UUID abc&role=admin would inject an extra param. Use:

const newDocUrl = $derived(() => {
    const params = new URLSearchParams({ senderId });
    if (receiverId) params.set('receiverId', receiverId);
    return `/documents/new?${params}`;
});

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/conversation endpoint currently log the senderId and receiverId values? If so, ensure those are logged as opaque UUIDs, not resolved to person names, to avoid leaking PII into application logs.

## 🔒 Nora "NullX" Steiner — Application Security Engineer ### Authorization gap on `senderId` / `receiverId` params — Medium risk The URL params `senderId` and `receiverId` are 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=X` with 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` (and `receiverId`) must be validated against the caller's permissions before the query runs. If `READ_ALL` is the gate, document that. Don't leave this to the implementer to infer. ### `localStorage` chip data is XSS-safe but deserves a note The `korrespondenz_recent_persons` store 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 ```svelte href="/documents/new?senderId={senderId}{receiverId ? '&receiverId=' + receiverId : ''}" ``` `senderId` and `receiverId` are backend-resolved UUIDs so injection is unlikely, but this pattern is unsafe by construction — it bypasses `URLSearchParams` encoding. A crafted UUID `abc&role=admin` would inject an extra param. Use: ```typescript const newDocUrl = $derived(() => { const params = new URLSearchParams({ senderId }); if (receiverId) params.set('receiverId', receiverId); return `/documents/new?${params}`; }); ``` ### 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/conversation` endpoint currently log the `senderId` and `receiverId` values? If so, ensure those are logged as opaque UUIDs, not resolved to person names, to avoid leaking PII into application logs.
Author
Owner

🎨 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:

Element Spec value Problem
Log card title text-[9px] Below 12px minimum — fails WCAG SC 1.4.4
Log card meta text-[7.5px] Well below 12px — unreadable for 60+ users
Hint bar text text-[8px] Below 12px minimum
Row 2 labels font-size: 8px Below 12px minimum
Count in Row 2 font-size: 8px Below 12px minimum
Sort button font-size: 8px Below 12px minimum
Year band count text-[7.5px] Below 12px minimum

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:

  • Log card title: text-sm (14px)
  • Log card meta: text-xs (12px)
  • Row 2 controls: text-xs (12px)
  • Year band: title text-lg (18px), count text-xs (12px)
  • Hint bar: text-xs (12px)

High: Row 2 date inputs at 22px height fail touch target requirements

height: 22px on 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:

class="h-[22px] sm:h-[22px] ... min-h-[44px] sm:min-h-0"

Or: keep 22px visual height but wrap in a <label> with 44px invisible tap area using padding.

High: opacity-40 on Row 2 makes already-small text invisible

opacity-40 on text-xs gray text (#AAA) on a #F7F5F2 background: 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 use opacity-50 at 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 to text-xs minimum, 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 an aria-live region:

<div aria-live="polite" class="sr-only">
    {#if focusMovedToSearch}Suche aktiviert — Person eingeben{/if}
</div>

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 history comment so it's on the radar.

Questions

  • What is the spec's intent for text-[9px] log titles — are those wireframe sizes or production sizes? I need confirmation before implementation starts that these will be scaled up.
  • The sort button ("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 with aria-hidden="true" and a screen-reader text alternative is the right approach.
## 🎨 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: | Element | Spec value | Problem | |---|---|---| | Log card title | `text-[9px]` | Below 12px minimum — fails WCAG SC 1.4.4 | | Log card meta | `text-[7.5px]` | Well below 12px — unreadable for 60+ users | | Hint bar text | `text-[8px]` | Below 12px minimum | | Row 2 labels | `font-size: 8px` | Below 12px minimum | | Count in Row 2 | `font-size: 8px` | Below 12px minimum | | Sort button | `font-size: 8px` | Below 12px minimum | | Year band count | `text-[7.5px]` | Below 12px minimum | 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: - Log card title: `text-sm` (14px) - Log card meta: `text-xs` (12px) - Row 2 controls: `text-xs` (12px) - Year band: title `text-lg` (18px), count `text-xs` (12px) - Hint bar: `text-xs` (12px) ### High: Row 2 date inputs at 22px height fail touch target requirements `height: 22px` on 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: ```svelte class="h-[22px] sm:h-[22px] ... min-h-[44px] sm:min-h-0" ``` Or: keep `22px` visual height but wrap in a `<label>` with `44px` invisible tap area using padding. ### High: `opacity-40` on Row 2 makes already-small text invisible `opacity-40` on `text-xs` gray text (`#AAA`) on a `#F7F5F2` background: 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 use `opacity-50` at 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 to `text-xs` minimum, 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 an `aria-live` region: ```svelte <div aria-live="polite" class="sr-only"> {#if focusMovedToSearch}Suche aktiviert — Person eingeben{/if} </div> ``` ### 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 history` comment so it's on the radar. ### Questions - What is the spec's intent for `text-[9px]` log titles — are those wireframe sizes or production sizes? I need confirmation before implementation starts that these will be scaled up. - The sort button (`"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 with `aria-hidden="true"` and a screen-reader text alternative is the right approach.
Author
Owner

⚙️ Tobias Wendt — DevOps & Platform Engineer

Route rename has no infrastructure impact — but the redirect does

The SvelteKit route move from conversations/ to korrespondenz/ 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.ts is 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:

redir /conversations/* /korrespondenz/{path} 301

This is belt-and-suspenders — not required for this issue, but worth noting for the production deployment runbook.

localStorage is SSR-safe as-is, but watch for adapter-static

localStorage is client-only. In the current adapter-node setup, SSR runs on the server and won't call localStorageonMount guards this correctly. No change needed.

However: if anyone ever runs vite build with adapter-static (for a preview or static export), the onMount guard 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 new sender_id = ? OR receivers contains ? query may trigger a sequential scan on large archives.

Check with EXPLAIN ANALYZE before merging the backend change. If needed, a partial index or a separate document_persons join 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 .env changes, 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 hardcode conversations/ 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.requests Prometheus metrics automatically (if actuator/prometheus is configured). No instrumentation needed on our end.

## ⚙️ Tobias Wendt — DevOps & Platform Engineer ### Route rename has no infrastructure impact — but the redirect does The SvelteKit route move from `conversations/` to `korrespondenz/` 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.ts` is 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: ```caddyfile redir /conversations/* /korrespondenz/{path} 301 ``` This is belt-and-suspenders — not required for this issue, but worth noting for the production deployment runbook. ### `localStorage` is SSR-safe as-is, but watch for `adapter-static` `localStorage` is client-only. In the current `adapter-node` setup, SSR runs on the server and won't call `localStorage` — `onMount` guards this correctly. No change needed. However: if anyone ever runs `vite build` with `adapter-static` (for a preview or static export), the `onMount` guard 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 new `sender_id = ? OR receivers contains ?` query may trigger a sequential scan on large archives. Check with `EXPLAIN ANALYZE` before merging the backend change. If needed, a partial index or a separate `document_persons` join 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 `.env` changes, 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 hardcode `conversations/` 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.requests` Prometheus metrics automatically (if actuator/prometheus is configured). No instrumentation needed on our end.
Author
Owner

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/conversation to support optional receiverId

  • receiverId becomes an optional query param
  • When absent: return all documents where sender_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 inference
  • Run EXPLAIN ANALYZE on the new OR-query before merging; add an index if a sequential scan is detected
  • The endpoint change is additive and backward-compatible — existing callers with receiverId are unaffected

0b. Fix PersonTypeahead empty/whitespace query — security bug

The 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 in ConversationTimeline, any goto('/conversations') calls.


Component decomposition (§5 + §8)

ConversationFilterBar.svelte is replaced by three focused components, each owning one visual region:

New component Responsibility
CorrespondenzPersonBar.svelte Row 1: Person A field, swap button, Korrespondent field
CorrespondenzFilterControls.svelte Row 2: date range inputs, live count, sort toggle
CorrespondentSuggestionsDropdown.svelte Suggestions panel below Korrespondent field

The hint bar (§8) becomes SinglePersonHintBar.svelte. +page.svelte is 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 senderId is set, CorrespondentSuggestionsDropdown makes an explicit API call to GET /api/persons?restrictToCorrespondentsOf={senderId}. This is intentional, scoped, and auditable — not a side effect of an empty search. PersonTypeahead gets a new prop suggestionsFor?: string; when set, it fires this fetch on focus instead of waiting for user input.


Recent persons (§7)

Using onMount + $state with a try/catch guard around JSON.parse:

let recentPersons = $state<RecentPerson[]>([]);

onMount(() => {
    try {
        recentPersons = JSON.parse(localStorage.getItem('korrespondenz_recent_persons') ?? '[]');
    } catch {
        recentPersons = [];
    }
});

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:

  • Log card title: text-sm (14px)
  • Log card meta, Row 2 controls, hint bar, year band count: text-xs (12px)
  • Year band year label: text-base (16px)

Addressing reviewer points

Felix Brandt:

  • Component decomposition: adopted (see above)
  • yearDocCount O(n²): fix to $derived Map — adopted
  • statusDotClass: type against components['schemas']['DocumentStatus'] — adopted
  • otherPartyName em-dash: move to i18n key conv_no_party — adopted
  • localStorage pattern: adopted with $state + try/catch as shown above
  • receiverId prop type: change to string | undefined (not empty string) for explicit semantics — adopted

Markus Keller:

  • Backend first: Phase 0 added
  • Sender-only query missing incoming letters: addressed in Phase 0a spec
  • Redirect in hook: not needed (no production bookmarks)
  • Asymmetry bar aria-label: scope to current filter period — "Briefverteilung in diesem Zeitraum: {outCount} von {senderName}, {inCount} von {receiverName}" — adopted

Sara Holt:

  • Load function tests: add to test plan — +page.server.ts is plain TypeScript, test directly with mocked fetch
  • data-testid="conv-strip-count": add to CorrespondenzFilterControls count element — adopted
  • E2E (Playwright): add 4-step happy-path journey in korrespondenz.spec.ts
  • 301 redirect test: moot — no redirect

Nora Steiner:

  • Authorization on single-person path: explicitly covered in Phase 0a
  • localStorage XSS: Svelte auto-escapes; add a code comment warning against future {@html} use — adopted
  • URL construction for new-doc link: replace string interpolation with URLSearchParams — adopted
  • Suggestions dropdown enumeration: accepted risk for a family archive with uniform trust; add a code comment for future granular permission work

Leonie Voss:

  • Font sizes: adopted (see above)
  • Row 2 date inputs height: 22px: add min-h-[44px] sm:min-h-0 for mobile touch targets — adopted
  • opacity-40 on Row 2: accepted for now on desktop; review contrast on mobile — follow-up issue if needed
  • Color-only direction cue: arrow symbol kept; increase to text-xs minimum — adopted
  • Focus teleport from empty state to strip: add aria-live="polite" announcement — adopted
  • Recent chips clear affordance: add // TODO: allow clearing recent history comment — adopted
  • Sort button arrows: use <svg> with aria-hidden="true" + screen-reader text alternative, not Unicode arrows — adopted

Tobias Wendt:

  • No Caddy rewrite needed (no redirect)
  • localStorage SSR safety: onMount guard is correct for adapter-node
  • Index check: covered in Phase 0a
  • CI pipeline: verify no hardcoded conversations/ paths in Gitea Actions workflow files
## 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/conversation` to support optional `receiverId`** - `receiverId` becomes an optional query param - When absent: return all documents where `sender_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 inference - Run `EXPLAIN ANALYZE` on the new OR-query before merging; add an index if a sequential scan is detected - The endpoint change is additive and backward-compatible — existing callers with `receiverId` are unaffected **0b. Fix `PersonTypeahead` empty/whitespace query — security bug** The 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 in `ConversationTimeline`, any `goto('/conversations')` calls. --- ### Component decomposition (§5 + §8) `ConversationFilterBar.svelte` is replaced by three focused components, each owning one visual region: | New component | Responsibility | |---|---| | `CorrespondenzPersonBar.svelte` | Row 1: Person A field, swap button, Korrespondent field | | `CorrespondenzFilterControls.svelte` | Row 2: date range inputs, live count, sort toggle | | `CorrespondentSuggestionsDropdown.svelte` | Suggestions panel below Korrespondent field | The hint bar (§8) becomes `SinglePersonHintBar.svelte`. `+page.svelte` is 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** `senderId` is set, `CorrespondentSuggestionsDropdown` makes an **explicit API call** to `GET /api/persons?restrictToCorrespondentsOf={senderId}`. This is intentional, scoped, and auditable — not a side effect of an empty search. `PersonTypeahead` gets a new prop `suggestionsFor?: string`; when set, it fires this fetch on focus instead of waiting for user input. --- ### Recent persons (§7) Using `onMount` + `$state` with a `try/catch` guard around `JSON.parse`: ```typescript let recentPersons = $state<RecentPerson[]>([]); onMount(() => { try { recentPersons = JSON.parse(localStorage.getItem('korrespondenz_recent_persons') ?? '[]'); } catch { recentPersons = []; } }); ``` 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: - Log card title: `text-sm` (14px) - Log card meta, Row 2 controls, hint bar, year band count: `text-xs` (12px) - Year band year label: `text-base` (16px) --- ### Addressing reviewer points **Felix Brandt:** - Component decomposition: adopted (see above) - `yearDocCount` O(n²): fix to `$derived` Map — adopted - `statusDotClass`: type against `components['schemas']['DocumentStatus']` — adopted - `otherPartyName` em-dash: move to i18n key `conv_no_party` — adopted - `localStorage` pattern: adopted with `$state` + `try/catch` as shown above - `receiverId` prop type: change to `string | undefined` (not empty string) for explicit semantics — adopted **Markus Keller:** - Backend first: Phase 0 added - Sender-only query missing incoming letters: addressed in Phase 0a spec - Redirect in hook: not needed (no production bookmarks) - Asymmetry bar `aria-label`: scope to current filter period — `"Briefverteilung in diesem Zeitraum: {outCount} von {senderName}, {inCount} von {receiverName}"` — adopted **Sara Holt:** - Load function tests: add to test plan — `+page.server.ts` is plain TypeScript, test directly with mocked `fetch` - `data-testid="conv-strip-count"`: add to `CorrespondenzFilterControls` count element — adopted - E2E (Playwright): add 4-step happy-path journey in `korrespondenz.spec.ts` - 301 redirect test: moot — no redirect **Nora Steiner:** - Authorization on single-person path: explicitly covered in Phase 0a - `localStorage` XSS: Svelte auto-escapes; add a code comment warning against future `{@html}` use — adopted - URL construction for new-doc link: replace string interpolation with `URLSearchParams` — adopted - Suggestions dropdown enumeration: accepted risk for a family archive with uniform trust; add a code comment for future granular permission work **Leonie Voss:** - Font sizes: adopted (see above) - Row 2 date inputs `height: 22px`: add `min-h-[44px] sm:min-h-0` for mobile touch targets — adopted - `opacity-40` on Row 2: accepted for now on desktop; review contrast on mobile — follow-up issue if needed - Color-only direction cue: arrow symbol kept; increase to `text-xs` minimum — adopted - Focus teleport from empty state to strip: add `aria-live="polite"` announcement — adopted - Recent chips clear affordance: add `// TODO: allow clearing recent history` comment — adopted - Sort button arrows: use `<svg>` with `aria-hidden="true"` + screen-reader text alternative, not Unicode arrows — adopted **Tobias Wendt:** - No Caddy rewrite needed (no redirect) - `localStorage` SSR safety: `onMount` guard is correct for `adapter-node` - Index check: covered in Phase 0a - CI pipeline: verify no hardcoded `conversations/` paths in Gitea Actions workflow files
Author
Owner

Implementation complete

All 9 tasks implemented on branch feat/issue-162-korrespondenz-redesign.

Commits

# Commit Description
1 f88371e feat(backend): extend conversation endpoint for optional receiverId
2 252881b fix(backend): reject whitespace-only person search queries
3 f352058 feat(frontend): rename route to /korrespondenz, update i18n, regen API types
4 e942699 feat(frontend): single-person mode in +page.server.ts load function
5 48286b9 feat(frontend): new strip components, suggestions dropdown, empty state
6 3addc72 feat(korrespondenz): redesign ConversationTimeline to correspondence log cards
7 4f5f825 feat(korrespondenz): wire up +page.svelte orchestrator with new components
8 1b95d94 test(korrespondenz): update and expand Vitest component specs (24 passing)
9 49f6b0a test(korrespondenz): add Playwright E2E happy-path journey

What was implemented

Backend

  • DocumentRepository.findSinglePersonCorrespondence — new JPQL query returning all docs where a person is sender OR receiver
  • DocumentService.getConversationFiltered — branches on null receiverId to use the single-person query
  • DocumentControllerreceiverId is now @RequestParam(required = false)
  • PersonService.findAll — blank/whitespace query now returns empty list instead of all persons

Frontend

  • Route /conversations/korrespondenz (server route + nav + all internal links)
  • Compact 2-row filter strip: CorrespondenzPersonBar (Person A + swap + Korrespondent) and CorrespondenzFilterControls (date range + letter count + sort toggle)
  • SinglePersonHintBar — amber bar shown in single-person mode
  • CorrespondentSuggestionsDropdown — keyboard-navigable listbox fetching from /api/persons/{id}/correspondents
  • CorrespondenzEmptyState — icon + heading + search button + recent person chips from localStorage
  • ConversationTimeline rewritten — 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 to korrespondenz_recent_persons in localStorage

i18n

  • New keys added to messages/de.json, en.json, es.json
  • src/lib/messages-extra.ts shim for keys not yet in root-owned paraglide compiled files

Tests

  • 24 Vitest component tests (all green)
  • 6 Playwright E2E scenarios (bilateral tests skip gracefully if no shared data)

Next step

Open a PR from feat/issue-162-korrespondenz-redesignmain.

## Implementation complete ✅ All 9 tasks implemented on branch `feat/issue-162-korrespondenz-redesign`. ### Commits | # | Commit | Description | |---|--------|-------------| | 1 | `f88371e` | feat(backend): extend conversation endpoint for optional receiverId | | 2 | `252881b` | fix(backend): reject whitespace-only person search queries | | 3 | `f352058` | feat(frontend): rename route to /korrespondenz, update i18n, regen API types | | 4 | `e942699` | feat(frontend): single-person mode in +page.server.ts load function | | 5 | `48286b9` | feat(frontend): new strip components, suggestions dropdown, empty state | | 6 | `3addc72` | feat(korrespondenz): redesign ConversationTimeline to correspondence log cards | | 7 | `4f5f825` | feat(korrespondenz): wire up +page.svelte orchestrator with new components | | 8 | `1b95d94` | test(korrespondenz): update and expand Vitest component specs (24 passing) | | 9 | `49f6b0a` | test(korrespondenz): add Playwright E2E happy-path journey | ### What was implemented **Backend** - `DocumentRepository.findSinglePersonCorrespondence` — new JPQL query returning all docs where a person is sender OR receiver - `DocumentService.getConversationFiltered` — branches on null receiverId to use the single-person query - `DocumentController` — `receiverId` is now `@RequestParam(required = false)` - `PersonService.findAll` — blank/whitespace query now returns empty list instead of all persons **Frontend** - Route `/conversations` → `/korrespondenz` (server route + nav + all internal links) - Compact 2-row filter strip: `CorrespondenzPersonBar` (Person A + swap + Korrespondent) and `CorrespondenzFilterControls` (date range + letter count + sort toggle) - `SinglePersonHintBar` — amber bar shown in single-person mode - `CorrespondentSuggestionsDropdown` — keyboard-navigable listbox fetching from `/api/persons/{id}/correspondents` - `CorrespondenzEmptyState` — icon + heading + search button + recent person chips from localStorage - `ConversationTimeline` rewritten — 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 to `korrespondenz_recent_persons` in localStorage **i18n** - New keys added to `messages/de.json`, `en.json`, `es.json` - `src/lib/messages-extra.ts` shim for keys not yet in root-owned paraglide compiled files **Tests** - 24 Vitest component tests (all green) - 6 Playwright E2E scenarios (bilateral tests skip gracefully if no shared data) ### Next step Open a PR from `feat/issue-162-korrespondenz-redesign` → `main`.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#162