The undated bucket is assembled from all entries, so it can contain events as well as letters. Rendering every undated entry with LetterCard produced a dead /documents/undefined link and "Unknown -> Unknown" for events. Dispatch on kind/type like YearBand does (WorldBand/EventPill/ LetterCard). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
108 lines
3.6 KiB
Svelte
108 lines
3.6 KiB
Svelte
<script lang="ts">
|
|
import * as m from '$lib/paraglide/messages.js';
|
|
import YearBand from './YearBand.svelte';
|
|
import GapSpan from './GapSpan.svelte';
|
|
import LetterCard from './LetterCard.svelte';
|
|
import EventPill from './EventPill.svelte';
|
|
import WorldBand from './WorldBand.svelte';
|
|
import { entryKey } from './entryKey';
|
|
import type { components } from '$lib/generated/api';
|
|
|
|
type TimelineDTO = components['schemas']['TimelineDTO'];
|
|
type TimelineYearDTO = components['schemas']['TimelineYearDTO'];
|
|
|
|
/**
|
|
* Orchestrates the global timeline (REQ-001/003). Renders the year bands the DTO
|
|
* delivers in order — never re-sorting — interleaving a folded GapSpan for each
|
|
* interior run of empty years (REQ-015), then the undated bucket (REQ-016). An
|
|
* empty timeline shows a calm message (REQ-017). `personId` is a declared seam
|
|
* for the per-person rail (issue #10) and is undefined here; it is not passed to
|
|
* leaf cards (REQ-025). Owns no <main> — the layout does.
|
|
*/
|
|
let { timeline, personId = undefined }: { timeline: TimelineDTO; personId?: string } = $props();
|
|
|
|
type Row = { t: 'band'; year: TimelineYearDTO } | { t: 'gap'; from: number; to: number };
|
|
|
|
const rows = $derived.by<Row[]>(() => {
|
|
const out: Row[] = [];
|
|
const years = timeline.years;
|
|
for (let i = 0; i < years.length; i++) {
|
|
if (i > 0) {
|
|
const prev = years[i - 1].year;
|
|
const cur = years[i].year;
|
|
if (cur - prev > 1) out.push({ t: 'gap', from: prev + 1, to: cur - 1 });
|
|
}
|
|
out.push({ t: 'band', year: years[i] });
|
|
}
|
|
return out;
|
|
});
|
|
|
|
const isEmpty = $derived(timeline.years.length === 0 && timeline.undated.length === 0);
|
|
</script>
|
|
|
|
{#if isEmpty}
|
|
<p class="py-12 text-center font-serif text-base text-ink-2">{m.timeline_empty_state()}</p>
|
|
{:else}
|
|
<!-- personId is a declared seam for the per-person Lebensweg rail (issue #10);
|
|
undefined in the global view, surfaced only on the root, never passed to
|
|
leaf cards (REQ-025). -->
|
|
<ol class="timeline-axis relative mx-auto max-w-3xl" data-person-id={personId}>
|
|
{#each rows as row (row.t === 'band' ? `band-${row.year.year}` : `gap-${row.from}`)}
|
|
<li>
|
|
{#if row.t === 'band'}
|
|
<YearBand year={row.year} />
|
|
{:else}
|
|
<GapSpan from={row.from} to={row.to} />
|
|
{/if}
|
|
</li>
|
|
{/each}
|
|
</ol>
|
|
|
|
{#if timeline.undated.length > 0}
|
|
<section data-testid="undated-section" class="mx-auto mt-8 max-w-3xl">
|
|
<h2 class="mb-3 font-serif text-sm font-bold text-ink-2">{m.timeline_undated_section()}</h2>
|
|
<ul class="space-y-2">
|
|
<!-- The undated bucket is filtered from ALL entries, so it can hold
|
|
events as well as letters. Dispatch on kind/type exactly like
|
|
YearBand — an event rendered as a LetterCard would link to
|
|
/documents/undefined and read "Unknown → Unknown" (REQ-007/008/009). -->
|
|
{#each timeline.undated as entry (entryKey(entry))}
|
|
<li>
|
|
{#if entry.kind === 'EVENT'}
|
|
{#if entry.type === 'HISTORICAL'}
|
|
<WorldBand entry={entry} />
|
|
{:else}
|
|
<EventPill entry={entry} />
|
|
{/if}
|
|
{:else}
|
|
<LetterCard entry={entry} />
|
|
{/if}
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
</section>
|
|
{/if}
|
|
{/if}
|
|
|
|
<style>
|
|
/* Phone (< 1024px): a single left-anchored spine. Desktop (≥ 1024px): a
|
|
centered spine the bands' alternating cards sit on either side of. The
|
|
spine is decorative — the chronology lives in the <ol> DOM order. */
|
|
.timeline-axis::before {
|
|
content: '';
|
|
position: absolute;
|
|
top: 0;
|
|
bottom: 0;
|
|
left: 0.5rem;
|
|
width: 2px;
|
|
background: linear-gradient(var(--palette-mint), var(--palette-navy));
|
|
}
|
|
|
|
@media (min-width: 1024px) {
|
|
.timeline-axis::before {
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
}
|
|
}
|
|
</style>
|