From 1011aef6392d6589b8b31e3be20785a00cd62922 Mon Sep 17 00:00:00 2001 From: mARTin Date: Mon, 23 Mar 2026 21:23:24 +0100 Subject: [PATCH] fix: recurring event DST time shift and block height overflow - Use TZID-based local time (instead of UTC 'Z') for DTSTART/DTEND when creating recurring CalDAV events, preventing DST-related time shifts (e.g. 13:40 CET showing as 14:40 CEST after clock change) - Send browser timezone from CalendarEventModal to server - Guard against NaN event duration (missing/invalid endTime) which caused event blocks to stretch to end of day v1.61.1 Co-Authored-By: Claude Opus 4.6 --- package.json | 2 +- src/app/api/calendar/events/route.ts | 3 ++- src/components/CalendarEventModal.tsx | 1 + src/components/WeeklyView.tsx | 5 +++++ src/lib/apple-calendar.ts | 29 ++++++++++++++++++++++----- src/lib/calendar-events.ts | 3 +++ src/lib/synology-calendar.ts | 27 +++++++++++++++++++++---- 7 files changed, 59 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 0d45958..df26363 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "my-weekly-todo-list", - "version": "1.61.0", + "version": "1.61.1", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view", "main": "index.js", "scripts": { diff --git a/src/app/api/calendar/events/route.ts b/src/app/api/calendar/events/route.ts index bc2f0be..155ce4d 100644 --- a/src/app/api/calendar/events/route.ts +++ b/src/app/api/calendar/events/route.ts @@ -40,7 +40,7 @@ export async function POST(request: NextRequest) { const body = await request.json(); const { calendarId, title, description, start, end, location, allDay, recurrence, recurrenceEndDate, recurrenceCount, recurrenceInterval, recurrenceDays, url, - reminders, busyStatus, visibility, attendees, attachments } = body; + reminders, busyStatus, visibility, attendees, attachments, timezone } = body; console.log('[API] Creating event:', { calendarId, title, start, end }); @@ -67,6 +67,7 @@ export async function POST(request: NextRequest) { recurrenceCount, recurrenceInterval, recurrenceDays, + timezone, url, reminders, busyStatus, diff --git a/src/components/CalendarEventModal.tsx b/src/components/CalendarEventModal.tsx index 63d680e..4b4217c 100644 --- a/src/components/CalendarEventModal.tsx +++ b/src/components/CalendarEventModal.tsx @@ -194,6 +194,7 @@ export default function CalendarEventModal({ allDay, start: { dateTime: startDate.toISOString() }, end: { dateTime: endDate.toISOString() }, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, reminders: activeReminders.length > 0 ? activeReminders : undefined, busyStatus: busyStatus !== 'busy' ? busyStatus : undefined, visibility: visibility !== 'default' ? visibility : undefined, diff --git a/src/components/WeeklyView.tsx b/src/components/WeeklyView.tsx index 7887991..8e7d882 100644 --- a/src/components/WeeklyView.tsx +++ b/src/components/WeeklyView.tsx @@ -3546,6 +3546,11 @@ export default function WeeklyView() { const end = new Date(event.endTime); const durationMinutes = (end.getTime() - start.getTime()) / (1000 * 60); + // Guard against NaN or negative durations (missing/invalid end time) + if (!isFinite(durationMinutes) || durationMinutes <= 0) { + return getSlotHeight(effectiveCellDuration); // Default to one slot height + } + // Calculate height based on duration and slot height const pixelsPerMinute = getSlotHeight(effectiveCellDuration) / effectiveCellDuration; return Math.max( diff --git a/src/lib/apple-calendar.ts b/src/lib/apple-calendar.ts index 27ba6cd..76e2878 100644 --- a/src/lib/apple-calendar.ts +++ b/src/lib/apple-calendar.ts @@ -394,6 +394,7 @@ export const createEvent = async ( recurrenceCount?: number; recurrenceInterval?: number; recurrenceDays?: number[]; + timezone?: string; start: { dateTime?: string; date?: string }; end: { dateTime?: string; date?: string }; reminders?: Array<{ method: string; minutes: number }>; @@ -439,11 +440,29 @@ export const createEvent = async ( // For all-day events, end date is exclusive, so if they are same, add 1 day // But typically UI handles this. Let's assume input is correct. } else if (eventData.start.dateTime) { - // Timed event - dtStart = new Date(eventData.start.dateTime).toISOString().replace(/[-:.]/g, '').substring(0, 15) + 'Z'; - dtEnd = eventData.end.dateTime - ? new Date(eventData.end.dateTime).toISOString().replace(/[-:.]/g, '').substring(0, 15) + 'Z' - : dtStart; + // For recurring events, use local time with TZID to avoid DST shifts + if (eventData.recurrence && eventData.timezone) { + const tz = eventData.timezone; + const toLocalIcal = (isoStr: string) => { + const d = new Date(isoStr); + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, + }).formatToParts(d); + const get = (t: string) => parts.find(p => p.type === t)?.value || '00'; + return `${get('year')}${get('month')}${get('day')}T${get('hour')}${get('minute')}${get('second')}`; + }; + dtStart = toLocalIcal(eventData.start.dateTime); + dtEnd = eventData.end.dateTime ? toLocalIcal(eventData.end.dateTime) : dtStart; + dtStartParam = `;TZID=${tz}`; + dtEndParam = `;TZID=${tz}`; + } else { + // Timed event (non-recurring or no timezone info) + dtStart = new Date(eventData.start.dateTime).toISOString().replace(/[-:.]/g, '').substring(0, 15) + 'Z'; + dtEnd = eventData.end.dateTime + ? new Date(eventData.end.dateTime).toISOString().replace(/[-:.]/g, '').substring(0, 15) + 'Z' + : dtStart; + } } const description = eventData.description ? `DESCRIPTION:${eventData.description.replace(/\n/g, '\\n')}\r\n` : ''; diff --git a/src/lib/calendar-events.ts b/src/lib/calendar-events.ts index ea1b848..6729dab 100644 --- a/src/lib/calendar-events.ts +++ b/src/lib/calendar-events.ts @@ -45,6 +45,7 @@ export interface CalendarEvent { recurrenceCount?: number; recurrenceInterval?: number; recurrenceDays?: number[]; + timezone?: string; recurringEventId?: string; isRecurring?: boolean; source: 'google' | 'apple' | 'outlook' | 'synology' | 'notion'; @@ -952,6 +953,7 @@ export const createCalendarEvent = async ( recurrenceCount: event.recurrenceCount, recurrenceInterval: event.recurrenceInterval, recurrenceDays: event.recurrenceDays, + timezone: event.timezone, start, end, reminders: event.reminders, @@ -992,6 +994,7 @@ export const createCalendarEvent = async ( recurrenceCount: event.recurrenceCount, recurrenceInterval: event.recurrenceInterval, recurrenceDays: event.recurrenceDays, + timezone: event.timezone, start: event.start!, end: event.end!, reminders: event.reminders, diff --git a/src/lib/synology-calendar.ts b/src/lib/synology-calendar.ts index 8b35d52..88895a0 100644 --- a/src/lib/synology-calendar.ts +++ b/src/lib/synology-calendar.ts @@ -404,6 +404,7 @@ export const createEvent = async ( recurrenceCount?: number; recurrenceInterval?: number; recurrenceDays?: number[]; + timezone?: string; start: { dateTime?: string; date?: string }; end: { dateTime?: string; date?: string }; reminders?: Array<{ method: string; minutes: number }>; @@ -440,10 +441,28 @@ export const createEvent = async ( dtStartParam = ';VALUE=DATE'; dtEndParam = ';VALUE=DATE'; } else if (eventData.start.dateTime) { - dtStart = new Date(eventData.start.dateTime).toISOString().replace(/[-:.]/g, '').substring(0, 15) + 'Z'; - dtEnd = eventData.end.dateTime - ? new Date(eventData.end.dateTime).toISOString().replace(/[-:.]/g, '').substring(0, 15) + 'Z' - : dtStart; + // For recurring events, use local time with TZID to avoid DST shifts + if (eventData.recurrence && eventData.timezone) { + const tz = eventData.timezone; + const toLocalIcal = (isoStr: string) => { + const d = new Date(isoStr); + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, + }).formatToParts(d); + const get = (t: string) => parts.find(p => p.type === t)?.value || '00'; + return `${get('year')}${get('month')}${get('day')}T${get('hour')}${get('minute')}${get('second')}`; + }; + dtStart = toLocalIcal(eventData.start.dateTime); + dtEnd = eventData.end.dateTime ? toLocalIcal(eventData.end.dateTime) : dtStart; + dtStartParam = `;TZID=${tz}`; + dtEndParam = `;TZID=${tz}`; + } else { + dtStart = new Date(eventData.start.dateTime).toISOString().replace(/[-:.]/g, '').substring(0, 15) + 'Z'; + dtEnd = eventData.end.dateTime + ? new Date(eventData.end.dateTime).toISOString().replace(/[-:.]/g, '').substring(0, 15) + 'Z' + : dtStart; + } } const description = eventData.description ? `DESCRIPTION:${eventData.description.replace(/\n/g, '\\n')}\r\n` : '';