From 76a1453fa73a8200c81cd9fcf33eb46881d39464 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Tue, 1 Sep 2026 16:31:09 +0200 Subject: [PATCH] fix(calendar): don't put a fixed reference date in the month header dayHeaderContent assumed arg.date is always the real column date. It is in the time-grid views, but FullCalendar v6 fills it from an internal reference week (1970-01-04..10) for dayGridMonth headers, so the month header read a fixed "Mo 05.01. ... So 04.01." regardless of the visible month. Body dates were correct; only the header row was wrong. Interpretation (flagged as ambiguous): a month-view column header labels seven generic weekday columns shared by every week in the grid -- it has no single date, so forcing one in is wrong by construction rather than just mis-computed. Month view now renders the localized weekday alone, which is also FC's own default there; timeGridWeek keeps weekday + DD.MM. since each column really is one date. Branches on view.type === 'dayGridMonth' exactly, not a dayGrid prefix: dayGridWeek/dayGridDay do have real per-column dates and a prefix match would break them if either is ever added. Extracted to an exported formatDayHeader so it is testable without mounting the page; the test mounts a real FullCalendar in both views, so an upgrade that changes the arg.date contract fails rather than silently regresses. FullCalendar dependency untouched. Refs testplan REPORT.md #10 (Part 8, S9). --- .../src/pages/admin/clients/CalendarPage.tsx | 53 ++++++++----- .../__tests__/CalendarPage.dayHeader.test.tsx | 77 +++++++++++++++++++ 2 files changed, 112 insertions(+), 18 deletions(-) create mode 100644 frontend/src/pages/admin/clients/__tests__/CalendarPage.dayHeader.test.tsx diff --git a/frontend/src/pages/admin/clients/CalendarPage.tsx b/frontend/src/pages/admin/clients/CalendarPage.tsx index e950328a..66398f1a 100644 --- a/frontend/src/pages/admin/clients/CalendarPage.tsx +++ b/frontend/src/pages/admin/clients/CalendarPage.tsx @@ -74,6 +74,38 @@ const COLOR_HOURS_LOCKED = '#9CA3AF'; // gray-400 (greyed) const COLOR_QUOTE_BORDER = '#F59E0B'; // amber-500 const COLOR_CONTRACT_BORDER = '#A855F7'; // purple-500 +/** + * Column-header text for the calendar's day headers. + * + * FC's default in en-US renders as "Thu 5/21" (M/D), which is wrong for + * DE / CH operators who expect day.month. dayHeaderContent (NOT + * dayHeaderFormat, which only accepts an Intl options object in FC v6 and + * throws if handed a function) returns the rendered string directly. + * Locale-aware short weekday + explicit DD.MM. to match the project-wide + * useLocalizedDate convention (per + * feedback_respect_general_format_settings.md). + * + * Only the time-grid views have one date per column. dayGridMonth's header + * row labels seven generic weekday columns shared by every week in the grid, + * and FC fills `arg.date` there from its internal reference week + * (1970-01-04..10) — which is why the month header used to read a fixed + * "Mo 05.01. … So 04.01." whatever month was on screen. The weekday is still + * correct, so month view renders the weekday alone: there is no single date + * that column could legitimately show. + */ +export function formatDayHeader(date: Date, viewType: string, language: string): string { + const weekday = date.toLocaleDateString(language || 'en', { + weekday: 'short', + timeZone: 'UTC', + }); + if (viewType === 'dayGridMonth') { + return weekday; + } + const day = String(date.getUTCDate()).padStart(2, '0'); + const month = String(date.getUTCMonth() + 1).padStart(2, '0'); + return `${weekday} ${day}.${month}.`; +} + /** * Convert a backend CalendarItem to a FullCalendar EventInput. Embeds * the original item in `extendedProps` so the event-click handler can @@ -486,24 +518,9 @@ export const CalendarPage: React.FC = () => { locale={i18n.language || 'en'} slotLabelFormat={fcTimeFormat} eventTimeFormat={fcTimeFormat} - // Week-view column headers. FC's default in en-US renders as - // "Thu 5/21" (M/D), which is wrong for DE / CH operators who - // expect day.month. dayHeaderContent (NOT dayHeaderFormat, - // which only accepts an Intl options object in FC v6 and - // throws if handed a function) returns the rendered string - // directly. Locale-aware short weekday + explicit DD.MM. to - // match the project-wide useLocalizedDate convention (per - // feedback_respect_general_format_settings.md). - dayHeaderContent={(arg) => { - const d = arg.date; - const day = String(d.getUTCDate()).padStart(2, '0'); - const month = String(d.getUTCMonth() + 1).padStart(2, '0'); - const weekday = d.toLocaleDateString(i18n.language || 'en', { - weekday: 'short', - timeZone: 'UTC', - }); - return `${weekday} ${day}.${month}.`; - }} + // Column headers — see formatDayHeader above for the per-view + // semantics (month headers carry no date). + dayHeaderContent={(arg) => formatDayHeader(arg.date, arg.view.type, i18n.language)} headerToolbar={{ left: 'prev,next today', center: 'title', diff --git a/frontend/src/pages/admin/clients/__tests__/CalendarPage.dayHeader.test.tsx b/frontend/src/pages/admin/clients/__tests__/CalendarPage.dayHeader.test.tsx new file mode 100644 index 00000000..4e14e1b7 --- /dev/null +++ b/frontend/src/pages/admin/clients/__tests__/CalendarPage.dayHeader.test.tsx @@ -0,0 +1,77 @@ +/** + * Calendar column headers per view (#S9). + * + * `dayHeaderContent` was written for the week view, where FullCalendar hands + * the callback the real date of that column. In dayGridMonth the header row + * labels seven generic weekday columns shared by every week, and FC fills + * `arg.date` from its internal reference week (1970-01-04..10) — so the month + * header read a fixed "Mo 05.01. … So 04.01." no matter which month was on + * screen, while the day cells underneath were correct. + * + * The second block below mounts a real FullCalendar to pin FC's actual arg + * behaviour: it is the library quirk, not our formatting, that these tests + * exist to catch if a future FC upgrade changes it. + */ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import FullCalendar from '@fullcalendar/react'; +import dayGridPlugin from '@fullcalendar/daygrid'; +import timeGridPlugin from '@fullcalendar/timegrid'; + +import { formatDayHeader } from '../CalendarPage'; + +describe('formatDayHeader', () => { + it('renders weekday + DD.MM. in the week view, where the column has a real date', () => { + const d = new Date(Date.UTC(2026, 8, 1)); // Tue 2026-09-01 + expect(formatDayHeader(d, 'timeGridWeek', 'en')).toBe('Tue 01.09.'); + }); + + it('renders the weekday alone in month view, where the column has no single date', () => { + // FC's month-view reference week — the date that used to leak into the UI. + const reference = new Date(Date.UTC(1970, 0, 5)); // Mon 1970-01-05 + expect(formatDayHeader(reference, 'dayGridMonth', 'en')).toBe('Mon'); + expect(formatDayHeader(reference, 'dayGridMonth', 'en')).not.toMatch(/\d/); + }); + + it('keeps the weekday locale-aware in both views', () => { + const d = new Date(Date.UTC(2026, 8, 1)); + expect(formatDayHeader(d, 'dayGridMonth', 'de')).toBe('Di'); + expect(formatDayHeader(d, 'timeGridWeek', 'de')).toBe('Di 01.09.'); + }); +}); + +describe('FullCalendar day headers', () => { + const headerTexts = () => + Array.from(document.querySelectorAll('.fc-col-header-cell')).map( + (c) => c.textContent?.trim() ?? '' + ); + + const renderCalendar = (view: 'dayGridMonth' | 'timeGridWeek') => + render( + formatDayHeader(arg.date, arg.view.type, 'en')} + /> + ); + + it('month view shows seven dateless weekday headers', () => { + renderCalendar('dayGridMonth'); + expect(headerTexts()).toEqual(['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']); + // The old formatting leaked FC's 1970 reference week into the UI. + expect(screen.queryByText(/05\.01\./)).toBeNull(); + }); + + it('week view still shows the real date of each column', () => { + renderCalendar('timeGridWeek'); + expect(headerTexts()).toEqual([ + 'Mon 14.09.', 'Tue 15.09.', 'Wed 16.09.', + 'Thu 17.09.', 'Fri 18.09.', 'Sat 19.09.', 'Sun 20.09.', + ]); + }); +});