Files
familienarchiv/frontend/src/routes/zeitstrahl/+page.svelte
Marcel 5936f3a9ae feat(timeline): wire the grouping toggle into the Zeitstrahl page
Add the grouping $state beside the #780 layer-filter state, render the
GroupingControl stacked above the filter trigger (disabled, but kept in place,
when no loose letters remain), make the meta-line grouping label track the
active mode, and thread groupingMode into TimelineView — filter-then-group,
no refetch.

Refs #827
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:56:55 +02:00

146 lines
5.7 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import TimelineView from '$lib/timeline/TimelineView.svelte';
import TimelineFilters from '$lib/timeline/TimelineFilters.svelte';
import GroupingControl from '$lib/timeline/GroupingControl.svelte';
import { timelineMeta } from '$lib/timeline/timelineMeta';
import { filterTimeline } from '$lib/timeline/timelineFilter';
import { hasLooseLetters, type GroupingMode } from '$lib/timeline/timelineGrouping';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const hasContent = $derived(data.timeline.years.length > 0 || data.timeline.undated.length > 0);
// Layer-filter state (#780). Layer hiding is client-side only — the whole
// timeline is loaded once by #779's SSR load and we derive a filtered view of
// it here; there is no goto, no URL param, and no extra fetch.
let personalOn = $state(true);
let historicalOn = $state(true);
let lettersOn = $state(true);
// Grouping state (#827) lives here beside the layer-filter state; the regroup is a
// pure client-side transform over the already-filtered view — filter-then-group.
let groupingMode = $state<GroupingMode>('date');
const filteredTimeline = $derived(
filterTimeline(data.timeline, { personalOn, historicalOn, lettersOn })
);
const filteredEmpty = $derived(
filteredTimeline.years.length === 0 && filteredTimeline.undated.length === 0
);
// The grouping control is only meaningful while loose letters remain in the filtered
// view; with the Letters layer off there is nothing to regroup, so it disables but
// keeps its selected mode (REQ-018).
const hasLetters = $derived(hasLooseLetters(filteredTimeline));
// Meta-line figures track the *filtered* view, so the header counts always
// match what is actually on screen once layers are toggled off (#780 — this
// closes the prior D1 limitation, where the counts stayed on the full timeline).
const meta = $derived(timelineMeta(filteredTimeline));
function resetFilters() {
personalOn = true;
historicalOn = true;
lettersOn = true;
}
// Compose the sub-line from segments joined by " · " so the range drops out
// cleanly when there are no year bands; the whole line is absent when the
// timeline is empty (REQ-002). Counts come from the route alone, never from
// TimelineView.
const metaLine = $derived.by(() => {
const segments: string[] = [];
if (meta.firstYear !== null && meta.lastYear !== null) {
segments.push(`${meta.firstYear}${meta.lastYear}`);
}
// A zero-count segment ("0 Briefe") reads as a data error — drop it; a count
// of one takes the singular key ("1 Brief"), per the project plural convention.
if (meta.letterCount > 0) {
segments.push(
meta.letterCount === 1
? m.timeline_letters_count_singular()
: m.timeline_letters_count({ count: meta.letterCount })
);
}
if (meta.eventCount > 0) {
segments.push(
meta.eventCount === 1
? m.timeline_events_count_singular()
: m.timeline_events_count({ count: meta.eventCount })
);
}
segments.push(
groupingMode === 'event'
? m.timeline_grouping_event()
: groupingMode === 'thema'
? m.timeline_grouping_thema()
: m.timeline_grouping_date()
);
return segments.join(' · ');
});
</script>
<svelte:head>
<title>{m.timeline_heading()}</title>
</svelte:head>
<div class="mx-auto max-w-5xl px-4 py-8">
<!-- The .tl-canvas sheet: a padded canvas surface for the timeline. The outer
border is intentionally omitted (the page is already bg-canvas), per the
review of REQ-001 — the sheet reads through its padding, not a frame line. -->
<div data-testid="timeline-canvas" class="rounded-[10px] bg-canvas p-6">
<!-- Wrapping header so the CTA drops below the heading at narrow widths
(≤360px) instead of overflowing — #842 REQ-001. -->
<header class="flex flex-wrap items-center justify-between gap-3">
<h1 class="font-serif text-2xl font-bold text-brand-navy">{m.timeline_heading()}</h1>
{#if data.canWrite}
<a
data-testid="timeline-add-event"
href="/zeitstrahl/events/new"
class="inline-flex h-11 items-center rounded bg-primary px-4 font-sans text-sm font-medium text-primary-fg hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
{m.timeline_add_event()}
</a>
{/if}
</header>
{#if hasContent}
<p data-testid="timeline-meta" class="mt-1 mb-3 font-sans text-xs text-ink-3">{metaLine}</p>
<!-- Grouping toggle stacked above the #780 layer-filter trigger so the two read as
one control cluster in the header (REQ-010); the top-right corner stays the
add-event CTA. Disabled — but kept in place — when no loose letters remain
(REQ-018). -->
<div class="mb-3" data-testid="grouping-cluster">
<GroupingControl bind:mode={groupingMode} disabled={!hasLetters} />
</div>
<TimelineFilters
bind:personalOn={personalOn}
bind:historicalOn={historicalOn}
bind:lettersOn={lettersOn}
/>
{/if}
{#if hasContent && filteredEmpty}
<!-- Filtered-empty: a calm message + one-click reset below the still-open
filter bar — never a blank page, and never the generic "no events"
state (which would imply the archive itself is empty). REQ-006. -->
<div data-testid="timeline-filter-empty" class="py-12 text-center">
<p class="font-serif text-base text-ink-2">{m.timeline_filter_empty_state()}</p>
<button
type="button"
data-testid="timeline-filter-empty-reset"
onclick={resetFilters}
class="mt-3 inline-flex min-h-[44px] items-center font-sans text-sm text-primary underline-offset-2 hover:underline"
>
{m.timeline_filter_reset()}
</button>
</div>
{:else}
<TimelineView
timeline={filteredTimeline}
canWrite={data.canWrite}
groupingMode={groupingMode}
/>
{/if}
</div>
</div>