Compare commits
6 Commits
feat/issue
...
093c942f67
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
093c942f67 | ||
|
|
bd78f34f09 | ||
|
|
9ad18f92d9 | ||
|
|
b08f86f76d | ||
|
|
c1dc58c24f | ||
|
|
4162cfa916 |
@@ -209,10 +209,10 @@
|
||||
| REQ-011 | ≤320px: control overflow-free + tap ≥44px, each abbreviation carries its full word as aria-label | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/GroupingControl.svelte` | `GroupingControl.svelte.spec.ts#exposes each segment full word as an aria-label`, `e2e/zeitstrahl-grouping.spec.ts#the control stays overflow-free and operable at 320px` | Done |
|
||||
| REQ-012 | new grouping/bucket Paraglide keys in de/en/es; no collision with existing timeline*_ keys | #827 | timeline-grouping-modes | `frontend/messages/{de,en,es}.json` | `messages.spec.ts#zeitstrahl grouping + bucket keys are present in all locales (#827 REQ-012)`, `messages.spec.ts#de, en, and es have identical key sets` | Done |
|
||||
| REQ-013 | failed timeline fetch → existing localized error via getErrorMessage; grouping has no independent failure mode | #827 | timeline-grouping-modes | `frontend/src/routes/zeitstrahl/+page.server.ts` (#779, unchanged) | `zeitstrahl/page.server` error path (#779 — getErrorMessage(extractErrorCode)) | Done |
|
||||
| REQ-014 | Ereignis event-clustered letter renders as the `.lcard.ev` variant, **nested directly under its event pill** with no duplicate title (redesign #847) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/LetterCard.svelte`, `frontend/src/lib/timeline/LetterBucket.svelte`, `frontend/src/lib/timeline/YearBand.svelte` | `LetterCard.svelte.spec.ts#carries the .lcard.ev class in the event variant`, `LetterBucket.svelte.spec.ts#renders its letters as .lcard.ev event cards`, `YearBand.svelte.spec.ts#nests an event cluster under its pill in the same year without repeating the title` | Done |
|
||||
| REQ-014 | Ereignis event-clustered letters live inside a **contained card whose header is the same-year curated event** (glyph, title, date, provenance, edit pencil) — the title reads once, no separate floating pill; letters render as the compact `.lcard.ev` variant, first 5 + show-more (redesign #847 → #827 grouped-card layout) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/LetterCard.svelte`, `frontend/src/lib/timeline/LetterBucket.svelte`, `frontend/src/lib/timeline/YearBand.svelte` | `LetterCard.svelte.spec.ts#carries the .lcard.ev class in the event variant`, `LetterBucket.svelte.spec.ts#renders the curated event as the card header when given an `event` (no separate pill)`, `LetterBucket.svelte.spec.ts#shows no edit affordance in the header when canWrite is false`, `YearBand.svelte.spec.ts#renders a same-year curated event as one card header, with no separate pill and no duplicate title` | Done |
|
||||
| REQ-015 | Thema bucket-header chip tinted via rootTagColor `var(--c-tag-*)`, neutral fallback when null/unknown; **label kept in a fixed ink for ≥4.5:1 contrast** (redesign #847) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/BucketHeaderChip.svelte` | `BucketHeaderChip.svelte.spec.ts#tints the chip with var(--c-tag-{token})`, `#renders a neutral chip with no --c-tag- binding when colour is null`, `#falls back to neutral for an unknown colour token`, `#paints the label in a fixed ink colour, never the saturated tag token` | Done |
|
||||
| REQ-016 | header meta-line grouping segment tracks the active mode (date/event/thema keys) | #827 | timeline-grouping-modes | `frontend/src/routes/zeitstrahl/+page.svelte` | `zeitstrahl/page.svelte.spec.ts#updates the meta-line grouping label when a mode is chosen` | Done |
|
||||
| REQ-017 | Thema: per-letter TagChip suppressed inside its own bucket; still shown in Datum/Ereignis | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/LetterCard.svelte`, `frontend/src/lib/timeline/LetterBucket.svelte` | `LetterCard.svelte.spec.ts#suppresses the per-letter tag chip when asked`, `#still shows the per-letter tag chip when not suppressed`, `LetterBucket.svelte.spec.ts#suppresses the per-letter tag chip inside its own root-tag bucket` | Done |
|
||||
| REQ-018 | Letters layer off → grouping control disabled (kept in place), mode retained | #827 | timeline-grouping-modes | `frontend/src/routes/zeitstrahl/+page.svelte`, `frontend/src/lib/timeline/GroupingControl.svelte`, `frontend/src/lib/timeline/timelineGrouping.ts#hasLooseLetters` | `zeitstrahl/page.svelte.spec.ts#disables the grouping control when the Letters layer is off`, `GroupingControl.svelte.spec.ts#retains the active mode while disabled`, `timelineGrouping.spec.ts#hasLooseLetters` | Done |
|
||||
| REQ-019 | Ereignis: letter whose only linking event was filtered off → "Weitere Briefe" (never re-introduced) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/timelineGrouping.ts#buildEventLookup`, `frontend/src/lib/timeline/TimelineView.svelte` | `timelineGrouping.spec.ts#drops a letter whose linked event is absent from the lookup into fallback` | Done |
|
||||
| REQ-020 | Grouped buckets are bound by a colour rail and carry compact cards; a bucket over `BUCKET_DENSE_THRESHOLD` (6) collapses to the month-density `YearLetterStrip` instead of flooding the timeline (redesign #847) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/LetterBucket.svelte`, `frontend/src/lib/timeline/timelineGrouping.ts#isBucketDense`, `frontend/src/lib/timeline/LetterCard.svelte` | `LetterBucket.svelte.spec.ts#collapses an oversized bucket to the density strip instead of flooding cards`, `#binds a tag bucket together with a coloured left rail from its token`, `#renders compact cards for a small bucket`, `LetterCard.svelte.spec.ts#renders the compact variant on a single tighter row` | Done |
|
||||
| REQ-020 | Grouped clusters are **contained colour-railed cards** (bordered, rounded, surface) carrying compact cards; a cluster shows the first `CLUSTER_PREVIEW` (5) letters behind a show-more toggle, and the leftover bin is a **collapsed count-only drawer** revealed on demand — the month-density `YearLetterStrip` is no longer used in grouped mode (still used in Datum dense years) (redesign #847 → #827 grouped-card layout) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/LetterBucket.svelte`, `frontend/src/lib/timeline/timelineGrouping.ts#CLUSTER_PREVIEW`, `frontend/src/lib/timeline/LetterCard.svelte` | `LetterBucket.svelte.spec.ts#renders the cluster as a contained card (bordered, rounded, surface)`, `#binds a tag bucket together with a coloured left rail from its token`, `#shows only the first 5 letters with a show-more toggle when the cluster is larger`, `#expands to all letters and collapses back on toggle`, `#renders collapsed — count + reveal, no letter cards — until opened`, `#reveals the first 5 letters when opened`, `LetterCard.svelte.spec.ts#renders the compact variant on a single tighter row` | Done |
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
# Zeitstrahl grouped-view contained-card layout — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the grouped-view's full-width bucket blocks with self-contained cards — a cluster (event or root tag) becomes one bordered card whose header is the event/tag and whose body shows the first 5 letters with a show-more toggle; the leftover bin collapses to a count-only drawer; a same-year curated event renders *as* its card header (no duplicate pill).
|
||||
|
||||
**Architecture:** Frontend-only, on branch `feat/issue-827-zeitstrahl-grouping` (worktree `.worktrees/issue-827-zeitstrahl-grouping`). Evolve `LetterBucket.svelte` into the contained card (preview cap + show-more + collapsed drawer + card chrome + optional event header) and rewire `YearBand.svelte`'s Ereignis branch so a same-year curated event becomes the card header instead of a separate pill. Datum mode is untouched. Design doc: `docs/superpowers/specs/2026-06-15-zeitstrahl-grouped-view-layout-design.md`.
|
||||
|
||||
**Tech Stack:** Svelte 5 (runes), Tailwind 4, Paraglide i18n, Vitest browser mode (`--project=client` for `*.svelte.spec.ts`, `--project=server` for plain `*.spec.ts`).
|
||||
|
||||
**Conventions (read before starting):**
|
||||
- Red→green TDD, one logical change per commit, `Refs #827` on the last body line.
|
||||
- Run only the specific spec file(s) — never the full suite. Client: `npx vitest run <file> --project=client`; server: `... --project=server`.
|
||||
- Before each commit: `npx prettier --write <changed files>`, then `git add <files>` + `git diff --cached --stat` to verify the staged set, then commit (the pre-commit hook runs `prettier --check` + `eslint`).
|
||||
- **No `new Set()`/`new Map()` inside a `.svelte` file** — `svelte/prefer-svelte-reactivity` errors even on transient locals. Use plain arrays (`find`/`some`/`filter`) inside `$derived`. (Pure `.ts` modules are fine.)
|
||||
- Prettier rewrites `class:foo` shorthand to `class:foo={foo}` — expect that.
|
||||
- Factories in `frontend/src/lib/timeline/test-factories.ts`: `makeEntry`, `makeYear`, `makeTimelineDTO`.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- `frontend/src/lib/timeline/timelineGrouping.ts` — add `CLUSTER_PREVIEW = 5`; remove `BUCKET_DENSE_THRESHOLD`/`isBucketDense` (no longer used). Keep `bucketLetters`, `buildEventLookup`, `hasLooseLetters`, `tagColorVar`.
|
||||
- `frontend/src/lib/timeline/LetterBucket.svelte` — the contained card: card chrome + colour rail + header variants (tag chip / event-header / cross-year text label / drawer label) + body (first-5 preview, show-more toggle, drawer collapsed-by-default). Drop the `YearLetterStrip` branch.
|
||||
- `frontend/src/lib/timeline/YearBand.svelte` — Ereignis branch: a same-year curated event renders as a `LetterBucket` card with `event={entry}` (no separate pill); letterless/derived/world events stay plain; cross-year clusters + the fallback drawer render after the axis entries.
|
||||
- `frontend/messages/{de,en,es}.json` — two new keys: `timeline_bucket_show_more` (`{count}`), `timeline_bucket_show_less`.
|
||||
- Specs: `LetterBucket.svelte.spec.ts`, `YearBand.svelte.spec.ts`, `messages.spec.ts` (extend), plus the route spec stays green.
|
||||
- `.specify/rtm.md` — update REQ-014/REQ-020 rows.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Preview cap + show-more toggle (drop the sparkline)
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/lib/timeline/timelineGrouping.ts`
|
||||
- Modify: `frontend/src/lib/timeline/LetterBucket.svelte`
|
||||
- Test: `frontend/src/lib/timeline/LetterBucket.svelte.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Add the i18n keys** in all three locales (so the toggle has a label).
|
||||
|
||||
`frontend/messages/de.json` (next to the existing `timeline_bucket_*` keys):
|
||||
```json
|
||||
"timeline_bucket_show_more": "+ {count} weitere Briefe anzeigen",
|
||||
"timeline_bucket_show_less": "Weniger anzeigen",
|
||||
```
|
||||
`en.json`: `"+ {count} more letters"`, `"Show fewer"`. `es.json`: `"+ {count} cartas más"`, `"Mostrar menos"`.
|
||||
Run `npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide` (from `frontend/`) or let the dev/test build compile them.
|
||||
|
||||
- [ ] **Step 2: Write the failing tests** in `LetterBucket.svelte.spec.ts` (replace the `manyLetters`-based density tests from PR #847 — the sparkline is going away).
|
||||
|
||||
```ts
|
||||
const manyLetters = (n: number) =>
|
||||
Array.from({ length: n }, (_, i) => makeEntry({ documentId: `d${i}`, eventDate: `1916-0${(i % 9) + 1}-01` }));
|
||||
|
||||
describe('LetterBucket — preview cap + show-more (#827 redesign)', () => {
|
||||
it('shows only the first 5 letters with a show-more toggle when the cluster is larger', () => {
|
||||
const bucket: Bucket = { key: 'tag:t1', kind: 'tag', title: 'Krieg', color: 'sienna', letters: manyLetters(8) };
|
||||
render(LetterBucket, { bucket, mode: 'thema', year: 1916 });
|
||||
expect(document.querySelectorAll('a.lcard')).toHaveLength(5);
|
||||
expect(document.querySelector('[data-testid="bucket-show-more"]')).not.toBeNull();
|
||||
expect(document.querySelector('[data-testid="strip-expand"]')).toBeNull(); // sparkline gone
|
||||
});
|
||||
|
||||
it('expands to all letters and collapses back on toggle', async () => {
|
||||
const bucket: Bucket = { key: 'tag:t1', kind: 'tag', title: 'Krieg', color: 'sienna', letters: manyLetters(8) };
|
||||
render(LetterBucket, { bucket, mode: 'thema', year: 1916 });
|
||||
(document.querySelector('[data-testid="bucket-show-more"]') as HTMLButtonElement).click();
|
||||
await tick();
|
||||
expect(document.querySelectorAll('a.lcard')).toHaveLength(8);
|
||||
(document.querySelector('[data-testid="bucket-show-more"]') as HTMLButtonElement).click();
|
||||
await tick();
|
||||
expect(document.querySelectorAll('a.lcard')).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('shows all letters and no toggle for a small cluster (<= 5)', () => {
|
||||
const bucket: Bucket = { key: 'tag:t1', kind: 'tag', title: 'Tod', color: null, letters: manyLetters(3) };
|
||||
render(LetterBucket, { bucket, mode: 'thema', year: 1916 });
|
||||
expect(document.querySelectorAll('a.lcard')).toHaveLength(3);
|
||||
expect(document.querySelector('[data-testid="bucket-show-more"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
```
|
||||
Add `import { tick } from 'svelte';` at the top of the spec if absent.
|
||||
|
||||
- [ ] **Step 3: Run the tests — verify they fail.** `npx vitest run src/lib/timeline/LetterBucket.svelte.spec.ts --project=client` → FAIL (still rendering the strip / all cards).
|
||||
|
||||
- [ ] **Step 4: Implement.** In `timelineGrouping.ts` remove `BUCKET_DENSE_THRESHOLD` + `isBucketDense`, add:
|
||||
```ts
|
||||
/** Letters shown before a cluster needs a "show more" toggle (#827 redesign). */
|
||||
export const CLUSTER_PREVIEW = 5;
|
||||
```
|
||||
In `LetterBucket.svelte`: remove the `YearLetterStrip` import + the `dense`/strip branch. Add expand state and a visible-slice derived; render `CLUSTER_PREVIEW` compact cards, then a toggle when there are more:
|
||||
```svelte
|
||||
import { CLUSTER_PREVIEW, tagColorVar, type LetterBucket } from './timelineGrouping';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
// ...
|
||||
let expanded = $state(false);
|
||||
const visible = $derived(expanded ? bucket.letters : bucket.letters.slice(0, CLUSTER_PREVIEW));
|
||||
const hiddenCount = $derived(bucket.letters.length - CLUSTER_PREVIEW);
|
||||
```
|
||||
Body markup (replace the `{#if dense}…{:else}…` block):
|
||||
```svelte
|
||||
<ul class="space-y-1.5">
|
||||
{#each visible as letter (entryKey(letter))}
|
||||
<li><LetterCard entry={letter} variant={cardVariant} suppressTagChip={mode === 'thema'} compact={true} /></li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if hiddenCount > 0}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="bucket-show-more"
|
||||
aria-expanded={expanded}
|
||||
onclick={() => (expanded = !expanded)}
|
||||
style="display: inline-flex; align-items: center; min-height: 44px"
|
||||
class="mt-1 px-1 font-sans text-xs font-semibold text-brand-navy hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||
>
|
||||
{expanded ? m.timeline_bucket_show_less() : m.timeline_bucket_show_more({ count: hiddenCount })}
|
||||
</button>
|
||||
{/if}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the tests — verify they pass.** Same command → PASS. Also run `src/lib/timeline/timelineGrouping.spec.ts --project=server` (still green; only constants changed).
|
||||
|
||||
- [ ] **Step 6: Commit.**
|
||||
```bash
|
||||
npx prettier --write src/lib/timeline/LetterBucket.svelte src/lib/timeline/timelineGrouping.ts src/lib/timeline/LetterBucket.svelte.spec.ts messages/de.json messages/en.json messages/es.json
|
||||
cd .. && git add frontend/src/lib/timeline/LetterBucket.svelte frontend/src/lib/timeline/timelineGrouping.ts frontend/src/lib/timeline/LetterBucket.svelte.spec.ts frontend/messages/{de,en,es}.json
|
||||
git diff --cached --stat
|
||||
git commit -m "$(printf 'feat(timeline): cap grouped clusters at 5 letters with a show-more toggle\n\nReplaces the in-bucket month-density sparkline with a first-5 preview + show-more\n/ show-less toggle, the agreed grouped-view pattern. Datum mode keeps the >12\nYearLetterStrip.\n\nRefs #827')"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Collapsed drawer for the leftover bin
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/lib/timeline/LetterBucket.svelte`
|
||||
- Test: `frontend/src/lib/timeline/LetterBucket.svelte.spec.ts`
|
||||
|
||||
The fallback bucket (`kind === 'fallback'` — "Weitere Briefe"/"Ohne Thema") is a junk drawer: render it **collapsed** (count only, no letters) until the user reveals it; revealing shows the same first-5 + show-more body.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests.**
|
||||
```ts
|
||||
describe('LetterBucket — leftover drawer (#827 redesign)', () => {
|
||||
const fb = (n: number, mode: 'event' | 'thema'): Bucket => ({
|
||||
key: '__fallback__', kind: 'fallback', color: null,
|
||||
letters: Array.from({ length: n }, (_, i) => makeEntry({ documentId: `f${i}`, eventDate: `1916-01-0${(i % 9) + 1}` }))
|
||||
});
|
||||
it('renders collapsed — count + reveal, no letter cards — until opened', () => {
|
||||
render(LetterBucket, { bucket: fb(20, 'event'), mode: 'event', year: 1916 });
|
||||
expect(document.querySelector('a.lcard')).toBeNull();
|
||||
expect(document.body.textContent).toContain(m.timeline_bucket_other_letters());
|
||||
expect(document.querySelector('[data-testid="bucket-reveal"]')).not.toBeNull();
|
||||
});
|
||||
it('reveals the first 5 letters when opened', async () => {
|
||||
render(LetterBucket, { bucket: fb(20, 'event'), mode: 'event', year: 1916 });
|
||||
(document.querySelector('[data-testid="bucket-reveal"]') as HTMLButtonElement).click();
|
||||
await tick();
|
||||
expect(document.querySelectorAll('a.lcard')).toHaveLength(5);
|
||||
expect(document.querySelector('[data-testid="bucket-show-more"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — verify fail.** `npx vitest run src/lib/timeline/LetterBucket.svelte.spec.ts --project=client` → FAIL.
|
||||
|
||||
- [ ] **Step 3: Implement.** In `LetterBucket.svelte` add a `revealed` state defaulting to `bucket.kind !== 'fallback'` (non-drawers start open). Gate the body on it; when collapsed, render only the header + a reveal button:
|
||||
```svelte
|
||||
let revealed = $state(bucket.kind !== 'fallback');
|
||||
// header always renders; body only when revealed
|
||||
```
|
||||
Collapsed drawer markup (when `!revealed`): the fallback label + count already render in the header; add the reveal control:
|
||||
```svelte
|
||||
{#if !revealed}
|
||||
<button type="button" data-testid="bucket-reveal" onclick={() => (revealed = true)}
|
||||
style="display:inline-flex;align-items:center;min-height:44px"
|
||||
class="px-1 font-sans text-xs font-semibold text-brand-navy hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy">
|
||||
{m.timeline_bucket_show_more({ count: bucket.letters.length })}
|
||||
</button>
|
||||
{:else}
|
||||
<!-- the Task-1 body (ul + show-more) -->
|
||||
{/if}
|
||||
```
|
||||
Give the drawer a dashed neutral rail: add `class:border-dashed={bucket.kind === 'fallback'}` to the card.
|
||||
|
||||
- [ ] **Step 4: Run — verify pass.** Same command → PASS. Re-run the Task-1 tests too (still green).
|
||||
|
||||
- [ ] **Step 5: Commit.**
|
||||
```bash
|
||||
npx prettier --write src/lib/timeline/LetterBucket.svelte src/lib/timeline/LetterBucket.svelte.spec.ts
|
||||
cd .. && git add frontend/src/lib/timeline/LetterBucket.svelte frontend/src/lib/timeline/LetterBucket.svelte.spec.ts
|
||||
git diff --cached --stat
|
||||
git commit -m "$(printf 'feat(timeline): collapse the leftover Weitere-Briefe/Ohne-Thema bin to a drawer\n\nThe catch-all bucket renders count-only by default behind a reveal control, then\nexpands to the first-5 + show-more body. Keeps the junk drawer quiet instead of\nflooding the timeline.\n\nRefs #827')"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Card chrome (the cluster is one contained card)
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/lib/timeline/LetterBucket.svelte`
|
||||
- Test: `frontend/src/lib/timeline/LetterBucket.svelte.spec.ts`
|
||||
|
||||
Turn the `<section>` (rail-only, from PR #847) into a bordered card: `rounded` + `border border-line` + `bg-surface` + `shadow-sm`, keeping the coloured left rail (mint for event cluster, tag colour for tag, dashed neutral for the drawer). Header on a subtle `bg-canvas`/tint bar.
|
||||
|
||||
- [ ] **Step 1: Write the failing test.**
|
||||
```ts
|
||||
it('renders the cluster as a contained card (bordered, rounded, surface)', () => {
|
||||
const bucket: Bucket = { key: 'tag:t1', kind: 'tag', title: 'Krieg', color: 'sienna', letters: [makeEntry({ documentId: 'a' })] };
|
||||
render(LetterBucket, { bucket, mode: 'thema', year: 1916 });
|
||||
const card = document.querySelector('[data-testid="letter-bucket"]') as HTMLElement;
|
||||
expect(card.className).toMatch(/\brounded\b|rounded-/);
|
||||
expect(card.className).toContain('border');
|
||||
expect(card.className).toContain('bg-surface');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — verify fail.** → FAIL (current section is `my-3 border-l-2 pl-3`, no `bg-surface`/`rounded`/full `border`).
|
||||
|
||||
- [ ] **Step 3: Implement.** Update the `<section data-testid="letter-bucket">` classes to e.g.:
|
||||
```
|
||||
class="my-3 overflow-hidden rounded-md border border-line border-l-2 bg-surface shadow-sm"
|
||||
```
|
||||
keep `class:border-l-brand-mint={isEventCluster}`, `class:border-dashed={bucket.kind==='fallback'}`, and the inline `style={railStyle}` for the tag colour. Move the body padding inside (e.g. wrap header + body in a `px-3 py-2`), and give the header a tint bar (`bg-canvas` for events, plain for the drawer). Verify the existing "coloured left rail" test (`expect(section.style).toContain('var(--c-tag-sienna)')`) still holds — keep `railStyle` on the section.
|
||||
|
||||
- [ ] **Step 4: Run — verify pass.** Run the whole `LetterBucket.svelte.spec.ts` → all PASS (including the PR #847 rail test).
|
||||
|
||||
- [ ] **Step 5: Commit.**
|
||||
```bash
|
||||
npx prettier --write src/lib/timeline/LetterBucket.svelte src/lib/timeline/LetterBucket.svelte.spec.ts
|
||||
cd .. && git add frontend/src/lib/timeline/LetterBucket.svelte frontend/src/lib/timeline/LetterBucket.svelte.spec.ts
|
||||
git diff --cached --stat
|
||||
git commit -m "$(printf 'feat(timeline): make a grouped cluster one contained card\n\nWraps each cluster in a bordered, rounded surface card (keeping the colour rail)\nso the header and its letters read as a single unit.\n\nRefs #827')"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Same-year curated event becomes the card header (kills the duplicate)
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/lib/timeline/LetterBucket.svelte` (add `event` + `canWrite` props + event-header rendering)
|
||||
- Modify: `frontend/src/lib/timeline/YearBand.svelte` (render the card in place of the pill for a same-year curated event)
|
||||
- Test: `frontend/src/lib/timeline/LetterBucket.svelte.spec.ts`, `frontend/src/lib/timeline/YearBand.svelte.spec.ts`
|
||||
|
||||
When a curated event has letters in the same band, the event IS the card header — no separate pill. Reuse `getAccentConfig` (glyph/label) + `timelineDateLabel` + the `kuratiert/abgeleitet` provenance + the `✎` edit affordance (curated + eventId + canWrite), mirroring `EventPill.svelte`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests.**
|
||||
|
||||
`LetterBucket.svelte.spec.ts`:
|
||||
```ts
|
||||
import { getAccentConfig } from './eventCardConfig';
|
||||
it('renders the curated event as the card header when given an `event` (no separate pill)', () => {
|
||||
const event = makeEntry({ kind: 'EVENT', type: 'PERSONAL', derived: false, eventId: 'e1',
|
||||
title: 'Ein gewaltiger Stadtbrand', eventDate: '1916-07-06', senderName: '', receiverName: '', documentId: undefined });
|
||||
const bucket: Bucket = { key: 'event:e1', kind: 'event', title: 'Ein gewaltiger Stadtbrand', color: null,
|
||||
letters: [makeEntry({ documentId: 'a', linkedEventId: 'e1' })] };
|
||||
render(LetterBucket, { bucket, mode: 'event', year: 1916, event, canWrite: true });
|
||||
const header = document.querySelector('[data-testid="bucket-event-header"]') as HTMLElement;
|
||||
expect(header.textContent).toContain('Ein gewaltiger Stadtbrand');
|
||||
expect(header.textContent).toContain(m.timeline_provenance_curated());
|
||||
expect(document.querySelector('[data-testid="event-edit"]')?.getAttribute('href')).toBe('/zeitstrahl/events/e1/edit');
|
||||
});
|
||||
it('shows no edit affordance in the header when canWrite is false', () => {
|
||||
const event = makeEntry({ kind: 'EVENT', type: 'PERSONAL', derived: false, eventId: 'e1', title: 'X', senderName: '', receiverName: '', documentId: undefined });
|
||||
const bucket: Bucket = { key: 'event:e1', kind: 'event', title: 'X', color: null, letters: [makeEntry({ documentId: 'a' })] };
|
||||
render(LetterBucket, { bucket, mode: 'event', year: 1916, event, canWrite: false });
|
||||
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
|
||||
});
|
||||
```
|
||||
|
||||
`YearBand.svelte.spec.ts` (replace/extend the PR #847 "nests an event cluster under its pill" test — the pill is now the card header):
|
||||
```ts
|
||||
it('renders a same-year curated event as one card header, with no separate pill and no duplicate title', () => {
|
||||
const pill = makeEntry({ kind: 'EVENT', type: 'PERSONAL', derived: false, eventId: 'e1',
|
||||
title: 'Ein gewaltiger Stadtbrand', eventDate: '1916-07-06', senderName: '', receiverName: '', documentId: undefined });
|
||||
const letter = makeEntry({ documentId: 'a', linkedEventId: 'e1', eventDate: '1916-07-06' });
|
||||
render(YearBand, { year: makeYear(1916, [pill, letter]), groupingMode: 'event', eventLookup: new Map([['e1', 'Ein gewaltiger Stadtbrand']]), canWrite: true });
|
||||
const occurrences = (document.body.textContent ?? '').split('Ein gewaltiger Stadtbrand').length - 1;
|
||||
expect(occurrences).toBe(1); // once — in the card header
|
||||
expect(document.querySelector('[data-testid="bucket-event-header"]')).not.toBeNull();
|
||||
expect(document.querySelector('a.lcard.ev')).not.toBeNull(); // its letter, inside
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — verify fail.** Both specs → FAIL.
|
||||
|
||||
- [ ] **Step 3: Implement `LetterBucket` header.** Add props `event?: TimelineEntryDTO` and `canWrite = false`. Derive (mirroring `EventPill.svelte`):
|
||||
```svelte
|
||||
import { getAccentConfig } from './eventCardConfig';
|
||||
import { timelineDateLabel } from './dateLabel';
|
||||
// ...
|
||||
const accent = $derived(event ? getAccentConfig(event) : null);
|
||||
const eventDateLabel = $derived(event ? timelineDateLabel(event.eventDate, event.precision, event.eventDateEnd) : null);
|
||||
const provenance = $derived(event?.derived ? m.timeline_provenance_derived() : m.timeline_provenance_curated());
|
||||
const eventSubtitle = $derived(eventDateLabel ? `${eventDateLabel} · ${provenance}` : provenance);
|
||||
const canEdit = $derived(canWrite && !!event && !event.derived && event.eventId != null);
|
||||
```
|
||||
Header branch order: `if (event)` → event header (`data-testid="bucket-event-header"`: glyph from `accent.glyph` aria-hidden + sr-only `accent.label`, `event.title`, `eventSubtitle`, count, and the `✎` link `/zeitstrahl/events/{event.eventId}/edit` with `data-testid="event-edit"` when `canEdit`); else the existing `thema/tag` chip / `fallback` label branches.
|
||||
|
||||
- [ ] **Step 4: Implement `YearBand` Ereignis branch.** When a curated event entry has a matching same-year bucket, render the card *instead of* the pill, passing `event={entry}` + `canWrite`; do **not** also push the `{ t: 'event' }` pill row for it. Letterless/derived/world events still push their pill/band row. Sketch:
|
||||
```svelte
|
||||
if (groupingMode === 'event') {
|
||||
const buckets = bucketLetters(letters, 'event', eventLookup);
|
||||
const sameYear = (id) => buckets.find((b) => b.kind === 'event' && b.key === `event:${id}`);
|
||||
for (const entry of year.entries) {
|
||||
if (entry.kind !== 'EVENT') continue;
|
||||
const bucket = entry.eventId ? sameYear(entry.eventId) : undefined;
|
||||
if (bucket) out.push({ t: 'eventcard', entry, bucket }); // card replaces pill
|
||||
else out.push({ t: 'event', entry }); // plain pill/band
|
||||
}
|
||||
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 }); // cross-year cluster / drawer
|
||||
}
|
||||
return out;
|
||||
}
|
||||
```
|
||||
Add a `Row` variant `{ t: 'eventcard'; entry: TimelineEntryDTO; bucket: LetterBucketModel }` and template branch:
|
||||
```svelte
|
||||
{:else if row.t === 'eventcard'}
|
||||
<LetterBucket bucket={row.bucket} mode="event" year={year.year} event={row.entry} canWrite={canWrite} />
|
||||
```
|
||||
Keep the existing `{ t: 'bucket' }` branch (cross-year clusters + drawer) rendering `<LetterBucket … nested={false} />` with no `event` prop → text header. Remember: **no `new Map`/`Set` in the component** — use `buckets.find` / `year.entries.some` as above.
|
||||
|
||||
- [ ] **Step 5: Run — verify pass.** `npx vitest run src/lib/timeline/LetterBucket.svelte.spec.ts src/lib/timeline/YearBand.svelte.spec.ts src/lib/timeline/grouping-event-layer-identity.svelte.spec.ts --project=client` → PASS. The identity spec (REQ-001) still passes because derived/world fixtures are unchanged; if a now-stale assertion expects a pill for a *curated-with-letters* event, update it to expect the card header (REQ-001 amendment) and note it in the commit.
|
||||
|
||||
- [ ] **Step 6: Commit.**
|
||||
```bash
|
||||
npx prettier --write src/lib/timeline/LetterBucket.svelte src/lib/timeline/YearBand.svelte src/lib/timeline/LetterBucket.svelte.spec.ts src/lib/timeline/YearBand.svelte.spec.ts
|
||||
cd .. && git add frontend/src/lib/timeline/LetterBucket.svelte frontend/src/lib/timeline/YearBand.svelte frontend/src/lib/timeline/LetterBucket.svelte.spec.ts frontend/src/lib/timeline/YearBand.svelte.spec.ts
|
||||
git diff --cached --stat
|
||||
git commit -m "$(printf 'feat(timeline): render a same-year curated event as its cluster card header\n\nA curated event with letters in its own band now becomes the contained card header\n(glyph, title, date, provenance, edit pencil) instead of a separate floating pill —\nthe title reads once. Derived life-events, world-bands, and letterless event pills\nare unchanged (REQ-001 amended for curated-with-letters).\n\nRefs #827')"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Regression sweep + route view + docs
|
||||
|
||||
**Files:**
|
||||
- Verify: route + cross-year + thema specs
|
||||
- Modify: `.specify/rtm.md`
|
||||
|
||||
- [ ] **Step 1: Run the affected specs (client).**
|
||||
```
|
||||
npx vitest run \
|
||||
src/lib/timeline/BucketHeaderChip.svelte.spec.ts src/lib/timeline/LetterCard.svelte.spec.ts \
|
||||
src/lib/timeline/LetterBucket.svelte.spec.ts src/lib/timeline/YearBand.svelte.spec.ts \
|
||||
src/lib/timeline/GroupingControl.svelte.spec.ts src/lib/timeline/grouping-event-layer-identity.svelte.spec.ts \
|
||||
src/lib/timeline/TimelineView.svelte.spec.ts src/routes/zeitstrahl/page.svelte.spec.ts --project=client
|
||||
```
|
||||
Expected: all PASS. Fix any cross-year/thema spec that assumed the old header (it should now find the card; thema card header is still the `BucketHeaderChip`).
|
||||
|
||||
- [ ] **Step 2: Run the server specs.** `npx vitest run src/lib/timeline/timelineGrouping.spec.ts src/lib/timeline/timeline-no-raw-html.spec.ts src/lib/messages.spec.ts --project=server` → PASS. If `messages.spec.ts` parity fails, it's the two new keys — they must be in de/en/es.
|
||||
|
||||
- [ ] **Step 3: Type-check the changed files.** `npm run check 2>&1 | grep -E "LetterBucket|YearBand|timelineGrouping"` → no ERROR lines (baseline noise elsewhere is fine).
|
||||
|
||||
- [ ] **Step 4: Update the RTM.** In `.specify/rtm.md`, edit REQ-014 (event-clustered letters live inside a contained card whose header is the same-year event; first-5 + show-more) and REQ-020 (clusters are contained cards with a 5-letter preview + show-more; the leftover bin is a collapsed drawer; the sparkline is no longer used in grouped mode), citing the new tests.
|
||||
|
||||
- [ ] **Step 5: Commit.**
|
||||
```bash
|
||||
cd .. && git add .specify/rtm.md
|
||||
git diff --cached --stat
|
||||
git commit -m "$(printf 'docs(rtm): trace the grouped-view contained-card layout (#827)\n\nRefs #827')"
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Push.** `git push origin feat/issue-827-zeitstrahl-grouping`
|
||||
|
||||
---
|
||||
|
||||
## Self-Review notes (author)
|
||||
|
||||
- **Spec coverage:** contained card (Task 3) ✓; first-5 + show-more (Task 1) ✓; collapsed drawer (Task 2) ✓; same-year event → card header / no duplicate (Task 4) ✓; derived/world unchanged (Task 4 keeps plain rows) ✓; thema chip header reused (existing, verified Task 5) ✓; cross-year text header (existing `{t:'bucket'}` path, verified Task 5) ✓; sparkline dropped from grouped mode (Task 1) ✓.
|
||||
- **Naming consistency:** `CLUSTER_PREVIEW` (Task 1) used in Tasks 1–2; testids `bucket-show-more` / `bucket-reveal` / `bucket-event-header` / `event-edit` consistent across tasks; `Row` variant `eventcard` defined and consumed in Task 4.
|
||||
- **REQ-001 amendment** is intentional and documented in the spec; Task 4 Step 5 flags fixing any stale identity assertion.
|
||||
@@ -1063,6 +1063,8 @@
|
||||
"timeline_grouping_multitag_hint": "Brief mit mehreren Tags erscheint unter seinem primären Tag.",
|
||||
"timeline_bucket_other_letters": "Weitere Briefe",
|
||||
"timeline_bucket_no_topic": "Ohne Thema",
|
||||
"timeline_bucket_show_more": "+ {count} weitere Briefe anzeigen",
|
||||
"timeline_bucket_show_less": "Weniger anzeigen",
|
||||
"timeline_provenance_derived": "abgeleitet",
|
||||
"timeline_provenance_curated": "kuratiert",
|
||||
"timeline_letter_glyph_label": "Brief",
|
||||
|
||||
@@ -1063,6 +1063,8 @@
|
||||
"timeline_grouping_multitag_hint": "A letter with several tags appears under its primary tag.",
|
||||
"timeline_bucket_other_letters": "More letters",
|
||||
"timeline_bucket_no_topic": "No topic",
|
||||
"timeline_bucket_show_more": "+ {count} more letters",
|
||||
"timeline_bucket_show_less": "Show fewer",
|
||||
"timeline_provenance_derived": "derived",
|
||||
"timeline_provenance_curated": "curated",
|
||||
"timeline_letter_glyph_label": "Letter",
|
||||
|
||||
@@ -1063,6 +1063,8 @@
|
||||
"timeline_grouping_multitag_hint": "Una carta con varias etiquetas aparece bajo su etiqueta principal.",
|
||||
"timeline_bucket_other_letters": "Más cartas",
|
||||
"timeline_bucket_no_topic": "Sin tema",
|
||||
"timeline_bucket_show_more": "+ {count} cartas más",
|
||||
"timeline_bucket_show_less": "Mostrar menos",
|
||||
"timeline_provenance_derived": "derivado",
|
||||
"timeline_provenance_curated": "curado",
|
||||
"timeline_letter_glyph_label": "Carta",
|
||||
|
||||
@@ -2,83 +2,194 @@
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import LetterCard from './LetterCard.svelte';
|
||||
import BucketHeaderChip from './BucketHeaderChip.svelte';
|
||||
import YearLetterStrip from './YearLetterStrip.svelte';
|
||||
import { entryKey } from './entryKey';
|
||||
import { isBucketDense, tagColorVar, type LetterBucket } from './timelineGrouping';
|
||||
import { getAccentConfig } from './eventCardConfig';
|
||||
import { timelineDateLabel } from './dateLabel';
|
||||
import { CLUSTER_PREVIEW, tagColorVar, type LetterBucket } from './timelineGrouping';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
|
||||
|
||||
/**
|
||||
* One cluster of loose letters, bound together by a coloured left rail so the group reads as a
|
||||
* unit (#827). The axis-fixed event/world-band layers are rendered elsewhere — this is only the
|
||||
* unit (#827). The axis-fixed world-band layer is rendered elsewhere — this is only the
|
||||
* loose-letter bundling.
|
||||
*
|
||||
* - Thema: a tinted root-tag header chip + a rail in the tag colour, over compact cards whose own
|
||||
* tag chip is suppressed (REQ-004/015/017).
|
||||
* - Ereignis: rendered `nested` directly beneath its event pill — no header (the pill is the
|
||||
* header), a mint rail, `.lcard.ev` cards (REQ-003/014). The standalone "Weitere Briefe" /
|
||||
* "Ohne Thema" fallback keeps its label and a neutral rail (REQ-006/007).
|
||||
* - Ereignis: a same-year curated `event` becomes the card header (glyph, title, date,
|
||||
* provenance, edit pencil) so its title reads once — no separate floating pill (#827 redesign,
|
||||
* REQ-001/014). A cross-year cluster keeps a plain text header. The standalone "Weitere Briefe"
|
||||
* / "Ohne Thema" fallback keeps its label and a neutral dashed rail (REQ-006/007).
|
||||
*
|
||||
* A bucket larger than the density threshold collapses to the month-density `YearLetterStrip`
|
||||
* instead of flooding the timeline with every card (#827) — the catch-all buckets are the biggest.
|
||||
* A cluster shows its first `CLUSTER_PREVIEW` letters, then a show-more toggle reveals the rest
|
||||
* instead of flooding the timeline with every card (#827 redesign).
|
||||
*/
|
||||
let {
|
||||
bucket,
|
||||
mode,
|
||||
// `year` is the band's year — accepted for the cross-year label card seam (#827) but no
|
||||
// longer consumed here now the in-bucket month-density strip is gone (the year frames the
|
||||
// time from the band heading). Kept in the prop contract for callers/tests.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
year = 0,
|
||||
nested = false
|
||||
}: { bucket: LetterBucket; mode: 'event' | 'thema'; year?: number; nested?: boolean } = $props();
|
||||
nested = false,
|
||||
event = undefined,
|
||||
canWrite = false
|
||||
}: {
|
||||
bucket: LetterBucket;
|
||||
mode: 'event' | 'thema';
|
||||
year?: number;
|
||||
nested?: boolean;
|
||||
/** The same-year curated event whose letters this card holds — renders as the header. */
|
||||
event?: TimelineEntryDTO;
|
||||
canWrite?: boolean;
|
||||
} = $props();
|
||||
|
||||
const count = $derived(bucket.letters.length);
|
||||
const dense = $derived(isBucketDense(count));
|
||||
const fallbackLabel = $derived(
|
||||
mode === 'event' ? m.timeline_bucket_other_letters() : m.timeline_bucket_no_topic()
|
||||
);
|
||||
|
||||
// Event-as-header (#827 redesign): a same-year curated event renders as this card's header,
|
||||
// mirroring EventPill — glyph + title + date · provenance + an edit pencil for a curator. The
|
||||
// title is never repeated as a separate floating pill.
|
||||
const accent = $derived(event ? getAccentConfig(event) : null);
|
||||
const eventDateLabel = $derived(
|
||||
event ? timelineDateLabel(event.eventDate, event.precision, event.eventDateEnd) : null
|
||||
);
|
||||
const provenance = $derived(
|
||||
event?.derived ? m.timeline_provenance_derived() : m.timeline_provenance_curated()
|
||||
);
|
||||
const eventSubtitle = $derived(eventDateLabel ? `${eventDateLabel} · ${provenance}` : provenance);
|
||||
const canEdit = $derived(canWrite && !!event && !event.derived && event.eventId != null);
|
||||
// The left rail binds the cluster: the tag colour in Thema, mint for an Ereignis cluster,
|
||||
// neutral for the fallback (and for a colourless/unknown tag token).
|
||||
const railColor = $derived(bucket.kind === 'tag' ? tagColorVar(bucket.color) : null);
|
||||
const railStyle = $derived(railColor ? `border-left-color: ${railColor}` : '');
|
||||
const isEventCluster = $derived(nested || bucket.kind === 'event');
|
||||
const cardVariant = $derived(mode === 'event' && isEventCluster ? 'event' : 'plain');
|
||||
|
||||
// First-5 preview + show-more (#827 redesign): a large cluster stays readable instead of
|
||||
// dumping every card into the timeline.
|
||||
let expanded = $state(false);
|
||||
const visible = $derived(expanded ? bucket.letters : bucket.letters.slice(0, CLUSTER_PREVIEW));
|
||||
const hiddenCount = $derived(bucket.letters.length - CLUSTER_PREVIEW);
|
||||
|
||||
// The catch-all "Weitere Briefe" / "Ohne Thema" bin is a junk drawer: render it count-only
|
||||
// behind a reveal control so it never floods the timeline; every other cluster starts open
|
||||
// (#827 redesign). The view re-creates a bucket per `{#each}` key, so the initial capture is
|
||||
// the right lifetime — `revealed` belongs to this bucket instance.
|
||||
const isDrawer = $derived(bucket.kind === 'fallback');
|
||||
// svelte-ignore state_referenced_locally
|
||||
let revealed = $state(bucket.kind !== 'fallback');
|
||||
</script>
|
||||
|
||||
<section
|
||||
class="my-3 border-l-2 pl-3"
|
||||
class="my-3 overflow-hidden rounded-md border border-l-2 border-line bg-surface shadow-sm"
|
||||
class:border-l-brand-mint={isEventCluster}
|
||||
class:border-line={!railColor && !isEventCluster}
|
||||
class:border-dashed={isDrawer}
|
||||
style={railStyle}
|
||||
data-testid="letter-bucket"
|
||||
data-bucket-kind={bucket.kind}
|
||||
>
|
||||
{#if !nested}
|
||||
<header class="mb-2 flex items-center gap-2">
|
||||
{#if mode === 'thema' && bucket.kind === 'tag'}
|
||||
<BucketHeaderChip name={bucket.title ?? ''} color={bucket.color} />
|
||||
{:else if mode === 'event' && bucket.kind === 'event'}
|
||||
<span class="font-serif text-sm font-bold text-ink">
|
||||
<span aria-hidden="true">✉</span>
|
||||
{bucket.title}
|
||||
{#if event && accent}
|
||||
<!-- A same-year curated event IS the card header — its title reads once here, never
|
||||
also as a floating pill (#827 redesign, REQ-001/014). Glyph is aria-hidden with an
|
||||
sr-only label sibling (REQ-018); the edit pencil mirrors EventPill's gate. -->
|
||||
<header
|
||||
data-testid="bucket-event-header"
|
||||
class="flex items-center gap-2 border-b border-line bg-canvas px-3 py-2"
|
||||
>
|
||||
<span
|
||||
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full {accent.accent ===
|
||||
'curated'
|
||||
? 'bg-brand-mint text-brand-navy'
|
||||
: 'bg-brand-navy text-brand-mint'}"
|
||||
>
|
||||
<span aria-hidden="true">{accent.glyph}</span>
|
||||
<span class="sr-only">{accent.label}</span>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="font-sans text-xs font-semibold text-ink-3">{fallbackLabel}</span>
|
||||
{/if}
|
||||
<span data-testid="bucket-count" class="font-sans text-xs text-ink-3">· {count}</span>
|
||||
</header>
|
||||
<span class="min-w-0 text-left">
|
||||
<span class="block font-serif text-sm font-bold whitespace-pre-line text-brand-navy"
|
||||
>{event.title}</span
|
||||
>
|
||||
<span class="block font-sans text-xs text-ink-3">
|
||||
{eventSubtitle} <span data-testid="bucket-count">· {count}</span>
|
||||
</span>
|
||||
</span>
|
||||
{#if canEdit}
|
||||
<a
|
||||
data-testid="event-edit"
|
||||
href="/zeitstrahl/events/{event.eventId}/edit"
|
||||
class="ml-auto rounded-sm px-1 font-sans text-xs text-ink-3 hover:text-brand-navy focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||
>
|
||||
<span aria-hidden="true">✎</span>
|
||||
<span class="sr-only">{m.btn_edit()}</span>
|
||||
</a>
|
||||
{/if}
|
||||
</header>
|
||||
{:else}
|
||||
<header
|
||||
class="flex items-center gap-2 px-3 py-2"
|
||||
class:bg-canvas={isEventCluster}
|
||||
class:border-b={!isDrawer}
|
||||
class:border-line={!isDrawer}
|
||||
>
|
||||
{#if mode === 'thema' && bucket.kind === 'tag'}
|
||||
<BucketHeaderChip name={bucket.title ?? ''} color={bucket.color} />
|
||||
{:else if mode === 'event' && bucket.kind === 'event'}
|
||||
<span class="font-serif text-sm font-bold text-ink">
|
||||
<span aria-hidden="true">✉</span>
|
||||
{bucket.title}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="font-sans text-xs font-semibold text-ink-3">{fallbackLabel}</span>
|
||||
{/if}
|
||||
<span data-testid="bucket-count" class="font-sans text-xs text-ink-3">· {count}</span>
|
||||
</header>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if dense}
|
||||
<!-- Oversized bucket → the month-density strip (count + sparkline + expand), not a flood. -->
|
||||
<YearLetterStrip letters={bucket.letters} year={year} />
|
||||
{:else}
|
||||
<ul class="space-y-1.5">
|
||||
{#each bucket.letters as letter (entryKey(letter))}
|
||||
<li>
|
||||
<LetterCard
|
||||
entry={letter}
|
||||
variant={cardVariant}
|
||||
suppressTagChip={mode === 'thema'}
|
||||
compact={true}
|
||||
/>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
<div class="px-3 py-2">
|
||||
{#if !revealed}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="bucket-reveal"
|
||||
onclick={() => (revealed = true)}
|
||||
style="display: inline-flex; align-items: center; min-height: 44px"
|
||||
class="px-1 font-sans text-xs font-semibold text-brand-navy hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||
>
|
||||
{m.timeline_bucket_show_more({ count: bucket.letters.length })}
|
||||
</button>
|
||||
{:else}
|
||||
<ul class="space-y-1.5">
|
||||
{#each visible as letter (entryKey(letter))}
|
||||
<li>
|
||||
<LetterCard
|
||||
entry={letter}
|
||||
variant={cardVariant}
|
||||
suppressTagChip={mode === 'thema'}
|
||||
compact={true}
|
||||
/>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if hiddenCount > 0}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="bucket-show-more"
|
||||
aria-expanded={expanded}
|
||||
onclick={() => (expanded = !expanded)}
|
||||
style="display: inline-flex; align-items: center; min-height: 44px"
|
||||
class="mt-1 px-1 font-sans text-xs font-semibold text-brand-navy hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||
>
|
||||
{expanded
|
||||
? m.timeline_bucket_show_less()
|
||||
: m.timeline_bucket_show_more({ count: hiddenCount })}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { tick } from 'svelte';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import LetterBucket from './LetterBucket.svelte';
|
||||
import { makeEntry } from './test-factories';
|
||||
@@ -78,47 +79,49 @@ const manyLetters = (n: number) =>
|
||||
makeEntry({ documentId: `d${i}`, eventDate: `1916-0${(i % 9) + 1}-01` })
|
||||
);
|
||||
|
||||
describe('LetterBucket — density + containment (#827)', () => {
|
||||
it('collapses an oversized bucket to the density strip instead of flooding cards', () => {
|
||||
describe('LetterBucket — preview cap + show-more (#827 redesign)', () => {
|
||||
it('shows only the first 5 letters with a show-more toggle when the cluster is larger', () => {
|
||||
const bucket: Bucket = {
|
||||
key: 'tag:t1',
|
||||
kind: 'tag',
|
||||
title: 'Sonstiges',
|
||||
color: null,
|
||||
letters: manyLetters(10)
|
||||
title: 'Krieg',
|
||||
color: 'sienna',
|
||||
letters: manyLetters(8)
|
||||
};
|
||||
render(LetterBucket, { bucket, mode: 'thema', year: 1916 });
|
||||
expect(document.querySelector('[data-testid="strip-expand"]')).not.toBeNull();
|
||||
// not ten individual cards dumped into the timeline
|
||||
expect(document.querySelectorAll('a.lcard')).toHaveLength(0);
|
||||
expect(document.querySelectorAll('a.lcard')).toHaveLength(5);
|
||||
expect(document.querySelector('[data-testid="bucket-show-more"]')).not.toBeNull();
|
||||
expect(document.querySelector('[data-testid="strip-expand"]')).toBeNull(); // sparkline gone
|
||||
});
|
||||
|
||||
it('renders compact cards for a small bucket (no strip)', () => {
|
||||
it('expands to all letters and collapses back on toggle', async () => {
|
||||
const bucket: Bucket = {
|
||||
key: 'tag:t1',
|
||||
kind: 'tag',
|
||||
title: 'Krieg',
|
||||
color: 'sienna',
|
||||
letters: manyLetters(8)
|
||||
};
|
||||
render(LetterBucket, { bucket, mode: 'thema', year: 1916 });
|
||||
(document.querySelector('[data-testid="bucket-show-more"]') as HTMLButtonElement).click();
|
||||
await tick();
|
||||
expect(document.querySelectorAll('a.lcard')).toHaveLength(8);
|
||||
(document.querySelector('[data-testid="bucket-show-more"]') as HTMLButtonElement).click();
|
||||
await tick();
|
||||
expect(document.querySelectorAll('a.lcard')).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('shows all letters and no toggle for a small cluster (<= 5)', () => {
|
||||
const bucket: Bucket = {
|
||||
key: 'tag:t1',
|
||||
kind: 'tag',
|
||||
title: 'Tod',
|
||||
color: null,
|
||||
letters: manyLetters(2)
|
||||
letters: manyLetters(3)
|
||||
};
|
||||
render(LetterBucket, { bucket, mode: 'thema', year: 1916 });
|
||||
expect(document.querySelector('[data-testid="strip-expand"]')).toBeNull();
|
||||
expect(document.querySelectorAll('a.lcard.compact')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('omits the header when nested — the event pill above is the header', () => {
|
||||
const bucket: Bucket = {
|
||||
key: 'event:e1',
|
||||
kind: 'event',
|
||||
title: 'Ein gewaltiger Stadtbrand',
|
||||
color: null,
|
||||
letters: manyLetters(1)
|
||||
};
|
||||
render(LetterBucket, { bucket, mode: 'event', nested: true, year: 1916 });
|
||||
expect(document.querySelector('[data-testid="bucket-count"]')).toBeNull();
|
||||
expect(document.body.textContent).not.toContain('Ein gewaltiger Stadtbrand');
|
||||
// still the event-letter variant, just headerless under its pill
|
||||
expect(document.querySelector('a.lcard.ev')).not.toBeNull();
|
||||
expect(document.querySelectorAll('a.lcard')).toHaveLength(3);
|
||||
expect(document.querySelector('[data-testid="bucket-show-more"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('binds a tag bucket together with a coloured left rail from its token', () => {
|
||||
@@ -134,3 +137,96 @@ describe('LetterBucket — density + containment (#827)', () => {
|
||||
expect(section.getAttribute('style') ?? '').toContain('var(--c-tag-sienna)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('LetterBucket — leftover drawer (#827 redesign)', () => {
|
||||
const fb = (n: number): Bucket => ({
|
||||
key: '__fallback__',
|
||||
kind: 'fallback',
|
||||
color: null,
|
||||
letters: Array.from({ length: n }, (_, i) =>
|
||||
makeEntry({ documentId: `f${i}`, eventDate: `1916-01-0${(i % 9) + 1}` })
|
||||
)
|
||||
});
|
||||
it('renders collapsed — count + reveal, no letter cards — until opened', () => {
|
||||
render(LetterBucket, { bucket: fb(20), mode: 'event', year: 1916 });
|
||||
expect(document.querySelector('a.lcard')).toBeNull();
|
||||
expect(document.body.textContent).toContain(m.timeline_bucket_other_letters());
|
||||
expect(document.querySelector('[data-testid="bucket-reveal"]')).not.toBeNull();
|
||||
});
|
||||
it('reveals the first 5 letters when opened', async () => {
|
||||
render(LetterBucket, { bucket: fb(20), mode: 'event', year: 1916 });
|
||||
(document.querySelector('[data-testid="bucket-reveal"]') as HTMLButtonElement).click();
|
||||
await tick();
|
||||
expect(document.querySelectorAll('a.lcard')).toHaveLength(5);
|
||||
expect(document.querySelector('[data-testid="bucket-show-more"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('LetterBucket — card chrome (#827 redesign)', () => {
|
||||
it('renders the cluster as a contained card (bordered, rounded, surface)', () => {
|
||||
const bucket: Bucket = {
|
||||
key: 'tag:t1',
|
||||
kind: 'tag',
|
||||
title: 'Krieg',
|
||||
color: 'sienna',
|
||||
letters: [makeEntry({ documentId: 'a' })]
|
||||
};
|
||||
render(LetterBucket, { bucket, mode: 'thema', year: 1916 });
|
||||
const card = document.querySelector('[data-testid="letter-bucket"]') as HTMLElement;
|
||||
expect(card.className).toMatch(/\brounded\b|rounded-/);
|
||||
expect(card.className).toContain('border');
|
||||
expect(card.className).toContain('bg-surface');
|
||||
});
|
||||
});
|
||||
|
||||
describe('LetterBucket — event-as-header (#827 redesign)', () => {
|
||||
it('renders the curated event as the card header when given an `event` (no separate pill)', () => {
|
||||
const event = makeEntry({
|
||||
kind: 'EVENT',
|
||||
type: 'PERSONAL',
|
||||
derived: false,
|
||||
eventId: 'e1',
|
||||
title: 'Ein gewaltiger Stadtbrand',
|
||||
eventDate: '1916-07-06',
|
||||
senderName: '',
|
||||
receiverName: '',
|
||||
documentId: undefined
|
||||
});
|
||||
const bucket: Bucket = {
|
||||
key: 'event:e1',
|
||||
kind: 'event',
|
||||
title: 'Ein gewaltiger Stadtbrand',
|
||||
color: null,
|
||||
letters: [makeEntry({ documentId: 'a', linkedEventId: 'e1' })]
|
||||
};
|
||||
render(LetterBucket, { bucket, mode: 'event', year: 1916, event, canWrite: true });
|
||||
const header = document.querySelector('[data-testid="bucket-event-header"]') as HTMLElement;
|
||||
expect(header.textContent).toContain('Ein gewaltiger Stadtbrand');
|
||||
expect(header.textContent).toContain(m.timeline_provenance_curated());
|
||||
expect(document.querySelector('[data-testid="event-edit"]')?.getAttribute('href')).toBe(
|
||||
'/zeitstrahl/events/e1/edit'
|
||||
);
|
||||
});
|
||||
|
||||
it('shows no edit affordance in the header when canWrite is false', () => {
|
||||
const event = makeEntry({
|
||||
kind: 'EVENT',
|
||||
type: 'PERSONAL',
|
||||
derived: false,
|
||||
eventId: 'e1',
|
||||
title: 'X',
|
||||
senderName: '',
|
||||
receiverName: '',
|
||||
documentId: undefined
|
||||
});
|
||||
const bucket: Bucket = {
|
||||
key: 'event:e1',
|
||||
kind: 'event',
|
||||
title: 'X',
|
||||
color: null,
|
||||
letters: [makeEntry({ documentId: 'a' })]
|
||||
};
|
||||
render(LetterBucket, { bucket, mode: 'event', year: 1916, event, canWrite: false });
|
||||
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,6 +41,7 @@ let {
|
||||
|
||||
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 };
|
||||
@@ -52,26 +53,30 @@ const bucketMode = $derived(groupingMode === 'thema' ? 'thema' : 'event');
|
||||
const rows = $derived.by<Row[]>(() => {
|
||||
const out: Row[] = [];
|
||||
|
||||
// Ereignis: events stay on the axis (REQ-001); each curated event's letters nest directly
|
||||
// beneath its pill — the pill IS the header, so the title is never repeated. A cluster whose
|
||||
// pill lives in another year band (or was filtered out) keeps its own header here, and the
|
||||
// unlinked letters fall to the single "Weitere Briefe" bucket (REQ-003/006/019).
|
||||
// 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 hasPill = (bucketKey: string) =>
|
||||
year.entries.some((e) => e.kind === 'EVENT' && `event:${e.eventId}` === bucketKey);
|
||||
// Each pill renders, then its same-year cluster nests directly beneath it (no header).
|
||||
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;
|
||||
out.push({ t: 'event', entry });
|
||||
const bucket = entry.eventId
|
||||
? buckets.find((b) => b.kind === 'event' && b.key === `event:${entry.eventId}`)
|
||||
: undefined;
|
||||
if (bucket) out.push({ t: 'bucket', bucket, nested: true });
|
||||
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 });
|
||||
}
|
||||
// Clusters whose pill is in another band keep their header; then the fallback, last.
|
||||
// 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' || !hasPill(bucket.key)) {
|
||||
if (
|
||||
bucket.kind === 'fallback' ||
|
||||
!year.entries.some((e) => e.kind === 'EVENT' && `event:${e.eventId}` === bucket.key)
|
||||
) {
|
||||
out.push({ t: 'bucket', bucket, nested: false });
|
||||
}
|
||||
}
|
||||
@@ -131,6 +136,14 @@ function rowKey(row: Row): string {
|
||||
{: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>
|
||||
|
||||
@@ -222,7 +222,7 @@ describe('YearBand — grouping modes (#827)', () => {
|
||||
expect(chip?.textContent).toContain('Krieg');
|
||||
});
|
||||
|
||||
it('nests an event cluster under its pill in the same year without repeating the title (#827)', () => {
|
||||
it('renders a same-year curated event as one card header, with no separate pill and no duplicate title (#827)', () => {
|
||||
const pill = makeEntry({
|
||||
kind: 'EVENT',
|
||||
type: 'PERSONAL',
|
||||
@@ -238,14 +238,15 @@ describe('YearBand — grouping modes (#827)', () => {
|
||||
render(YearBand, {
|
||||
year: makeYear(1916, [pill, letter]),
|
||||
groupingMode: 'event',
|
||||
eventLookup: new Map([['e1', 'Ein gewaltiger Stadtbrand']])
|
||||
eventLookup: new Map([['e1', 'Ein gewaltiger Stadtbrand']]),
|
||||
canWrite: true
|
||||
});
|
||||
// the title appears exactly once — on the axis pill, NOT also as a bucket header
|
||||
// the title appears exactly once — in the card header, not also as a separate pill
|
||||
const occurrences =
|
||||
(document.body.textContent ?? '').split('Ein gewaltiger Stadtbrand').length - 1;
|
||||
expect(occurrences).toBe(1);
|
||||
// the letter is still clustered (nested under the pill) as the event-letter card
|
||||
expect(document.querySelector('[data-testid="letter-bucket"]')).not.toBeNull();
|
||||
// the event renders as the card header, with its letter clustered inside
|
||||
expect(document.querySelector('[data-testid="bucket-event-header"]')).not.toBeNull();
|
||||
expect(document.querySelector('a.lcard.ev')).not.toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -46,13 +46,18 @@ function eventLayerSignature(): string {
|
||||
});
|
||||
}
|
||||
|
||||
// Brief A links to the curated event p1 (Hochzeit), not the world band — so the world band
|
||||
// stays letterless and renders as a plain band in every mode (REQ-001). Under the #827 redesign
|
||||
// a curated event WITH letters becomes its cluster card's header, so the signature tracks the
|
||||
// stable layer: the letterless world band's marker count and the two titles, which all survive
|
||||
// regardless of whether Hochzeit renders as a pill (Datum) or a card header (grouped).
|
||||
const mixed = () =>
|
||||
makeTimelineDTO({
|
||||
years: [
|
||||
makeYear(1915, [
|
||||
worldBand('Erster Weltkrieg'),
|
||||
eventPill('Hochzeit'),
|
||||
makeEntry({ documentId: 'a', title: 'Brief A', linkedEventId: 'h1' }),
|
||||
makeEntry({ documentId: 'a', title: 'Brief A', linkedEventId: 'p1' }),
|
||||
makeEntry({
|
||||
documentId: 'b',
|
||||
title: 'Brief B',
|
||||
|
||||
@@ -14,17 +14,8 @@ 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;
|
||||
}
|
||||
/** Letters shown before a cluster needs a "show more" toggle (#827 redesign). */
|
||||
export const CLUSTER_PREVIEW = 5;
|
||||
|
||||
/** The `--c-tag-*` colour-name tokens (sage/sienna/…); shared by the chip and the rail. */
|
||||
const TAG_COLOR_TOKENS = new Set([
|
||||
|
||||
Reference in New Issue
Block a user