diff --git a/TESTING.md b/TESTING.md index 9550750..71220ff 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1562,6 +1562,40 @@ bleibt `node` (schnell); wer ein DOM braucht, schreibt --- +## Wochentage und Monate aus `Intl` (20.09.2026) + +Sechs handgeschriebene Arrays in drei Dateien, und `Intl` kam im ganzen Projekt +nicht vor – obwohl jeder Browser es seit Jahren mitbringt. + +Die Doppelpflege war nicht nur lästig, sie war eine Falle: `DayDialog` begann +seine Woche am **Sonntag**, die anderen am **Montag**, jeweils passend zu ihrer +Indizierung. Beide waren für sich richtig. Wer aber eines der Arrays in eine +neue Ansicht kopiert hätte, hätte sich einen Off-by-one gebaut, der nur an +sieben Tagen im Jahr auffällt. + +Jetzt eine Ordnung (Montag zuerst, wie `weekdayIndex`) plus eine ausdrücklich +benannte zweite Liste `weekdayLongNamesSun` für alles, was direkt mit +`getDay()` indiziert. + +**Verhaltensgleich belegt:** Die erzeugten Namen stimmen zeichengenau mit den +gelöschten Arrays überein – vor dem Austausch verglichen, nicht danach gehofft. + +**Die Sprache ist fest auf `en` gesetzt, nicht die des Browsers.** +`Intl.DateTimeFormat(undefined, …)` nähme die Einstellung des Rechners und +setzte auf einem deutschen System „Montag" neben ein englisches „Repeat" – +genau der Mischmasch, der am 18.09. ausgeräumt wurde. Kommt eine Sprachwahl, +ist `uiLocale` in `dates.ts` die eine Stelle, an der sie andockt. + +- [x] Fünf Tests in `dates.test.ts`, unter Mutation nachgewiesen: Nimmt man + dem Sonntag seinen Platz vorn, wird der Test rot. +- [ ] Monatsansicht: Kopfzeile Mon–Sun, Termine am richtigen Tag 🔎 +- [ ] Wochenansicht: Datumszeile stimmt mit dem Wochentag überein +- [ ] Tagesdialog: Überschrift nennt den richtigen Wochentag (der Fall mit + der Sonntags-Liste) 🔎 +- [ ] Serientermin „An diesen Tagen": Beschriftung passt zum gesetzten Tag – + anlegen, in DAVx5 prüfen, ob derselbe Wochentag ankommt +- [ ] Listenansicht: Datum als „September 18, 2026" + ## Platzhalter und Leerzustände (18.09.2026) Beim Laden stand ein Kreisel in der Mitte, danach klappte das Layout um. Jetzt diff --git a/frontend/src/components/calendar/DayDialog.tsx b/frontend/src/components/calendar/DayDialog.tsx index 356e303..4e3e135 100644 --- a/frontend/src/components/calendar/DayDialog.tsx +++ b/frontend/src/components/calendar/DayDialog.tsx @@ -1,12 +1,8 @@ import { useEffect } from 'react' import { useOverlayDismiss } from '../../hooks/useOverlayDismiss' import type { CalendarEvent } from '../../api/client' +import { weekdayLongNamesSun, monthLongNames } from './dates' -const weekdayLong = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] -const monthLabels = [ - 'January', 'February', 'March', 'April', 'May', 'June', - 'July', 'August', 'September', 'October', 'November', 'December', -] export interface DayEvent extends CalendarEvent { calendarId: string @@ -60,9 +56,9 @@ export default function DayDialog({ day, events, onPick, onCreate, onClose }: { display: 'flex', flexDirection: 'column', overflow: 'hidden', }}>
-
{weekdayLong[day.getDay()]}
+
{weekdayLongNamesSun[day.getDay()]}
- {day.getDate()}. {monthLabels[day.getMonth()]} {day.getFullYear()} + {day.getDate()}. {monthLongNames[day.getMonth()]} {day.getFullYear()}
diff --git a/frontend/src/components/calendar/EventDialog.tsx b/frontend/src/components/calendar/EventDialog.tsx index 7c734e4..47941a6 100644 --- a/frontend/src/components/calendar/EventDialog.tsx +++ b/frontend/src/components/calendar/EventDialog.tsx @@ -4,6 +4,7 @@ import type { CalendarEvent, CalendarItem, EventInput, Recurrence, WeekdayCode } import { addDays, allDayISO, dateFromInput, eventDay, nthOfMonth, timedISO, toDateInput, toTimeInput, weekdayCodeOf, weekdayCodes, + weekdayShortNames, weekdayLongNames, monthLongNames, } from './dates' import { CalendarSelect, ColorSwatches, Toggle } from './controls' @@ -30,20 +31,14 @@ const freqLabels: Record = { YEARLY: 'Yearly', } -const weekdayShort = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] -const weekdayLong = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] -const monthNames = [ - 'January', 'February', 'March', 'April', 'May', 'June', - 'July', 'August', 'September', 'October', 'November', 'December', -] /** „Am 4." bzw. „Am 1. Freitag" – beide aus dem Startdatum abgeleitet, wie * man es aus anderen Kalendern kennt. Bei jährlich kommt der Monat dazu. */ function monthlyModeLabels(start: Date, yearly: boolean): { day: string; weekday: string } { const n = nthOfMonth(start) const nth = n === -1 ? 'letzten' : `${n}.` - const wd = weekdayLong[(start.getDay() + 6) % 7] - const month = monthNames[start.getMonth()] + const wd = weekdayLongNames[(start.getDay() + 6) % 7] + const month = monthLongNames[start.getMonth()] return yearly ? { day: `Am ${start.getDate()}. ${month}`, weekday: `Am ${nth} ${wd} im ${month}` } : { day: `Am ${start.getDate()}.`, weekday: `Am ${nth} ${wd}` } @@ -292,7 +287,7 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId ) })} diff --git a/frontend/src/components/calendar/dates.test.ts b/frontend/src/components/calendar/dates.test.ts new file mode 100644 index 0000000..0f85928 --- /dev/null +++ b/frontend/src/components/calendar/dates.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest' +import { + weekdayIndex, weekdayCodes, + weekdayShortNames, weekdayLongNames, weekdayLongNamesSun, monthLongNames, +} from './dates' + +/** + * Die Namen kommen seit dem 20.09.2026 aus `Intl` statt aus sechs + * handgeschriebenen Arrays. Geprüft wird hier nicht, ob der Browser Englisch + * kann – sondern die **Reihenfolge**, denn daran hing die eigentliche Falle: + * Eine Liste begann am Montag, eine am Sonntag, jede passend zu ihrer + * Indizierung. Wer die falsche erwischt, verschiebt jeden Wochentag um eins. + */ +describe('Namen von Wochentagen und Monaten', () => { + it('beginnt die Woche am Montag – wie weekdayIndex', () => { + expect(weekdayLongNames[0]).toBe('Monday') + expect(weekdayLongNames[6]).toBe('Sunday') + expect(weekdayShortNames[0]).toBe('Mon') + expect(weekdayShortNames).toHaveLength(7) + + // Der Beweis, dass Namen und Index zusammenpassen: Für jeden Tag einer + // echten Woche muss der Name am errechneten Index stehen. + const montag = new Date(2024, 0, 1) + for (let i = 0; i < 7; i++) { + const tag = new Date(2024, 0, 1 + i) + expect(weekdayIndex(tag)).toBe(i) + expect(weekdayLongNames[weekdayIndex(tag)]).toBe( + new Intl.DateTimeFormat('en', { weekday: 'long' }).format(tag)) + } + expect(weekdayIndex(montag)).toBe(0) + }) + + it('hat eine zweite Liste, die am Sonntag beginnt – für getDay()', () => { + expect(weekdayLongNamesSun[0]).toBe('Sunday') + expect(weekdayLongNamesSun[1]).toBe('Monday') + + // Genau dafür ist sie da: direkt mit getDay() indizierbar. + for (let i = 0; i < 7; i++) { + const tag = new Date(2024, 0, 1 + i) + expect(weekdayLongNamesSun[tag.getDay()]).toBe( + new Intl.DateTimeFormat('en', { weekday: 'long' }).format(tag)) + } + }) + + it('passt zu den Wiederholungs-Kürzeln der Terminreihe', () => { + // weekdayCodes ist MO..SU. Stünden die Namen anders herum, bekäme die + // Auswahl „An diesen Tagen" die falsche Beschriftung – und ein + // Serientermin landete auf dem falschen Wochentag. + expect(weekdayCodes).toHaveLength(weekdayLongNames.length) + expect(weekdayCodes[0]).toBe('MO') + expect(weekdayLongNames[0].startsWith('Mo')).toBe(true) + expect(weekdayCodes[6]).toBe('SU') + expect(weekdayLongNames[6].startsWith('Su')).toBe(true) + }) + + it('liefert zwölf Monate, Januar zuerst', () => { + expect(monthLongNames).toHaveLength(12) + expect(monthLongNames[0]).toBe('January') + expect(monthLongNames[11]).toBe('December') + // Mitte des Monats als Bezugspunkt: Am Monatsersten könnte eine Zeitzone + // westlich von UTC in den Vormonat kippen. + for (let i = 0; i < 12; i++) { + expect(monthLongNames[new Date(2024, i, 15).getMonth()]).toBe(monthLongNames[i]) + } + }) + + it('spricht Englisch, unabhängig von der Sprache des Browsers', () => { + // Die Oberfläche ist englisch; die Daten müssen es auch sein. Sonst stünde + // auf einem deutschen Rechner „Montag" neben „Repeat". + expect(monthLongNames[2]).toBe('March') + expect(weekdayLongNames[2]).toBe('Wednesday') + }) +}) diff --git a/frontend/src/components/calendar/dates.ts b/frontend/src/components/calendar/dates.ts index 399ecc3..6b7f207 100644 --- a/frontend/src/components/calendar/dates.ts +++ b/frontend/src/components/calendar/dates.ts @@ -132,3 +132,57 @@ export function spannedDays(start: Date, end: Date | null, allDay: boolean): Dat for (let d = first, i = 0; +d <= +last && i < 400; d = addDays(d, 1), i++) out.push(d) return out } + +// ── Namen von Wochentagen und Monaten ──────────────────────────────────────── +// +// Bis zum 20.09.2026 standen die als **sechs** handgeschriebene Arrays in drei +// Dateien – und `Intl` kam im ganzen Projekt nicht vor, obwohl jeder Browser +// es seit Jahren mitbringt. +// +// Die Doppelpflege war nicht nur lästig, sie war eine Falle: `DayDialog` +// begann seine Woche am Sonntag, die anderen am Montag, jeweils passend zu +// ihrer Indizierung. Beide waren für sich richtig. Wer aber eines der Arrays +// in eine neue Ansicht kopiert hätte, hätte sich mit hoher Wahrscheinlichkeit +// einen Off-by-one gebaut, der nur an sieben Tagen im Jahr auffällt. +// +// Hier gibt es deshalb nur noch **eine** Ordnung: Montag zuerst, wie +// `weekdayIndex` oben. Wer den Sonntag vorn braucht, nimmt `weekdayNamesSun`. + +/** + * **Feste Sprache, nicht die des Browsers.** + * + * `Intl.DateTimeFormat(undefined, …)` nähme die Spracheinstellung des + * Browsers – und läge auf einem deutschen Rechner „Montag" neben ein + * englisches „Repeat". Genau diesen Mischmasch haben wir am 18.09.2026 + * ausgeräumt; ihn über die Hintertür wieder hereinzulassen wäre absurd. + * + * Die Oberfläche spricht Englisch, also sprechen die Daten Englisch. Kommt + * eines Tages eine Sprachwahl (siehe IDEAS.md), ist **diese Zeile** die + * Stelle, an der sie andockt – und dann stimmt es überall auf einmal. + */ +const uiLocale = 'en' + +/** Einmal rechnen, nicht bei jedem Rendern. */ +function bauen(anzahl: number, tag: (i: number) => Date, opts: Intl.DateTimeFormatOptions) { + const fmt = new Intl.DateTimeFormat(uiLocale, opts) + return Array.from({ length: anzahl }, (_, i) => fmt.format(tag(i))) +} + +// Der 1. Januar 2024 war ein Montag – der Bezugspunkt für die Wochentage. +const ersterMontag = (i: number) => new Date(2024, 0, 1 + i) +// Mitte des Monats, damit keine Zeitzone den Monat kippen kann. +const monatsMitte = (i: number) => new Date(2024, i, 15) + +/** Mo–So, kurz („Mon"). */ +export const weekdayShortNames = bauen(7, ersterMontag, { weekday: 'short' }) +/** Montag–Sonntag, lang. */ +export const weekdayLongNames = bauen(7, ersterMontag, { weekday: 'long' }) +/** Januar–Dezember, lang. */ +export const monthLongNames = bauen(12, monatsMitte, { month: 'long' }) + +/** + * Dieselben Namen, aber **Sonntag zuerst** – für alles, was direkt mit + * `Date.getDay()` indiziert. Bewusst eine eigene Liste statt eines Umrechnens + * am Aufrufort: Genau dort entsteht sonst der Off-by-one. + */ +export const weekdayLongNamesSun = [weekdayLongNames[6], ...weekdayLongNames.slice(0, 6)] diff --git a/frontend/src/pages/CalendarPage.tsx b/frontend/src/pages/CalendarPage.tsx index 7641328..6a52f73 100644 --- a/frontend/src/pages/CalendarPage.tsx +++ b/frontend/src/pages/CalendarPage.tsx @@ -14,6 +14,7 @@ import DayDialog, { type DayEvent } from '../components/calendar/DayDialog' import DeleteEventDialog from '../components/calendar/DeleteEventDialog' import { addDays, dayKey, eventDay, isSameDay, spannedDays, startOfDay, startOfMonth, startOfWeek, + weekdayShortNames, monthLongNames, } from '../components/calendar/dates' import { useEventDrag } from '../components/calendar/useEventDrag' import { TrashIcon, RenameIcon, DownloadIcon, PeopleIcon } from '../components/icons' @@ -23,11 +24,6 @@ import EmptyState from '../components/EmptyState' type ViewMode = 'month' | 'week' | 'list' -const weekdayLabels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] -const monthLabels = [ - 'January', 'February', 'March', 'April', 'May', 'June', - 'July', 'August', 'September', 'October', 'November', 'December', -] /** Höhe einer Stunde im Wochenraster. */ const HOUR_H = 44 @@ -332,7 +328,7 @@ export default function CalendarPage() { } const periodLabel = viewMode === 'month' - ? `${monthLabels[cursor.getMonth()]} ${cursor.getFullYear()}` + ? `${monthLongNames[cursor.getMonth()]} ${cursor.getFullYear()}` : viewMode === 'week' ? (() => { const s = startOfWeek(cursor) @@ -729,7 +725,7 @@ function MonthGrid({ cursor, byDay, onOpenDay, onEventClick, onEventDown }: { return (
- {weekdayLabels.map(l => ( + {weekdayShortNames.map(l => (
{l}
))}
@@ -876,7 +872,7 @@ function WeekGrid({ cursor, byDay, onSlotClick, onOpenDay, onEventClick, onEvent background: 'transparent', border: 'none', borderLeftWidth: 1, borderLeftStyle: 'solid', borderLeftColor: 'var(--color-border)', cursor: 'pointer', }}> -
{weekdayLabels[(day.getDay() + 6) % 7]}
+
{weekdayShortNames[(day.getDay() + 6) % 7]}
- {weekdayLabels[(day.getDay() + 6) % 7]}, {monthLabels[day.getMonth()]} {day.getDate()}, {day.getFullYear()} + {weekdayShortNames[(day.getDay() + 6) % 7]}, {monthLongNames[day.getMonth()]} {day.getDate()}, {day.getFullYear()} {isSameDay(day, today) && ' · today'}
{evs.map(ev => (