import type { components } from '$lib/generated/api'; type TimelineDTO = components['schemas']['TimelineDTO']; type TimelineEntryDTO = components['schemas']['TimelineEntryDTO']; /** * The three ways a reader can bundle the loose letters on `/zeitstrahl` (#827). The * axis-fixed layers (life-events, event pills, world-bands) are identical in every mode * — only loose-letter bundling changes. Grouping runs over the *already layer-filtered* * timeline (#780): filter-then-group. */ export type GroupingMode = 'date' | 'event' | 'thema'; /** The default mode — chronological, as #779 shipped. */ export const DEFAULT_GROUPING: GroupingMode = 'date'; /** * A bucket larger than this collapses to a month-density strip instead of flooding the * timeline with individual cards (#827) — the catch-all "Weitere Briefe"/"Ohne Thema" * buckets are always the biggest, so without this they swamp the grouped view. Lower than * Datum mode's `DENSE_THRESHOLD` (12) because a bucket is a narrower context than a year. */ export const BUCKET_DENSE_THRESHOLD = 6; export function isBucketDense(letterCount: number): boolean { return letterCount > BUCKET_DENSE_THRESHOLD; } /** The `--c-tag-*` colour-name tokens (sage/sienna/…); shared by the chip and the rail. */ const TAG_COLOR_TOKENS = new Set([ 'sage', 'sienna', 'amber', 'slate', 'violet', 'rose', 'cobalt', 'moss', 'sand', 'coral' ]); /** * Maps a root-tag colour-name token to its CSS variable reference, or `null` for an absent * or unknown token (so a colourless/unrecognised tag falls back to a neutral rail, never a * broken `var(--c-tag-undefined)`). */ export function tagColorVar(token: string | null | undefined): string | null { return token && TAG_COLOR_TOKENS.has(token) ? `var(--c-tag-${token})` : null; } /** * One bundle of loose letters under a single header, within a year (Ereignis/Thema modes). * `kind` decides the header: a curated-event title, a tinted root-tag chip, or the localized * fallback ("Weitere Briefe" / "Ohne Thema") the view supplies for `kind === 'fallback'`. */ export interface LetterBucket { /** Stable `{#each}` key, unique within a year's bucket list. */ key: string; kind: 'event' | 'tag' | 'fallback'; /** Header label for `event`/`tag` buckets; absent for `fallback` (view supplies a localized label). */ title?: string; /** Root-tag colour token for a `tag` bucket; `null` for `event`/`fallback` (neutral). */ color: string | null; letters: TimelineEntryDTO[]; } /** * Maps each curated event present in the (already-filtered) timeline to its title. These are the * only events a letter may cluster under — a letter whose `linkedEventId` is absent here links to * an event the layer filter removed, so it falls back to "Weitere Briefe" (filter-then-group, * REQ-019). Curated events carry an `eventId`; derived life-events and letters do not. */ export function buildEventLookup(timeline: TimelineDTO): Map { const lookup = new Map(); const collect = (entries: TimelineEntryDTO[]) => { for (const entry of entries) { if (entry.kind === 'EVENT' && entry.eventId) lookup.set(entry.eventId, entry.title ?? ''); } }; for (const band of timeline.years) collect(band.entries); collect(timeline.undated); return lookup; } /** * True when the timeline still holds at least one loose letter. Drives the grouping control's * enabled state: with the Letters layer filtered off there is nothing to regroup (REQ-018). */ export function hasLooseLetters(timeline: TimelineDTO): boolean { const holdsLetter = (entries: TimelineEntryDTO[]) => entries.some((e) => e.kind === 'LETTER'); return timeline.years.some((band) => holdsLetter(band.entries)) || holdsLetter(timeline.undated); } /** * Buckets one year's loose letters for Ereignis/Thema mode. The caller passes only that year's * `LETTER` entries; events stay on the axis untouched (REQ-001). Buckets keep first-seen order and * the fallback bucket, if any, always sorts last. * * - `event`: cluster under `linkedEventId` when it is set AND survives in `eventLookup`; otherwise * the fallback "Weitere Briefe" bucket (REQ-003/006/019). * - `thema`: bucket under `rootTagId` (header = `rootTagName`, tint = `rootTagColor`); an untagged * letter goes to the fallback "Ohne Thema" bucket (REQ-004/007). A letter carries exactly one * `rootTagId`, so it lands in exactly one bucket (REQ-008). */ export function bucketLetters( letters: TimelineEntryDTO[], mode: Exclude, eventLookup: Map ): LetterBucket[] { const byKey = new Map(); let fallback: LetterBucket | null = null; const fallbackBucket = (): LetterBucket => { if (!fallback) fallback = { key: '__fallback__', kind: 'fallback', color: null, letters: [] }; return fallback; }; const namedBucket = (id: string, build: () => LetterBucket): LetterBucket => { let bucket = byKey.get(id); if (!bucket) { bucket = build(); byKey.set(id, bucket); } return bucket; }; for (const letter of letters) { if (mode === 'event') { const id = letter.linkedEventId; if (id && eventLookup.has(id)) { namedBucket(id, () => ({ key: `event:${id}`, kind: 'event', title: eventLookup.get(id), color: null, letters: [] })).letters.push(letter); } else { fallbackBucket().letters.push(letter); } } else { const id = letter.rootTagId; if (id) { namedBucket(id, () => ({ key: `tag:${id}`, kind: 'tag', title: letter.rootTagName ?? '', color: letter.rootTagColor ?? null, letters: [] })).letters.push(letter); } else { fallbackBucket().letters.push(letter); } } } const buckets = [...byKey.values()]; if (fallback) buckets.push(fallback); return buckets; }