refactor(calendar): Wochentage und Monate aus Intl statt aus sechs Arrays
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 für alles, was direkt mit getDay() indiziert – statt eines Umrechnens am Aufrufort, wo der Off-by-one sonst entsteht. Verhaltensgleich belegt: Die erzeugten Namen stimmen zeichengenau mit den gelöschten Arrays überein, vor dem Austausch verglichen. 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 von vorgestern, nur durch die Hintertür. Kommt eine Sprachwahl (IDEAS.md), ist uiLocale die eine Stelle, an der sie andockt. Fünf neue Tests, unter Mutation nachgewiesen: Nimmt man dem Sonntag seinen Platz vorn, wird der Test rot. Geprüft: tsc sauber, 46 Vitest-Tests, alle 234 Prüfungen der sieben Selbsttest-Suiten grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8586664fae
commit
e1259dd55c
6 files changed
+174
-26
No files matched your search
+34
@@ -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
|
||||
|
||||
@@ -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',
|
||||
}}>
|
||||
<div style={{ padding: '18px 22px 12px', borderBottom: '1px solid var(--color-border)' }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{weekdayLong[day.getDay()]}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{weekdayLongNamesSun[day.getDay()]}</div>
|
||||
<div style={{ fontSize: 17, fontWeight: 600, color: 'var(--color-text)' }}>
|
||||
{day.getDate()}. {monthLabels[day.getMonth()]} {day.getFullYear()}
|
||||
{day.getDate()}. {monthLongNames[day.getMonth()]} {day.getFullYear()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<FreqChoice, string> = {
|
||||
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
|
||||
<button
|
||||
key={code} type="button" disabled={readOnly || fixed}
|
||||
onClick={() => toggleDay(code)}
|
||||
title={fixed ? `${weekdayLong[i]} – the day the event starts` : weekdayLong[i]}
|
||||
title={fixed ? `${weekdayLongNames[i]} – the day the event starts` : weekdayLongNames[i]}
|
||||
style={{
|
||||
flex: 1, padding: '6px 0', borderRadius: 7, fontSize: 12,
|
||||
border: '1px solid var(--color-border)',
|
||||
@@ -303,7 +298,7 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
||||
transition: 'background 0.12s, color 0.12s',
|
||||
}}
|
||||
>
|
||||
{weekdayShort[i]}
|
||||
{weekdayShortNames[i]}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)]
|
||||
@@ -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 (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', borderBottom: '1px solid var(--color-border)', flexShrink: 0 }}>
|
||||
{weekdayLabels.map(l => (
|
||||
{weekdayShortNames.map(l => (
|
||||
<div key={l} style={{ padding: '6px 8px', fontSize: 11, color: 'var(--color-text-muted)', fontWeight: 600 }}>{l}</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -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',
|
||||
}}>
|
||||
<div style={{ fontSize: 10.5, color: 'var(--color-text-muted)' }}>{weekdayLabels[(day.getDay() + 6) % 7]}</div>
|
||||
<div style={{ fontSize: 10.5, color: 'var(--color-text-muted)' }}>{weekdayShortNames[(day.getDay() + 6) % 7]}</div>
|
||||
<span style={{
|
||||
fontSize: 14, fontWeight: isToday ? 700 : 500,
|
||||
color: isToday ? '#fff' : 'var(--color-text)',
|
||||
@@ -1012,7 +1008,7 @@ function ListAgenda({ events, onEventClick, onEventDown }: {
|
||||
fontSize: 12.5, fontWeight: 600, marginBottom: 6,
|
||||
color: isSameDay(day, today) ? 'var(--color-primary)' : 'var(--color-text-muted)',
|
||||
}}>
|
||||
{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'}
|
||||
</div>
|
||||
{evs.map(ev => (
|
||||
|
||||
Reference in new issue
Block a user