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).
This commit is contained in:
Paul Nothaft
2026-09-01 16:31:09 +02:00
parent fc7cb226f4
commit 76a1453fa7
2 changed files with 112 additions and 18 deletions
@@ -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',
@@ -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(
<FullCalendar
plugins={[dayGridPlugin, timeGridPlugin]}
initialView={view}
initialDate="2026-09-15"
timeZone="UTC"
firstDay={1}
headerToolbar={false}
height="auto"
dayHeaderContent={(arg) => 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.',
]);
});
});