diff --git a/frontend/src/components/TimelineNav.vue b/frontend/src/components/TimelineNav.vue index 21dfb8c..43ec221 100644 --- a/frontend/src/components/TimelineNav.vue +++ b/frontend/src/components/TimelineNav.vue @@ -1,90 +1,268 @@ diff --git a/frontend/src/components/TimelineView.vue b/frontend/src/components/TimelineView.vue index c12826b..6891274 100644 --- a/frontend/src/components/TimelineView.vue +++ b/frontend/src/components/TimelineView.vue @@ -104,6 +104,31 @@ const yearRanges = computed(() => { // All years that exist in the data, ascending (for the year navigation) const allYears = computed(() => yearRanges.value.map(r => r.year)) +// Years with their event count and the months that hold events, for the +// quick-jump strip. Events are sorted by date, so every year and every month +// within it is one contiguous run of indices. +const yearIndex = computed(() => { + const years = [] + displayEvents.value.forEach((event, index) => { + const year = calendarYear(event.date) + const month = calendarMonthIndex(event.date) + let entry = years[years.length - 1] + if (!entry || entry.year !== year) { + entry = { year, count: 0, firstIndex: index, months: [] } + years.push(entry) + } + entry.count += 1 + if (month === null) return + let monthEntry = entry.months[entry.months.length - 1] + if (!monthEntry || monthEntry.month !== month) { + monthEntry = { month, count: 0, firstIndex: index } + entry.months.push(monthEntry) + } + monthEntry.count += 1 + }) + return years +}) + // Virtualization: only render events near the viewport const VIS_BUFFER = 2 const VIEW_EMIT_BUFFER = 3 @@ -151,7 +176,8 @@ const activePosition = computed(() => { const month = calendarMonthIndex(event.date) return { year: calendarYear(event.date), - month: month === null ? '' : MONTHS_LONG[month] + month: month === null ? '' : MONTHS_LONG[month], + monthIndex: month } }) @@ -180,6 +206,28 @@ function scrollToYearCenter(year) { }) } +// Centres the run of events from index `first` to `last`. +function scrollToIndexRange(first, last) { + if (!timelineRef.value) return + const centerX = (getEventX(first) + getEventX(last)) / 2 + timelineRef.value.scrollTo({ + left: centerX - viewportWidth.value / 2, + behavior: 'smooth' + }) +} + +// Centres the events of one month; a month without events falls back to +// the year. +function scrollToMonth(year, month) { + const entry = yearIndex.value.find(candidate => candidate.year === year) + const monthEntry = entry?.months.find(candidate => candidate.month === month) + if (!monthEntry) { + scrollToYearCenter(year) + return + } + scrollToIndexRange(monthEntry.firstIndex, monthEntry.firstIndex + monthEntry.count - 1) +} + function updateViewportWidth() { if (!timelineRef.value) return viewportWidth.value = timelineRef.value.clientWidth || 400 @@ -530,7 +578,9 @@ defineExpose({ forceReflow, activePosition, years: allYears, - scrollToYearCenter + yearIndex, + scrollToYearCenter, + scrollToMonth }) diff --git a/frontend/src/layouts/LifeWaveLayout.vue b/frontend/src/layouts/LifeWaveLayout.vue index 824812c..bafb5bb 100644 --- a/frontend/src/layouts/LifeWaveLayout.vue +++ b/frontend/src/layouts/LifeWaveLayout.vue @@ -125,12 +125,14 @@ class="lifewave-bar__nav" :year="activePosition?.year ?? null" :month="activePosition?.month ?? ''" - :years="timelineYears" + :month-index="activePosition?.monthIndex ?? null" + :year-index="timelineYearIndex" :can-prev="canGoPrevYear" :can-next="canGoNextYear" @prev="goToYear(activeYearIndex - 1)" @next="goToYear(activeYearIndex + 1)" @jump="year => timelineViewRef?.scrollToYearCenter(year)" + @jump-month="({ year, month }) => timelineViewRef?.scrollToMonth(year, month)" /> @@ -449,6 +451,7 @@ const parsedGradient = computed(() => { // Position and year navigation, read from TimelineView const activePosition = computed(() => timelineViewRef.value?.activePosition ?? null) const timelineYears = computed(() => timelineViewRef.value?.years ?? []) +const timelineYearIndex = computed(() => timelineViewRef.value?.yearIndex ?? []) const activeYearIndex = computed(() => timelineYears.value.indexOf(activePosition.value?.year)) const canGoPrevYear = computed(() => activeYearIndex.value > 0) const canGoNextYear = computed(() => diff --git a/frontend/test/vitest/components-timeline-nav.spec.js b/frontend/test/vitest/components-timeline-nav.spec.js index 1be7ff7..aab3003 100644 --- a/frontend/test/vitest/components-timeline-nav.spec.js +++ b/frontend/test/vitest/components-timeline-nav.spec.js @@ -2,25 +2,31 @@ import { describe, expect, it } from 'vitest' import { mount } from '@vue/test-utils' import TimelineNav from 'components/TimelineNav.vue' -// The menu is a portal; rendering it inline keeps the year list testable. -const inlineMenu = { - QMenu: { - props: ['modelValue'], - template: '' +function yearEntry(year, months) { + return { + year, + count: months.reduce((sum, [, count]) => sum + count, 0), + months: months.map(([month, count]) => ({ month, count })) } } +const YEARS = [ + yearEntry(2025, [[3, 1]]), + yearEntry(2026, [[0, 2], [5, 1], [11, 4]]), + yearEntry(2027, [[7, 1]]) +] + function mountNav(props = {}) { return mount(TimelineNav, { props: { year: 2026, month: 'Januar', - years: [2025, 2026, 2027], + monthIndex: 0, + yearIndex: YEARS, canPrev: true, canNext: true, ...props - }, - global: { stubs: inlineMenu } + } }) } @@ -51,27 +57,94 @@ describe('TimelineNav — the position pill', () => { expect(arrows[1].attributes('disabled')).toBeDefined() }) - it('opens the year list on tap and emits the chosen year', async () => { - const wrapper = mountNav() - - expect(wrapper.find('.menu-stub').exists()).toBe(false) - await wrapper.find('.timeline-nav__label').trigger('click') - expect(wrapper.find('.menu-stub').exists()).toBe(true) - - const items = wrapper.findAll('.timeline-nav__year-item') - expect(items.map(item => item.text())).toEqual(['2025', '2026', '2027']) - - await items[2].trigger('click') - expect(wrapper.emitted('jump')).toEqual([[2027]]) - // Choosing closes the list. - expect(wrapper.find('.menu-stub').exists()).toBe(false) - }) - it('shows a dash and no month while there is no event', () => { - const wrapper = mountNav({ year: null, month: '', years: [], canPrev: false, canNext: false }) + const wrapper = mountNav({ year: null, month: '', monthIndex: null, yearIndex: [], canPrev: false, canNext: false }) expect(wrapper.find('.timeline-nav__year').text()).toBe('–') expect(wrapper.find('.timeline-nav__month').exists()).toBe(false) expect(wrapper.find('.timeline-nav__label').attributes('disabled')).toBeDefined() }) }) + +describe('TimelineNav — the quick-jump strip', () => { + it('opens on tap with every year, its density and the months of the year in view', async () => { + const wrapper = mountNav() + expect(wrapper.find('.timeline-nav__panel').exists()).toBe(false) + + await wrapper.find('.timeline-nav__label').trigger('click') + expect(wrapper.find('.timeline-nav__panel').exists()).toBe(true) + + const chips = wrapper.findAll('.timeline-nav__chip') + expect(chips.map(chip => chip.find('.timeline-nav__chip-year').text())).toEqual(['2025', '2026', '2027']) + // Up to three events are dots, more is a number. + expect(chips[0].findAll('.timeline-nav__chip-dot')).toHaveLength(1) + expect(chips[1].find('.timeline-nav__chip-count').text()).toBe('7') + expect(chips[1].classes()).toContain('timeline-nav__chip--current') + + // 2026 spreads over three months, so the month row is there right away. + const cells = wrapper.findAll('.timeline-nav__month-cell') + expect(cells).toHaveLength(12) + expect(cells.filter(cell => cell.attributes('disabled') === undefined).map(cell => cell.text())) + .toEqual(['Jan', 'Jun', 'Dez']) + expect(cells[0].classes()).toContain('timeline-nav__month-cell--current') + // Three years: no decade row. + expect(wrapper.find('.timeline-nav__decades').exists()).toBe(false) + }) + + it('jumps to a single-month year and closes', async () => { + const wrapper = mountNav() + await wrapper.find('.timeline-nav__label').trigger('click') + + await wrapper.findAll('.timeline-nav__chip')[2].trigger('click') + + expect(wrapper.emitted('jump')).toEqual([[2027]]) + expect(wrapper.find('.timeline-nav__panel').exists()).toBe(false) + }) + + it('stays open for a year with several months and jumps to the chosen month', async () => { + const wrapper = mountNav({ year: 2025, month: 'April', monthIndex: 3 }) + await wrapper.find('.timeline-nav__label').trigger('click') + // The year in view has one month only: no month row yet. + expect(wrapper.find('.timeline-nav__months').exists()).toBe(false) + + await wrapper.findAll('.timeline-nav__chip')[1].trigger('click') + expect(wrapper.emitted('jump')).toEqual([[2026]]) + expect(wrapper.find('.timeline-nav__panel').exists()).toBe(true) + expect(wrapper.findAll('.timeline-nav__chip')[1].classes()).toContain('timeline-nav__chip--expanded') + + const cells = wrapper.findAll('.timeline-nav__month-cell') + expect(cells[3].attributes('disabled')).toBeDefined() + await cells[11].trigger('click') + + expect(wrapper.emitted('jump-month')).toEqual([[{ year: 2026, month: 11 }]]) + expect(wrapper.find('.timeline-nav__panel').exists()).toBe(false) + }) + + it('adds a decade row once there are many years', async () => { + const years = Array.from({ length: 30 }, (_, i) => yearEntry(1990 + i, [[i % 12, 1]])) + const wrapper = mountNav({ year: 2005, month: 'Mai', monthIndex: 4, yearIndex: years }) + await wrapper.find('.timeline-nav__label').trigger('click') + + const decades = wrapper.findAll('.timeline-nav__decade') + expect(decades.map(decade => decade.text())).toEqual(['1990er', '2000er', '2010er']) + expect(decades[1].classes()).toContain('timeline-nav__decade--current') + }) + + it('closes on Escape and when tapping elsewhere', async () => { + const wrapper = mountNav({ attachTo: document.body }) + await wrapper.find('.timeline-nav__label').trigger('click') + expect(wrapper.find('.timeline-nav__panel').exists()).toBe(true) + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + await wrapper.vm.$nextTick() + expect(wrapper.find('.timeline-nav__panel').exists()).toBe(false) + + await wrapper.find('.timeline-nav__label').trigger('click') + expect(wrapper.find('.timeline-nav__panel').exists()).toBe(true) + document.body.dispatchEvent(new Event('pointerdown', { bubbles: true })) + await wrapper.vm.$nextTick() + expect(wrapper.find('.timeline-nav__panel').exists()).toBe(false) + + wrapper.unmount() + }) +}) diff --git a/frontend/test/vitest/components-timeline.spec.js b/frontend/test/vitest/components-timeline.spec.js index 8b158b8..30e9861 100644 --- a/frontend/test/vitest/components-timeline.spec.js +++ b/frontend/test/vitest/components-timeline.spec.js @@ -79,7 +79,19 @@ describe('TimelineView — the reported position follows the calendar date (A18) // Whichever event the timeline centres on mount, month and year must be // the ones spelled out in that event's date string. const matching = EVENTS.map(event => positionFromDateString(event.date)) - expect(matching).toContainEqual(position) + expect(matching).toContainEqual({ year: position.year, month: position.month }) + expect(MONTHS_LONG[position.monthIndex]).toBe(position.month) + }) + + it('indexes years and months with their event counts for the quick-jump strip', () => { + const index = wrapper.vm.yearIndex + expect(index.map(entry => entry.year)).toEqual([2025, 2026, 2027]) + expect(index.reduce((sum, entry) => sum + entry.count, 0)).toBe(EVENTS.length) + for (const entry of index) { + expect(entry.months.reduce((sum, month) => sum + month.count, 0)).toBe(entry.count) + expect(entry.months.every(month => month.month >= 0 && month.month <= 11)).toBe(true) + } + expect(typeof wrapper.vm.scrollToMonth).toBe('function') }) it('names months in full for the navigation pill', () => {