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:
@@ -74,6 +74,38 @@ const COLOR_HOURS_LOCKED = '#9CA3AF'; // gray-400 (greyed)
|
|||||||
const COLOR_QUOTE_BORDER = '#F59E0B'; // amber-500
|
const COLOR_QUOTE_BORDER = '#F59E0B'; // amber-500
|
||||||
const COLOR_CONTRACT_BORDER = '#A855F7'; // purple-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
|
* Convert a backend CalendarItem to a FullCalendar EventInput. Embeds
|
||||||
* the original item in `extendedProps` so the event-click handler can
|
* the original item in `extendedProps` so the event-click handler can
|
||||||
@@ -486,24 +518,9 @@ export const CalendarPage: React.FC = () => {
|
|||||||
locale={i18n.language || 'en'}
|
locale={i18n.language || 'en'}
|
||||||
slotLabelFormat={fcTimeFormat}
|
slotLabelFormat={fcTimeFormat}
|
||||||
eventTimeFormat={fcTimeFormat}
|
eventTimeFormat={fcTimeFormat}
|
||||||
// Week-view column headers. FC's default in en-US renders as
|
// Column headers — see formatDayHeader above for the per-view
|
||||||
// "Thu 5/21" (M/D), which is wrong for DE / CH operators who
|
// semantics (month headers carry no date).
|
||||||
// expect day.month. dayHeaderContent (NOT dayHeaderFormat,
|
dayHeaderContent={(arg) => formatDayHeader(arg.date, arg.view.type, i18n.language)}
|
||||||
// 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}.`;
|
|
||||||
}}
|
|
||||||
headerToolbar={{
|
headerToolbar={{
|
||||||
left: 'prev,next today',
|
left: 'prev,next today',
|
||||||
center: 'title',
|
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.',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user