Move the per-entry {#each} key logic into a shared entryKey.ts so the
undated bucket in TimelineView can reuse it. No behavior change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
100 lines
2.9 KiB
Svelte
100 lines
2.9 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 { isDense } from './timelineDensity';
|
|
import { entryKey } from './entryKey';
|
|
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).
|
|
*/
|
|
let { year }: { year: TimelineYearDTO } = $props();
|
|
|
|
type Row =
|
|
| { t: 'event'; entry: TimelineEntryDTO }
|
|
| { t: 'letter'; entry: TimelineEntryDTO; side: 'left' | 'right' }
|
|
| { t: 'strip' };
|
|
|
|
const letters = $derived(year.entries.filter((e) => e.kind === 'LETTER'));
|
|
const dense = $derived(isDense(letters.length));
|
|
|
|
const rows = $derived.by<Row[]>(() => {
|
|
const out: Row[] = [];
|
|
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;
|
|
});
|
|
</script>
|
|
|
|
<section class="py-2">
|
|
<h2
|
|
class="year-heading w-fit rounded-full bg-brand-navy px-4 py-1 font-serif text-sm font-bold text-white"
|
|
>
|
|
{year.year}
|
|
</h2>
|
|
|
|
<div class="mt-3 space-y-3">
|
|
{#each rows as row (row.t === 'strip' ? `strip-${year.year}` : entryKey(row.entry))}
|
|
{#if row.t === 'event'}
|
|
{#if row.entry.type === 'HISTORICAL'}
|
|
<WorldBand entry={row.entry} />
|
|
{:else}
|
|
<EventPill entry={row.entry} />
|
|
{/if}
|
|
{:else if row.t === 'letter'}
|
|
<div class="letter-row" data-side={row.side}>
|
|
<LetterCard entry={row.entry} />
|
|
</div>
|
|
{: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). REQ-006. */
|
|
.year-heading {
|
|
position: sticky;
|
|
top: 4rem;
|
|
z-index: 1;
|
|
}
|
|
|
|
/* Phone (< 1024px): single left-anchored column, all letters on one side
|
|
(REQ-005). Desktop (≥ 1024px): centered axis, letters alternate left/right
|
|
so consecutive cards sit on opposite sides of the spine (REQ-004). */
|
|
@media (min-width: 1024px) {
|
|
.letter-row {
|
|
width: 50%;
|
|
}
|
|
.letter-row[data-side='left'] {
|
|
margin-right: auto;
|
|
padding-right: 1.75rem;
|
|
}
|
|
.letter-row[data-side='right'] {
|
|
margin-left: auto;
|
|
padding-left: 1.75rem;
|
|
}
|
|
}
|
|
</style>
|