Final design for the dashboard block that extends /persons/[id]. Gives every person page a correspondence-at-a-glance view — stats, activity per year, direction split, top correspondents, top locations, tag cloud — and turns each element into a filter shortcut into /briefwechsel. Replaces the current CoCorrespondentsList block.
impl-ref tables for exact Tailwind class + pixel value. Close-ups in Section 03 render each dashboard block at ~100 % scale for pixel-accurate reference.
The page is a 35% / 65% split (existing lg:grid-cols-[35%_65%]). Left column keeps PersonCard and NameHistoryCard. Right column replaces CoCorrespondentsList with the new PersonDashboard block at the top, followed by the existing sent/received document lists.
bg-primary, others bg-accent/60. Hovering a bar shows "{year} · {count} Briefe" tooltip; clicking filters /briefwechsel?senderId=…&from=YYYY-01-01&to=YYYY-12-31./briefwechsel?senderId=<this>&receiverId=<other> (bilateral view). "Alle N Korrespondenten →" link below.xl > 100, l > 50, m > 20, muted ≤ 20. Click on any tag: /briefwechsel?senderId=<this>&tag=<id>.| Element | Classes | Real | Note |
|---|---|---|---|
| Page container | mx-auto max-w-6xl px-4 py-10 | max 72rem | Unchanged · existing route shell |
| 2-column grid | lg:grid lg:grid-cols-[35%_65%] lg:gap-8 | 32 px gap | Existing · unchanged |
| Left column stack | PersonCard → NameHistoryCard (mt-6) | 24 px mt | Existing · unchanged |
| Right column | PersonDashboard → PersonDocumentList(sent) → PersonDocumentList(received) | new + existing | PersonDashboard replaces CoCorrespondentsList |
| Dashboard container | overflow-hidden rounded-sm border border-line bg-surface shadow-sm | 1 px border | Matches card pattern from CLAUDE.md |
| Dashboard header | flex items-center justify-between gap-3 bg-primary text-primary-fg px-5 py-3 | 12 px y padding | Dark navy strip — sets dashboard apart from body card patterns |
| Dashboard title | font-serif text-base font-bold | 16 px / 700 | Merriweather |
| "Briefwechsel öffnen" CTA | bg-accent text-primary text-xs font-extrabold uppercase tracking-wide px-3 py-1.5 rounded-sm min-h-[44px] min-w-[44px] inline-flex items-center | 44 px min | WCAG 2.2 AA touch target; Paraglide m.person_open_conversation() |
Four states. Every frame renders the page shell (header → back link → split grid). Reading order per state: 320 px → 768 px → 1440 px. At 320 and 768, the grid stacks and the dashboard flows below the person card.
letterCount >= 10 and yearSpan >= 3./api/persons/{id} payload (fast); the dashboard shows skeleton rectangles for each section in the same grid slots.Six blocks rendered at near-real pixel sizes. These are the reference renderings developers check against when implementing PersonDashboard.svelte and its sub-components.
| Part | Classes | Real | Note |
|---|---|---|---|
| Strip container | grid grid-cols-2 sm:grid-cols-4 gap-px bg-line border-b border-line | 1 px gap (shows as lines) | Separators are the background showing through |
| Cell | bg-muted px-4 py-3.5 text-center | 14 px y padding | Uses bg-muted not bg-surface so separators read |
| Number | font-serif text-[22px] font-black text-primary leading-none tabular-nums tracking-tight | 22 px / 900 | Merriweather Black · .out = primary, .in = accent |
| Label | mt-1 text-[10px] font-bold uppercase tracking-wide text-ink-3 | 10 px | Direction labels match number colour |
| Mobile formatter | Abbreviate "Briefe gesamt" → "gesamt" | m.person_stats_total_short() | Paraglide key with _short suffix |
| Part | Classes | Real | Note |
|---|---|---|---|
| Container | flex items-end gap-0.5 h-[72px] pt-1 | 72 px height | Height tuned to comfortable reading — not too small |
| Bar (normal) | flex-1 bg-accent/60 rounded-t-sm hover:bg-accent/90 transition-colors cursor-pointer | variable width | Min-width 3 px for ≤ 30 years; thinner for longer ranges |
| Bar (peak) | flex-1 bg-primary rounded-t-sm | max height | Exactly one peak bar highlighted |
| Bar link | <a href="/briefwechsel?senderId={id}&from={year}-01-01&to={year}-12-31"> | — | Whole bar is the link; tooltip announces "Jahr {year} · {count} Briefe" |
| Year labels | flex justify-between text-[10px] font-bold text-ink-3 mt-1.5 | 10 px | Only show: earliest year · peak year · latest year |
| Tooltip | native title attribute on bar | — | "1922 · 78 Briefe" — Paraglide pluralized |
| Empty-year bars | rendered as min-height: 2px placeholder | 2 px | Keeps bar spacing regular across decades with gaps |
DistributionBar.svelte component from the thumbnail rows spec is also used here. Same props (outCount, outLabel, inCount, inLabel), same aria-label pattern.| Part | Classes | Real | Note |
|---|---|---|---|
| Wrapper | <ol> · flex flex-col gap-2 | 8 px gap | Ordered list — rank order matters, screen readers announce "1 of 6" |
| Item | <li> containing <a> · flex items-center gap-3 text-sm px-1.5 py-1 rounded-sm min-h-[32px] hover:bg-muted | 32 px min | Not 44 px because these are secondary links inside a card — desktop focus. Mobile bumps to 44 via md:min-h-[32px] min-h-[44px] |
| Name | flex-1 font-semibold text-ink truncate | 14 px / 600 | Truncate middle-ellipsis on very long German names at < 768 px |
| Proportional bar wrapper | w-[120px] h-[7px] bg-line rounded overflow-hidden shrink-0 hidden sm:block | 120 × 7 | Hidden on mobile — the number carries the data |
| Proportional bar fill | h-full bg-primary rounded | width = value ÷ max | Widths computed client-side from the list's max value |
| Count | w-10 text-right text-sm text-ink-3 font-bold tabular-nums shrink-0 | 14 px / 700 | Tabular figures so columns align |
| "Alle N anzeigen →" | mt-2.5 text-xs font-bold text-primary border-b border-dashed border-primary/60 hover:border-primary | 12 px / 700 | Appears when total > shown count |
::before with aria-hidden="true". The location string alone carries semantic meaning. Future iteration may replace with a map icon component.| Size | Classes | Count threshold | Note |
|---|---|---|---|
| xl | text-[15px] font-bold px-3.5 py-1 bg-accent text-primary rounded-full | ≥ 100 | Max 2 tags at this size — visual anchor |
| l | text-[13px] font-bold px-3 py-1 bg-accent text-primary rounded-full | 50–99 | Up to 4 tags |
| m | text-xs font-bold px-2.5 py-0.5 bg-accent text-primary rounded-full | 20–49 | Up to 6 tags |
| regular | text-xs font-bold px-2.5 py-0.5 bg-accent text-primary rounded-full | 5–19 | All tags meeting threshold |
| muted | text-xs font-semibold px-2.5 py-0.5 bg-line text-ink-3 rounded-full | < 5 | Up to 8 muted tags, sorted alphabetically |
| Click target | min-h-[28px] min-w-[44px] inline-flex items-center | 44 px min width | Very short tag names ("Kur") still meet touch target |
| Hover | hover:-translate-y-px transition-transform | 1 px lift | Bypassed when prefers-reduced-motion |
The dashboard is the discovery surface; /briefwechsel is the reading surface. Every clickable element in the dashboard adds filters to the existing /briefwechsel query string so the transition feels continuous.
| Element | Link | Note |
|---|---|---|
| Header "↗ Briefwechsel öffnen" | /briefwechsel?senderId={id}&dir=DESC | Opens all letters for this person · no date filter · newest first |
| Stats — "gesamt" | Same as header | Entire count is tap-to-open |
| Stats — "ausgehend" | /briefwechsel?senderId={id}&direction=OUT&dir=DESC | Requires new direction query param on /briefwechsel |
| Stats — "eingehend" | /briefwechsel?senderId={id}&direction=IN&dir=DESC | Same |
| Histogram bar | /briefwechsel?senderId={id}&from={year}-01-01&to={year}-12-31 | Opens bilateral or single view scoped to that year |
| Top correspondent row | /briefwechsel?senderId={id}&receiverId={otherId}&dir=DESC | Opens the bilateral view for the pair |
| Top location row | /briefwechsel?senderId={id}&location={locSlug} | Requires new location query param on /briefwechsel |
| Tag chip | /briefwechsel?senderId={id}&tagId={tagId} | Requires new tagId query param on /briefwechsel |
direction=OUT|IN — filter to letters in one direction only (existing endpoint already distinguishes sender vs receiver; this adds symmetry for the single-person view).location=<slug> — case-insensitive match on Document.location. Slug because German locations can contain spaces and dots ("B.Lichterfelde", "Bad Kissingen").tagId=<uuid> — filter to letters that reference this tag. If omitted, no tag filter is applied.Every colour pair on the dashboard has been measured. Semantics matter as much as colour: the stats use real numbers (not SVG), the histogram has tooltip text, the top lists are ordered lists, the tag cloud is a list of links.
| Pair | Value | Ratio | WCAG |
|---|---|---|---|
| Stat number (primary on muted) | #002850 on #f7f5f2 | 14.0:1 | AAA ✓ |
| Stat label (ink-3 on muted) | #666666 on #f7f5f2 | 5.4:1 | AA ✓ |
| Stat "in" (accent on muted) | #2F9E95 on #f7f5f2 | 4.5:1 | AA ✓ (borderline — number is 22 px / 900 qualifies as large) |
| Dashboard header title (surface on primary) | #ffffff on #002850 | 14.5:1 | AAA ✓ |
| CTA "Briefwechsel öffnen" (primary on accent) | #002850 on #a6dad8 | 8.1:1 | AAA ✓ |
| Histogram bar (accent/60 on surface) | rgba(47,158,149,.6) on #ffffff | 2.8:1 | Decorative (bars have titles; not text) |
| Histogram peak bar (primary on surface) | #002850 on #ffffff | 14.5:1 | AAA ✓ |
| Tag chip (primary on accent) | #002850 on #a6dad8 | 8.1:1 | AAA ✓ |
| Muted tag (ink-3 on line) | #666666 on #eee8dc | 5.1:1 | AA ✓ |
| Focus ring (primary on surface, 2 px offset) | #002850 | 14.5:1 | AAA ✓ |
| Pair | Value | Ratio | WCAG |
|---|---|---|---|
| Stat number (ink on canvas-2) | #f0efe9 on #011526 | 15.1:1 | AAA ✓ |
| Stat "out" (mint on canvas-2) | #a1dcd8 on #011526 | 9.6:1 | AAA ✓ |
| Stat "in" (turquoise on canvas-2) | #00c7b1 on #011526 | 6.8:1 | AA ✓ |
| Stat label (ink-3 on canvas-2) | #8b97a5 on #011526 | 7.1:1 | AAA ✓ |
| Dashboard header (ink on navy-2) | #f0efe9 on #01223f | 13.8:1 | AAA ✓ |
| CTA (primary on mint) | #012851 on #a1dcd8 | 9.6:1 | AAA ✓ |
| Histogram peak (mint on canvas) | #a1dcd8 on #010e1e | 9.2:1 | AAA ✓ |
| Tag (turquoise on tint) | #00c7b1 on rgba(0,199,177,.2) | 6.3:1 | AA ✓ |
<dl> / <dt> / <dd> pairs. Screen readers announce "Briefe gesamt: 851, ausgehend: 612, …".role="img" with aria-label="Aktivität über 42 Jahre, Spitzenjahr 1922 mit 78 Briefen". Each bar has a title attribute for sighted tooltip.<ol> elements — screen readers announce "Top Korrespondenten, list 6 items, 1 of 6 Walter Dieckmann, 184 Briefe".<ul> of <li><a>. The visual size does not carry meaning that text cannot — the count is not exposed to screen readers via size, only via the tooltip / aria-label "Schlagwort Verlag, {count} Briefe".focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 on every interactive element (histogram bars, top-list rows, tag chips, header CTA).prefers-reduced-motion: disable bar width animation on first render and the tag-chip hover lift. The skeleton shimmer collapses to a static gradient.| Field | Value | Note |
|---|---|---|
| Route | GET /api/persons/{id}/dashboard | Separate from /api/persons/{id} to keep person entity lean and let the dashboard query be cache-friendly |
| Permission | @RequirePermission(Permission.READ_ALL) | Same as /api/persons/{id} |
| Cache | Server-side cache keyed on (personId, dataVersion) | Invalidate on Document write that references this person (see DocumentService update hooks) |
| Response schema | see next table | All counts server-computed — never client-computed |
| Field | Type | Note |
|---|---|---|
totalCount | int | outCount + inCount |
outCount | int | Letters where this person is sender |
inCount | int | Letters where this person is in receivers |
yearSpan | int | latestYear - earliestYear + 1 (null when no letters) |
correspondentCount | int | Distinct counterparts |
activityByYear | Map<int, int> | year → count · always contiguous (missing years = 0 · dashboard decides display) |
peakYear | int | Year with most letters (null when no letters) |
peakYearCount | int | |
topCorrespondents | List<CorrespondentTileDTO> | Max 10 · sorted desc · each has personId · displayName · count |
topLocations | List<LocationTileDTO> | Max 10 · each has location · count |
topTags | List<TagTileDTO> | Max 20 · each has tagId · label · count · frontend buckets into size tiers |
| File | Responsibility | Change |
|---|---|---|
PersonDashboard.svelte | Orchestrator · renders header + stats + sections | new |
StatStrip.svelte | 4-cell stats grid with direction colouring | new |
ActivityHistogram.svelte | One bar per year, peak highlight, hover tooltip | new |
DistributionBar.svelte | Already introduced in briefwechsel-thumbnail-rows-spec.html · re-used here with different labels | shared |
TopTileList.svelte | Ordered list of tiles (name + bar + count) — used for correspondents and locations | new · generic |
TagCloud.svelte | Frequency-sized chips with size buckets | new |
PersonPageShell.svelte / +page.svelte | Renders 2-column grid | Replace CoCorrespondentsList with PersonDashboard. Remove coCorrespondents derivation in +page.svelte — dashboard owns it. |
+page.server.ts | Loads person data | Add parallel call to /api/persons/{id}/dashboard; keep error handling identical |
GET /api/persons/{id}/dashboard with PersonDashboardDTO. Cache on person-write hooks. Tests for empty / sparse / full.PersonDashboard.svelte + its six sub-components. Replaces CoCorrespondentsList in the right column./briefwechsel: direction, location, tagId. Wire every dashboard element to them.