Files
familienarchiv/frontend/src/lib/timeline/YearBand.svelte
Marcel bd78f34f09 feat(timeline): render a same-year curated event as its cluster card header
A curated event with letters in its own band now becomes the contained card header
(glyph, title, date, provenance, edit pencil) instead of a separate floating pill —
the title reads once. Derived life-events, world-bands, and letterless event pills
are unchanged (REQ-001 amended for curated-with-letters; the identity fixture now
links its letter to the curated event so the letterless world band stays a band).

Refs #827
2026-06-15 14:56:57 +02:00

253 lines
8.1 KiB
Svelte

<script lang="ts">
import EventPill from './EventPill.svelte';
import WorldBand from './WorldBand.svelte';
import LetterCard from './LetterCard.svelte';
import YearLetterStrip from './YearLetterStrip.svelte';
import LetterBucket from './LetterBucket.svelte';
import { isDense } from './timelineDensity';
import { entryKey } from './entryKey';
import {
bucketLetters,
type GroupingMode,
type LetterBucket as LetterBucketModel
} from './timelineGrouping';
import type { components } from '$lib/generated/api';
type TimelineYearDTO = components['schemas']['TimelineYearDTO'];
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
/**
* One year of the timeline: a <section> with a sticky <h2> (REQ-006). Events
* render in DTO order as pills/bands; letters render as individual cards while
* the band holds ≤ 12 (REQ-011) or collapse to a single density strip above that
* (REQ-012). Entries are never re-sorted — DTO order is preserved (REQ-003).
*
* In Ereignis/Thema mode (#827) the event pills/world-bands render identically
* (REQ-001); only the loose letters re-bundle into per-year buckets below them
* (REQ-002/003/004). Datum mode is the original individual-card / density-strip
* path, untouched.
*/
let {
year,
canWrite = false,
groupingMode = 'date',
eventLookup = new Map<string, string>()
}: {
year: TimelineYearDTO;
canWrite?: boolean;
groupingMode?: GroupingMode;
eventLookup?: Map<string, string>;
} = $props();
type Row =
| { t: 'event'; entry: TimelineEntryDTO }
| { t: 'eventcard'; entry: TimelineEntryDTO; bucket: LetterBucketModel }
| { t: 'letter'; entry: TimelineEntryDTO; side: 'left' | 'right' }
| { t: 'strip' }
| { t: 'bucket'; bucket: LetterBucketModel; nested: boolean };
const letters = $derived(year.entries.filter((e) => e.kind === 'LETTER'));
const dense = $derived(isDense(letters.length));
const bucketMode = $derived(groupingMode === 'thema' ? 'thema' : 'event');
const rows = $derived.by<Row[]>(() => {
const out: Row[] = [];
// Ereignis: events stay on the axis (REQ-001). A curated event WITH letters in this band
// becomes the contained card's header (no separate pill — its title reads once, #827
// redesign); a letterless/derived/world event stays a plain pill/band. A cluster whose event
// lives in another year band (or was filtered out) renders as a text-header card here, and
// the unlinked letters fall to the single "Weitere Briefe" drawer (REQ-003/006/019).
if (groupingMode === 'event') {
const buckets = bucketLetters(letters, 'event', eventLookup);
const sameYearBucket = (id: string | undefined) =>
id ? buckets.find((b) => b.kind === 'event' && b.key === `event:${id}`) : undefined;
for (const entry of year.entries) {
if (entry.kind !== 'EVENT') continue;
const bucket = sameYearBucket(entry.eventId);
// A curated event with same-year letters becomes the card header (card replaces pill);
// otherwise it stays a plain pill/world-band.
if (bucket) out.push({ t: 'eventcard', entry, bucket });
else out.push({ t: 'event', entry });
}
// Cross-year clusters (no matching event entry in this band) and the fallback drawer
// render after the axis entries, with their own text header.
for (const bucket of buckets) {
if (
bucket.kind === 'fallback' ||
!year.entries.some((e) => e.kind === 'EVENT' && `event:${e.eventId}` === bucket.key)
) {
out.push({ t: 'bucket', bucket, nested: false });
}
}
return out;
}
// Thema: events stay on the axis (REQ-001); loose letters re-bundle into per-year root-tag
// buckets below them (REQ-004) — no axis pill exists for a tag, so every bucket keeps a header.
if (groupingMode === 'thema') {
for (const entry of year.entries) {
if (entry.kind === 'EVENT') out.push({ t: 'event', entry });
}
for (const bucket of bucketLetters(letters, 'thema', eventLookup)) {
out.push({ t: 'bucket', bucket, nested: false });
}
return out;
}
let stripInserted = false;
let letterIndex = 0;
for (const entry of year.entries) {
if (entry.kind === 'EVENT') {
out.push({ t: 'event', entry });
} else if (!dense) {
out.push({ t: 'letter', entry, side: letterIndex % 2 === 0 ? 'left' : 'right' });
letterIndex += 1;
} else if (!stripInserted) {
out.push({ t: 'strip' });
stripInserted = true;
}
}
return out;
});
function rowKey(row: Row): string {
if (row.t === 'strip') return `strip-${year.year}`;
if (row.t === 'bucket') return row.bucket.key;
return entryKey(row.entry);
}
</script>
<section class="py-2">
<h2 class="year-heading">
<span data-testid="year-node" class="year-node bg-brand-navy" aria-hidden="true"></span>
<span
data-testid="year-label"
class="year-label rounded-full bg-brand-navy px-4 py-1 font-serif text-sm font-bold text-white"
>{year.year}</span
>
</h2>
<div class="mt-3 space-y-3">
{#each rows as row (rowKey(row))}
{#if row.t === 'event'}
{#if row.entry.type === 'HISTORICAL'}
<WorldBand entry={row.entry} canWrite={canWrite} />
{:else}
<EventPill entry={row.entry} canWrite={canWrite} />
{/if}
{:else if row.t === 'eventcard'}
<LetterBucket
bucket={row.bucket}
mode="event"
year={year.year}
event={row.entry}
canWrite={canWrite}
/>
{:else if row.t === 'letter'}
<div class="letter-row" data-side={row.side}>
<span data-testid="letter-dot" class="letter-dot bg-surface" aria-hidden="true"></span>
<LetterCard entry={row.entry} />
</div>
{:else if row.t === 'bucket'}
<LetterBucket bucket={row.bucket} mode={bucketMode} year={year.year} nested={row.nested} />
{:else}
<YearLetterStrip letters={letters} year={year.year} />
{/if}
{/each}
</div>
</section>
<style>
/* Sticky offset in scoped CSS so it holds in unit tests too (the global
header is a 64px sticky nav). #779 REQ-006 / #833 REQ-003. The sticky
element is also the positioning context for the year-node marker. */
.year-heading {
position: sticky;
top: 4rem;
z-index: 1;
}
/* Markers ride on the spine's single source of truth: --spine-x, declared
once on TimelineView's .timeline-axis and inherited here (0.5rem phone /
50% desktop). The 0.5rem fallback only applies when a YearBand is rendered
outside the axis (e.g. component tests). #833 REQ-003/004/005. */
/* Phone (< 1024px): badge sits at the left spine, clearing the node marker.
The badge sits above the node (z-index) so on desktop, where the centered
pill covers the centered node, the white year text is never occluded. */
.year-label {
display: inline-block;
margin-left: 1.75rem;
position: relative;
z-index: 2;
}
/* Navy node marker on the spine. On phone it shows to the left of the badge;
on desktop it sits behind the centered pill, which is itself the
axis interruption. */
.year-node {
position: absolute;
top: 50%;
left: var(--spine-x, 0.5rem);
width: 11px;
height: 11px;
border-radius: 9999px;
transform: translate(-50%, -50%);
z-index: 1;
}
/* Per-letter connector dot (white fill via bg-surface, mint ring) on the spine. */
.letter-row {
position: relative;
padding-left: 1.75rem;
}
.letter-dot {
position: absolute;
top: 0.9rem;
left: var(--spine-x, 0.5rem);
width: 13px;
height: 13px;
border-radius: 9999px;
border: 2.5px solid var(--palette-mint);
transform: translate(-50%, -50%);
z-index: 2;
}
/* Desktop (≥ 1024px): centered axis. The badge centers on the spine, the node
sits at the spine centre, and letters alternate left/right with the
connector dot on the centred spine between card and axis. #833 REQ-003/004/005. */
@media (min-width: 1024px) {
.year-label {
display: block;
width: fit-content;
margin-left: auto;
margin-right: auto;
}
/* .year-node needs no desktop override — it inherits --spine-x: 50% from
the axis. */
.letter-row {
width: 50%;
padding-left: 0;
}
.letter-row[data-side='left'] {
margin-right: auto;
padding-right: 1.75rem;
}
.letter-row[data-side='right'] {
margin-left: auto;
padding-left: 1.75rem;
}
.letter-row[data-side='left'] .letter-dot {
left: auto;
right: 0;
transform: translate(50%, -50%);
}
.letter-row[data-side='right'] .letter-dot {
left: 0;
right: auto;
transform: translate(-50%, -50%);
}
}
</style>