From 7fed715d6be84abcf295593c1ab0cd2c7cea3fbd Mon Sep 17 00:00:00 2001 From: Benno Tielen Date: Fri, 24 Apr 2026 12:10:34 +0200 Subject: [PATCH] fix: timezone --- src/utils/recurrenceDescription.test.ts | 15 ++--- src/utils/recurrenceDescription.ts | 78 ++++++++++++++++++------- 2 files changed, 66 insertions(+), 27 deletions(-) diff --git a/src/utils/recurrenceDescription.test.ts b/src/utils/recurrenceDescription.test.ts index d760bc5..d48349e 100644 --- a/src/utils/recurrenceDescription.test.ts +++ b/src/utils/recurrenceDescription.test.ts @@ -1,11 +1,12 @@ import { describe, it, expect } from 'vitest' import { describeRecurrence } from './recurrenceDescription' -// Reference: 2026-04-13 local time = Monday, 2nd Monday of April 2026. -const tuesdayFirstOfMonth = new Date(2026, 3, 7, 18, 30).toISOString() // 2026-04-07 is a Tuesday, 1st of month -const monday2ndOfMonth = new Date(2026, 3, 13, 18, 30).toISOString() // 2026-04-13 -const sunday13thOfMonth = new Date(2026, 8, 13, 10, 0).toISOString() // 2026-09-13 is a Sunday -const monday5thOfMonth = new Date(2026, 2, 30, 20, 0).toISOString() // 2026-03-30 +// All test dates fall inside EU DST (Mar 29 – Oct 25, 2026), so Berlin is UTC+2. +// We spell the UTC instant explicitly so tests are timezone-independent. +const tuesdayFirstOfMonth = '2026-04-07T16:30:00.000Z' // Tue 2026-04-07 18:30 Berlin +const monday2ndOfMonth = '2026-04-13T16:30:00.000Z' // Mon 2026-04-13 18:30 Berlin +const sunday13thOfMonth = '2026-09-13T08:00:00.000Z' // Sun 2026-09-13 10:00 Berlin +const monday5thOfMonth = '2026-03-30T18:00:00.000Z' // Mon 2026-03-30 20:00 Berlin describe('describeRecurrence', () => { it('returns undefined for none / missing recurrenceType', () => { @@ -14,7 +15,7 @@ describe('describeRecurrence', () => { }) it('describes daily with time from event.date', () => { - const dateAt10 = new Date(2026, 3, 13, 10, 0).toISOString() + const dateAt10 = '2026-04-13T08:00:00.000Z' // 10:00 Berlin expect(describeRecurrence({ recurrenceType: 'daily', date: dateAt10 })).toBe( 'Täglich um 10:00 Uhr', ) @@ -85,7 +86,7 @@ describe('describeRecurrence', () => { }) it('pads minutes correctly (08:05, not 8:5)', () => { - const dateEarly = new Date(2026, 3, 13, 8, 5).toISOString() + const dateEarly = '2026-04-13T06:05:00.000Z' // 08:05 Berlin expect(describeRecurrence({ recurrenceType: 'daily', date: dateEarly })).toBe( 'Täglich um 08:05 Uhr', ) diff --git a/src/utils/recurrenceDescription.ts b/src/utils/recurrenceDescription.ts index d44eaf0..735813c 100644 --- a/src/utils/recurrenceDescription.ts +++ b/src/utils/recurrenceDescription.ts @@ -28,18 +28,55 @@ const WEEK_OF_MONTH_LABEL: Record = { last: 'letzten', } -// Match the clamp in eventRecurrence.ts so the label on the Event page -// agrees with the rule the generator actually produces. -const weekOfMonthN = (d: Date): number => Math.min(Math.ceil(d.getDate() / 7), 4) +const BERLIN_TZ = 'Europe/Berlin' -const formatTime = (d: Date): string => { - const hh = String(d.getHours()).padStart(2, '0') - const mm = String(d.getMinutes()).padStart(2, '0') - return `${hh}:${mm}` +const WEEKDAY_EN_TO_INDEX: Record = { + Sunday: 0, + Monday: 1, + Tuesday: 2, + Wednesday: 3, + Thursday: 4, + Friday: 5, + Saturday: 6, } -const withTime = (description: string, d: Date | null): string => - d ? `${description} um ${formatTime(d)} Uhr` : description +const berlinFormatter = new Intl.DateTimeFormat('en-GB', { + timeZone: BERLIN_TZ, + hour: '2-digit', + minute: '2-digit', + day: 'numeric', + weekday: 'long', + hourCycle: 'h23', +}) + +type BerlinParts = { + hour: string + minute: string + day: number + weekdayIndex: number +} + +const getBerlinParts = (d: Date): BerlinParts => { + const parts = berlinFormatter.formatToParts(d) + const get = (type: string) => + parts.find((p) => p.type === type)?.value ?? '' + return { + hour: get('hour'), + minute: get('minute'), + day: Number(get('day')), + weekdayIndex: WEEKDAY_EN_TO_INDEX[get('weekday')] ?? 0, + } +} + +// Match the clamp in eventRecurrence.ts so the label on the Event page +// agrees with the rule the generator actually produces. +const weekOfMonthN = (dayOfMonth: number): number => + Math.min(Math.ceil(dayOfMonth / 7), 4) + +const formatTime = (p: BerlinParts): string => `${p.hour}:${p.minute}` + +const withTime = (description: string, p: BerlinParts | null): string => + p ? `${description} um ${formatTime(p)} Uhr` : description type RecurrenceRule = NonNullable[number] @@ -78,33 +115,34 @@ export const describeRecurrence = ( const parsed = event.date ? new Date(event.date) : null const d = parsed && !Number.isNaN(parsed.getTime()) ? parsed : null + const p = d ? getBerlinParts(d) : null switch (type) { case 'daily': - return withTime('Täglich', d) + return withTime('Täglich', p) case 'weekly': - return withTime(d ? `Jeden ${WEEKDAYS_DE[d.getDay()]}` : 'Wöchentlich', d) + return withTime(p ? `Jeden ${WEEKDAYS_DE[p.weekdayIndex]}` : 'Wöchentlich', p) case 'biweekly': return withTime( - d ? `Alle 2 Wochen, ${WEEKDAYS_DE[d.getDay()]}` : 'Alle 2 Wochen', - d, + p ? `Alle 2 Wochen, ${WEEKDAYS_DE[p.weekdayIndex]}` : 'Alle 2 Wochen', + p, ) case 'monthlyByDate': - return withTime(d ? `Monatlich am ${d.getDate()}.` : 'Monatlich', d) + return withTime(p ? `Monatlich am ${p.day}.` : 'Monatlich', p) case 'monthlyByWeekday': return withTime( - d - ? `Monatlich am ${weekOfMonthN(d)}. ${WEEKDAYS_DE[d.getDay()]}` + p + ? `Monatlich am ${weekOfMonthN(p.day)}. ${WEEKDAYS_DE[p.weekdayIndex]}` : 'Monatlich', - d, + p, ) case 'custom': { const rules = event.recurrenceRules ?? [] const parts = rules .map(describeCustomRule) - .filter((p): p is string => Boolean(p)) - if (parts.length === 0) return withTime('Benutzerdefiniert', d) - return withTime(parts.join(' · '), d) + .filter((part): part is string => Boolean(part)) + if (parts.length === 0) return withTime('Benutzerdefiniert', p) + return withTime(parts.join(' · '), p) } default: return undefined