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', () => {