diff --git a/client/src/components/Journey/JourneyDetailPageDatePicker.test.tsx b/client/src/components/Journey/JourneyDetailPageDatePicker.test.tsx new file mode 100644 index 000000000..37f092878 --- /dev/null +++ b/client/src/components/Journey/JourneyDetailPageDatePicker.test.tsx @@ -0,0 +1,101 @@ +// FE-COMP-JOURNEYDATEPICKER-001 to FE-COMP-JOURNEYDATEPICKER-006 +import React from 'react' +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '../../../tests/helpers/render' +import userEvent from '@testing-library/user-event' +import { DatePicker } from './JourneyDetailPageDatePicker' + +function open(value = '2026-09-11') { + const onChange = vi.fn() + render() + return { onChange } +} + +const header = () => screen.getByRole('button', { name: /select month|select year|\d{4} – \d{4}/i }) +const prev = (name: RegExp) => screen.getByRole('button', { name }) + +describe('DatePicker year navigation (#2318)', () => { + it('FE-COMP-JOURNEYDATEPICKER-001: opens on the month of the value with weekday headers', async () => { + const user = userEvent.setup() + open() + await user.click(screen.getByText(/Sep 11, 2026/)) + expect(screen.getByText('Mo')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Select month' })).toHaveTextContent('September 2026') + }) + + it('FE-COMP-JOURNEYDATEPICKER-002: the header climbs to the months, then to the years', async () => { + const user = userEvent.setup() + open() + await user.click(screen.getByText(/Sep 11, 2026/)) + + await user.click(screen.getByRole('button', { name: 'Select month' })) + expect(screen.queryByText('Mo')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Jan' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Select year' })).toHaveTextContent('2026') + + await user.click(screen.getByRole('button', { name: 'Select year' })) + expect(screen.getByRole('button', { name: '2016' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '2027' })).toBeInTheDocument() + expect(header()).toHaveTextContent('2016 – 2027') + }) + + it('FE-COMP-JOURNEYDATEPICKER-003: picking a year three back lands on that year in one move', async () => { + const user = userEvent.setup() + open() + await user.click(screen.getByText(/Sep 11, 2026/)) + await user.click(screen.getByRole('button', { name: 'Select month' })) + await user.click(screen.getByRole('button', { name: 'Select year' })) + await user.click(screen.getByRole('button', { name: '2023' })) + + // Back on the months, of the chosen year. + expect(screen.getByRole('button', { name: 'Select year' })).toHaveTextContent('2023') + await user.click(screen.getByRole('button', { name: 'Mar' })) + expect(screen.getByRole('button', { name: 'Select month' })).toHaveTextContent('March 2023') + expect(screen.getByText('Mo')).toBeInTheDocument() + }) + + it('FE-COMP-JOURNEYDATEPICKER-004: the arrows step the level they are on', async () => { + const user = userEvent.setup() + open() + await user.click(screen.getByText(/Sep 11, 2026/)) + + await user.click(prev(/previous month/i)) + expect(screen.getByRole('button', { name: 'Select month' })).toHaveTextContent('August 2026') + + await user.click(screen.getByRole('button', { name: 'Select month' })) + await user.click(prev(/previous year/i)) + expect(screen.getByRole('button', { name: 'Select year' })).toHaveTextContent('2025') + + await user.click(screen.getByRole('button', { name: 'Select year' })) + await user.click(prev(/previous years/i)) + expect(header()).toHaveTextContent('2004 – 2015') + await user.click(screen.getByRole('button', { name: /next years/i })) + expect(header()).toHaveTextContent('2016 – 2027') + }) + + it('FE-COMP-JOURNEYDATEPICKER-005: a day picked after the jump is handed back as ISO', async () => { + const user = userEvent.setup() + const { onChange } = open() + await user.click(screen.getByText(/Sep 11, 2026/)) + await user.click(screen.getByRole('button', { name: 'Select month' })) + await user.click(screen.getByRole('button', { name: 'Select year' })) + await user.click(screen.getByRole('button', { name: '2023' })) + await user.click(screen.getByRole('button', { name: 'Mar' })) + await user.click(screen.getByRole('button', { name: '15' })) + expect(onChange).toHaveBeenCalledWith('2023-03-15') + expect(screen.queryByText('Mo')).not.toBeInTheDocument() + }) + + it('FE-COMP-JOURNEYDATEPICKER-006: reopening starts on the days again', async () => { + const user = userEvent.setup() + open() + const trigger = screen.getByText(/Sep 11, 2026/) + await user.click(trigger) + await user.click(screen.getByRole('button', { name: 'Select month' })) + await user.click(screen.getByRole('button', { name: 'Select year' })) + await user.click(trigger) + expect(screen.queryByRole('button', { name: '2023' })).not.toBeInTheDocument() + await user.click(trigger) + expect(screen.getByText('Mo')).toBeInTheDocument() + }) +}) diff --git a/client/src/components/Journey/JourneyDetailPageDatePicker.tsx b/client/src/components/Journey/JourneyDetailPageDatePicker.tsx index 2eaa993c6..733519d28 100644 --- a/client/src/components/Journey/JourneyDetailPageDatePicker.tsx +++ b/client/src/components/Journey/JourneyDetailPageDatePicker.tsx @@ -3,6 +3,16 @@ import { localIsoDate } from '../../utils/localDate' import { ArrowLeft, ChevronRight, Calendar } from 'lucide-react' import { useTranslation } from '../../i18n' +/** + * The header steps up a level on each click, days → months → years, and a + * pick steps back down. Mirrors CustomDateTimePicker, so a person who has + * learnt one calendar in TREK knows the other. Without it the only way to a + * different year was one month at a time, which on a photo picker asked to + * cover a trip three years back meant thirty-six clicks (#2318). + */ +type CalendarView = 'days' | 'months' | 'years' +const YEAR_PAGE_SIZE = 12 + export function DatePicker({ value, onChange, tripDates }: { value: string onChange: (date: string) => void @@ -10,6 +20,8 @@ export function DatePicker({ value, onChange, tripDates }: { }) { const { t } = useTranslation() const [open, setOpen] = useState(false) + const [view, setView] = useState('days') + const [yearPageStart, setYearPageStart] = useState(0) const [viewMonth, setViewMonth] = useState(() => { const d = value ? new Date(value + 'T00:00:00') : new Date() return { year: d.getFullYear(), month: d.getMonth() } @@ -27,19 +39,73 @@ export function DatePicker({ value, onChange, tripDates }: { setViewMonth(p => p.month === 11 ? { year: p.year + 1, month: 0 } : { ...p, month: p.month + 1 }) } + const handlePrev = () => { + if (view === 'days') prevMonth() + else if (view === 'months') setViewMonth(p => ({ ...p, year: p.year - 1 })) + else setYearPageStart(s => s - YEAR_PAGE_SIZE) + } + const handleNext = () => { + if (view === 'days') nextMonth() + else if (view === 'months') setViewMonth(p => ({ ...p, year: p.year + 1 })) + else setYearPageStart(s => s + YEAR_PAGE_SIZE) + } + const handleHeaderClick = () => { + if (view === 'days') { + setView('months') + } else if (view === 'months') { + setYearPageStart(Math.floor(viewMonth.year / YEAR_PAGE_SIZE) * YEAR_PAGE_SIZE) + setView('years') + } + } + const selectMonth = (month: number) => { + setViewMonth(p => ({ ...p, month })) + setView('days') + } + const selectYear = (year: number) => { + setViewMonth(p => ({ ...p, year })) + setView('months') + } + const toggleOpen = () => { + // Reopening lands on the days again: a picker left on the year grid + // would otherwise open there next time, one level away from a date. + setView('days') + setOpen(!open) + } + + const prevLabel = view === 'days' ? t('common.datepicker.prevMonth') + : view === 'months' ? t('common.datepicker.prevYear') : t('common.datepicker.prevYears') + const nextLabel = view === 'days' ? t('common.datepicker.nextMonth') + : view === 'months' ? t('common.datepicker.nextYear') : t('common.datepicker.nextYears') + const headerLabel = view === 'days' ? monthName + : view === 'months' ? String(viewMonth.year) + : `${yearPageStart} – ${yearPageStart + YEAR_PAGE_SIZE - 1}` + const headerAria = view === 'days' ? t('common.datepicker.selectMonth') + : view === 'months' ? t('common.datepicker.selectYear') : undefined + const pad = (n: number) => String(n).padStart(2, '0') const cells: (number | null)[] = [] for (let i = 0; i < firstDow; i++) cells.push(null) for (let d = 1; d <= daysInMonth; d++) cells.push(d) + const monthNames = Array.from({ length: 12 }, (_, i) => + new Date(viewMonth.year, i).toLocaleDateString(undefined, { month: 'short' })) + const years = Array.from({ length: YEAR_PAGE_SIZE }, (_, i) => yearPageStart + i) + const formatted = value ? new Date(value + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : null + const navButton = 'w-7 h-7 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center justify-center text-zinc-500' + const gridCell = (selected: boolean) => `h-9 rounded-lg text-[12px] font-medium flex items-center justify-center transition-colors ${ + selected + ? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900' + : 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700' + }` + return (
- {monthName} - +
- {/* Weekday headers */} -
- {['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'].map((d, i) => ( -
{d}
- ))} -
- - {/* Day grid */} -
- {cells.map((day, i) => { - if (day === null) return
- const dateStr = `${viewMonth.year}-${pad(viewMonth.month + 1)}-${pad(day)}` - const isSelected = dateStr === value - const isTrip = tripDates?.has(dateStr) - const isToday = dateStr === localIsoDate() - - return ( - - ) - })} -
+ ))} +
+ )} + + {view === 'years' && ( +
+ {years.map(y => ( + + ))} +
+ )} + + {view === 'days' && ( + <> + {/* Weekday headers */} +
+ {['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'].map((d, i) => ( +
{d}
+ ))} +
+ + {/* Day grid */} +
+ {cells.map((day, i) => { + if (day === null) return
+ const dateStr = `${viewMonth.year}-${pad(viewMonth.month + 1)}-${pad(day)}` + const isSelected = dateStr === value + const isTrip = tripDates?.has(dateStr) + const isToday = dateStr === localIsoDate() + + return ( + + ) + })} +
+ + )}
)}