feat(briefwechsel): discovery landing grid + gap markers #338

Closed
opened 2026-04-26 18:39:29 +02:00 by marcel · 0 comments
Owner

Problem

The Briefwechsel page is perceived as a constrained version of document search — "search filtered to sender/receiver". It lacks a distinct identity as a correspondence reader. Two changes address this:

  1. Landing grid — when no persons are selected, show the archive's richest correspondences as a browsable grid. Entry point becomes discovery, not goal-directed search.
  2. Gap markers — show silences between letters in the timeline. A search page can only show documents that exist; a correspondence page can show what's missing. This is the key differentiator.

Feature A — Discovery Landing Grid

Behaviour

When /briefwechsel loads with no senderId in the URL, the page shows a discovery grid of the archive's most prolific correspondences, sorted by letter count descending.

The person selectors remain visible above the grid at all times — goal-directed users can type directly without touching the grid.

On card click:

  1. Both person selectors pre-fill with the card's sender and receiver.
  2. The grid fades out.
  3. The correspondence timeline renders in place (no navigation).
  4. URL updates to /briefwechsel?senderId=…&receiverId=… — shareable and bookmarkable.

Grid visibility rule: The grid is shown only when both selectors are empty (no senderId in URL). Clearing one selector while the other still has a value falls through to the existing single-person filter behaviour — the grid does not reappear in that case. Clearing both selectors restores the grid.

Card Design

Each card:

  • Background: bg-surface (#E4E2D7), border: border border-line
  • Two overlapping person-initial circles:
    • Sender: bg-primary (#002850), white text
    • Receiver: bg-accent (#A6DAD8), text-primary text
    • Size: 44px diameter (WCAG 2.2 touch target minimum)
    • Overlap: receiver offset −14px left, both with border-2 border-surface to visually separate
  • Person names: font-serif font-bold text-sm text-primary — e.g. "Käthe · Wilhelm"
  • Metadata: font-sans text-xs text-ink-3 — e.g. "34 Briefe · 1912–1938"

Grid layout:

  • Desktop (≥768px): 2-column, gap-4
  • Mobile (<768px): 1-column

Section label above grid: text-xs font-bold uppercase tracking-widest text-ink-3 — "Die aktivsten Korrespondenzen"

Backend — New Endpoint

GET /api/documents/top-correspondences

Returns the top N person-pairs ranked by total letter count (both directions combined: Käthe→Wilhelm + Wilhelm→Käthe = one pair, one combined count).

Response (array):

[
  {
    "senderId": "uuid",
    "senderName": "Käthe Raddatz",
    "receiverId": "uuid",
    "receiverName": "Wilhelm Müller",
    "letterCount": 34,
    "firstYear": 1912,
    "lastYear": 1938
  }
]

Query parameter: limit (default 10, max 20).

Pair canonicalisation (to merge both directions into one): developer's choice at implementation time — lower UUID first or alphabetical by last name both work; pick one and document it (see OQ-01 below).

The senderName / receiverName in the response should be the person with the higher outgoing count of the pair (so the card reads naturally: the more prolific writer on the left).


Feature B — Gap Markers

Behaviour

A gap marker is injected between two consecutive letter rows in the timeline when the gap between their dates is ≥ MIN_GAP_YEARS.

// ConversationTimeline — easy to tune without touching logic
private static final int MIN_GAP_YEARS = 1;

(Frontend equivalent if computed client-side: const MIN_GAP_YEARS = 1;)

The marker shows the duration in whole years, rounded down, with correct German plural:

  • 1 year → "1 Jahr ohne Brief"
  • 3 years → "3 Jahre ohne Brief"

Since letters are already grouped by year dividers, a gap marker only appears between entries in different years with a gap ≥ MIN_GAP_YEARS. It never appears between two letters in the same year.

Calculation (frontend, no backend change needed)

for each consecutive pair (docA, docB) in the sorted documents array:
  gap = year(docB.documentDate) - year(docA.documentDate)
  if gap >= MIN_GAP_YEARS → render gap marker between rows

Visual Design

[letter row — 15. März 1912]

  ─ ─ ─  3 Jahre ohne Brief  ─ ─ ─

[letter row — 3. Juli 1915]

Tailwind classes:

  • Container: flex items-center gap-3 px-4 py-3 text-ink-3
  • Decorative lines: flex-1 border-t border-dashed border-line
  • Label: font-sans text-xs font-bold uppercase tracking-widest text-ink-3 whitespace-nowrap

Intentionally quiet — the marker is a breath in the narrative, not an error state.


Acceptance Criteria

Feature A

Given /briefwechsel loads with no senderId in the URL
Then I see a 2-column grid of correspondence cards (1-column on mobile)
And cards are sorted by letterCount descending
And each card shows overlapping navy/mint initials, both names, letter count, and year range

Given I click a correspondence card
Then both person selectors fill with that card's sender and receiver
And the grid fades out and the correspondence timeline renders in place
And the URL updates to include senderId and receiverId

Given both selectors are filled and I clear one
Then the single-person filter behaviour runs (existing)
And the grid does not reappear

Given both selectors are filled and I clear both
Then the grid reappears

Given I type directly into a person selector without touching the grid
Then the existing search flow runs unchanged

Feature B

Given a correspondence timeline is rendered
And two consecutive letters are more than MIN_GAP_YEARS apart
Then a gap marker appears between those letter rows
And the label reads "X Jahr(e) ohne Brief" with the correct German plural

Given two consecutive letters are in the same year
Then no gap marker appears between them regardless of month difference

Given MIN_GAP_YEARS is changed in the source constant
Then all gap calculations update without any other code change

Open Questions

ID Question Notes
OQ-01 Canonical pair ordering for top-correspondences: lower UUID first or alphabetical? Developer decision at implementation time — just pick one consistently
OQ-02 Should gap markers eventually include historical era context (WWI 1914–18, WWII 1939–45)? Technically trivial for global events, but labelling a gap "WWI" without family evidence is presumptuous. Park for a follow-up issue.
OQ-03 Should users be able to annotate gaps with personal context (business trip, illness)? Meaningful but needs a "period" data model. Separate issue.

Future Direction

Gap markers are step one of a larger idea: Briefwechsel should contain more information than a document search with sender/receiver filters, not less. A correspondence has a shape — an arc, silences, a beginning and an end — that a flat search result list structurally cannot express. Gap markers make the silences visible. Contextual annotations (OQ-02, OQ-03) would make them meaningful. This is the direction worth pursuing.

## Problem The Briefwechsel page is perceived as a constrained version of document search — "search filtered to sender/receiver". It lacks a distinct identity as a correspondence reader. Two changes address this: 1. **Landing grid** — when no persons are selected, show the archive's richest correspondences as a browsable grid. Entry point becomes discovery, not goal-directed search. 2. **Gap markers** — show silences between letters in the timeline. A search page can only show documents that *exist*; a correspondence page can show what's *missing*. This is the key differentiator. --- ## Feature A — Discovery Landing Grid ### Behaviour When `/briefwechsel` loads with no `senderId` in the URL, the page shows a discovery grid of the archive's most prolific correspondences, sorted by letter count descending. The person selectors remain visible above the grid at all times — goal-directed users can type directly without touching the grid. **On card click:** 1. Both person selectors pre-fill with the card's sender and receiver. 2. The grid fades out. 3. The correspondence timeline renders in place (no navigation). 4. URL updates to `/briefwechsel?senderId=…&receiverId=…` — shareable and bookmarkable. **Grid visibility rule:** The grid is shown only when both selectors are empty (no `senderId` in URL). Clearing one selector while the other still has a value falls through to the existing single-person filter behaviour — the grid does not reappear in that case. Clearing both selectors restores the grid. ### Card Design Each card: - Background: `bg-surface` (`#E4E2D7`), border: `border border-line` - **Two overlapping person-initial circles:** - Sender: `bg-primary` (`#002850`), white text - Receiver: `bg-accent` (`#A6DAD8`), `text-primary` text - Size: 44px diameter (WCAG 2.2 touch target minimum) - Overlap: receiver offset −14px left, both with `border-2 border-surface` to visually separate - Person names: `font-serif font-bold text-sm text-primary` — e.g. "Käthe · Wilhelm" - Metadata: `font-sans text-xs text-ink-3` — e.g. "34 Briefe · 1912–1938" Grid layout: - Desktop (≥768px): 2-column, `gap-4` - Mobile (<768px): 1-column Section label above grid: `text-xs font-bold uppercase tracking-widest text-ink-3` — "Die aktivsten Korrespondenzen" ### Backend — New Endpoint **`GET /api/documents/top-correspondences`** Returns the top N person-pairs ranked by total letter count (both directions combined: Käthe→Wilhelm + Wilhelm→Käthe = one pair, one combined count). Response (array): ```json [ { "senderId": "uuid", "senderName": "Käthe Raddatz", "receiverId": "uuid", "receiverName": "Wilhelm Müller", "letterCount": 34, "firstYear": 1912, "lastYear": 1938 } ] ``` Query parameter: `limit` (default 10, max 20). Pair canonicalisation (to merge both directions into one): developer's choice at implementation time — lower UUID first or alphabetical by last name both work; pick one and document it (see OQ-01 below). The `senderName` / `receiverName` in the response should be the person with the higher outgoing count of the pair (so the card reads naturally: the more prolific writer on the left). --- ## Feature B — Gap Markers ### Behaviour A gap marker is injected between two consecutive letter rows in the timeline when the gap between their dates is **≥ MIN_GAP_YEARS**. ```java // ConversationTimeline — easy to tune without touching logic private static final int MIN_GAP_YEARS = 1; ``` (Frontend equivalent if computed client-side: `const MIN_GAP_YEARS = 1;`) The marker shows the duration in whole years, rounded down, with correct German plural: - 1 year → "1 Jahr ohne Brief" - 3 years → "3 Jahre ohne Brief" Since letters are already grouped by year dividers, a gap marker only appears between entries in *different* years with a gap ≥ MIN_GAP_YEARS. It never appears between two letters in the same year. ### Calculation (frontend, no backend change needed) ``` for each consecutive pair (docA, docB) in the sorted documents array: gap = year(docB.documentDate) - year(docA.documentDate) if gap >= MIN_GAP_YEARS → render gap marker between rows ``` ### Visual Design ``` [letter row — 15. März 1912] ─ ─ ─ 3 Jahre ohne Brief ─ ─ ─ [letter row — 3. Juli 1915] ``` Tailwind classes: - Container: `flex items-center gap-3 px-4 py-3 text-ink-3` - Decorative lines: `flex-1 border-t border-dashed border-line` - Label: `font-sans text-xs font-bold uppercase tracking-widest text-ink-3 whitespace-nowrap` Intentionally quiet — the marker is a breath in the narrative, not an error state. --- ## Acceptance Criteria ### Feature A ``` Given /briefwechsel loads with no senderId in the URL Then I see a 2-column grid of correspondence cards (1-column on mobile) And cards are sorted by letterCount descending And each card shows overlapping navy/mint initials, both names, letter count, and year range Given I click a correspondence card Then both person selectors fill with that card's sender and receiver And the grid fades out and the correspondence timeline renders in place And the URL updates to include senderId and receiverId Given both selectors are filled and I clear one Then the single-person filter behaviour runs (existing) And the grid does not reappear Given both selectors are filled and I clear both Then the grid reappears Given I type directly into a person selector without touching the grid Then the existing search flow runs unchanged ``` ### Feature B ``` Given a correspondence timeline is rendered And two consecutive letters are more than MIN_GAP_YEARS apart Then a gap marker appears between those letter rows And the label reads "X Jahr(e) ohne Brief" with the correct German plural Given two consecutive letters are in the same year Then no gap marker appears between them regardless of month difference Given MIN_GAP_YEARS is changed in the source constant Then all gap calculations update without any other code change ``` --- ## Open Questions | ID | Question | Notes | |----|----------|-------| | OQ-01 | Canonical pair ordering for top-correspondences: lower UUID first or alphabetical? | Developer decision at implementation time — just pick one consistently | | OQ-02 | Should gap markers eventually include historical era context (WWI 1914–18, WWII 1939–45)? | Technically trivial for global events, but labelling a gap "WWI" without family evidence is presumptuous. Park for a follow-up issue. | | OQ-03 | Should users be able to annotate gaps with personal context (business trip, illness)? | Meaningful but needs a "period" data model. Separate issue. | --- ## Future Direction Gap markers are step one of a larger idea: **Briefwechsel should contain more information than a document search with sender/receiver filters, not less.** A correspondence has a shape — an arc, silences, a beginning and an end — that a flat search result list structurally cannot express. Gap markers make the silences visible. Contextual annotations (OQ-02, OQ-03) would make them meaningful. This is the direction worth pursuing.
marcel added the P1-highconversationfeatureui labels 2026-04-26 18:39:34 +02:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#338