diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 6be0122c..ffabb217 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -1052,6 +1052,8 @@ "timeline_grouping_date": "Gruppierung: Datum", "timeline_provenance_derived": "abgeleitet", "timeline_provenance_curated": "kuratiert", + "timeline_bucket_show_more": "+ {count} weitere Briefe anzeigen", + "timeline_bucket_show_less": "Weniger anzeigen", "timeline_letter_glyph_label": "Brief", "timeline_tag_chip_label": "Thema", "timeline_layer_historical_suffix": "historisch", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index b07fe58e..75bd5dd1 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1052,6 +1052,8 @@ "timeline_grouping_date": "Grouping: Date", "timeline_provenance_derived": "derived", "timeline_provenance_curated": "curated", + "timeline_bucket_show_more": "+ {count} more letters", + "timeline_bucket_show_less": "Show fewer", "timeline_letter_glyph_label": "Letter", "timeline_tag_chip_label": "Topic", "timeline_layer_historical_suffix": "historical", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 2048c1f6..3d41e31a 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -1052,6 +1052,8 @@ "timeline_grouping_date": "Agrupación: Fecha", "timeline_provenance_derived": "derivado", "timeline_provenance_curated": "curado", + "timeline_bucket_show_more": "+ {count} cartas más", + "timeline_bucket_show_less": "Mostrar menos", "timeline_letter_glyph_label": "Carta", "timeline_tag_chip_label": "Tema", "timeline_layer_historical_suffix": "histórico", diff --git a/frontend/src/lib/timeline/EventCluster.svelte b/frontend/src/lib/timeline/EventCluster.svelte new file mode 100644 index 00000000..09168458 --- /dev/null +++ b/frontend/src/lib/timeline/EventCluster.svelte @@ -0,0 +1,140 @@ + + + + {#if event && accent} + + + + {accent.glyph} + {accent.label} + + + {event.title} + + {eventSubtitle} · {count} + + + {#if canEdit} + + ✎ + {m.btn_edit()} + + {/if} + + {:else} + + + + ✉ + {title} + + · {count} + + {/if} + + + + {#each visible as letter (entryKey(letter))} + + + + {/each} + + {#if hiddenCount > 0} + (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 })} + + {/if} + + diff --git a/frontend/src/lib/timeline/EventCluster.svelte.spec.ts b/frontend/src/lib/timeline/EventCluster.svelte.spec.ts new file mode 100644 index 00000000..4e4ebc24 --- /dev/null +++ b/frontend/src/lib/timeline/EventCluster.svelte.spec.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { tick } from 'svelte'; +import EventCluster from './EventCluster.svelte'; +import { makeEntry } from './test-factories'; +import type { components } from '$lib/generated/api'; + +type TimelineEntryDTO = components['schemas']['TimelineEntryDTO']; + +afterEach(() => cleanup()); + +const EV_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; + +const makeEvent = (overrides: Partial = {}): TimelineEntryDTO => + makeEntry({ + kind: 'EVENT', + type: 'PERSONAL', + documentId: undefined, + eventId: EV_ID, + eventDate: '1916-07-06', + precision: 'DAY', + title: 'Ein gewaltiger Stadtbrand', + ...overrides + }); + +const letters = (n: number): TimelineEntryDTO[] => + Array.from({ length: n }, (_, i) => + makeEntry({ kind: 'LETTER', documentId: `doc-${i}`, title: `Brief ${i}`, linkedEventId: EV_ID }) + ); + +describe('EventCluster — contained event card (#850)', () => { + it('renders a data-testid event-card with the event title once (REQ-002)', () => { + render(EventCluster, { letters: letters(2), event: makeEvent() }); + expect(document.querySelector('[data-testid="event-card"]')).not.toBeNull(); + const occurrences = (document.body.textContent?.match(/Ein gewaltiger Stadtbrand/g) ?? []) + .length; + expect(occurrences).toBe(1); + }); + + it('shows the event-edit link for a curator on a curated event (REQ-002)', () => { + render(EventCluster, { letters: letters(2), event: makeEvent(), canWrite: true }); + const edit = document.querySelector('[data-testid="event-edit"]') as HTMLAnchorElement; + expect(edit).not.toBeNull(); + expect(edit.getAttribute('href')).toBe(`/zeitstrahl/events/${EV_ID}/edit`); + }); + + it('hides the event-edit link when canWrite is false', () => { + render(EventCluster, { letters: letters(2), event: makeEvent(), canWrite: false }); + expect(document.querySelector('[data-testid="event-edit"]')).toBeNull(); + }); + + it('hides the event-edit link for a derived event even with canWrite', () => { + render(EventCluster, { + letters: letters(2), + event: makeEvent({ derived: true, eventId: undefined, derivedType: 'BIRTH' }), + canWrite: true + }); + expect(document.querySelector('[data-testid="event-edit"]')).toBeNull(); + }); + + it('renders its letters as compact a.lcard.ev cards (REQ-002)', () => { + render(EventCluster, { letters: letters(2), event: makeEvent() }); + expect(document.querySelectorAll('a.lcard.ev').length).toBeGreaterThan(0); + }); + + it('shows the first 5 of 8 letters + a show-more toggle that expands to 8 then back to 5 (REQ-003)', async () => { + render(EventCluster, { letters: letters(8), event: makeEvent() }); + expect(document.querySelectorAll('a.lcard').length).toBe(5); + const toggle = document.querySelector('[data-testid="bucket-show-more"]') as HTMLButtonElement; + expect(toggle).not.toBeNull(); + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + expect(toggle.getBoundingClientRect().height).toBeGreaterThanOrEqual(44); + + toggle.click(); + await tick(); + expect(document.querySelectorAll('a.lcard').length).toBe(8); + expect(toggle.getAttribute('aria-expanded')).toBe('true'); + + toggle.click(); + await tick(); + expect(document.querySelectorAll('a.lcard').length).toBe(5); + }); + + it('renders no show-more toggle when the cluster holds 5 or fewer letters', () => { + render(EventCluster, { letters: letters(5), event: makeEvent() }); + expect(document.querySelector('[data-testid="bucket-show-more"]')).toBeNull(); + }); + + it('renders a cross-year text header (✉ title, no event-edit, no pill) when no event is given (REQ-004)', () => { + render(EventCluster, { + letters: letters(2), + title: 'Briefe von der Front', + canWrite: true + }); + expect(document.querySelector('[data-testid="event-card"]')).not.toBeNull(); + expect(document.body.textContent).toContain('✉'); + expect(document.body.textContent).toContain('Briefe von der Front'); + expect(document.querySelector('[data-testid="event-edit"]')).toBeNull(); + }); + + it('renders an HTML-bearing event title verbatim as text, never as markup (REQ-010)', () => { + render(EventCluster, { + letters: letters(1), + event: makeEvent({ title: '' }) + }); + expect(document.querySelector('[data-testid="event-card"] img')).toBeNull(); + expect(document.body.textContent).toContain(''); + }); +});