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 <noreply@anthropic.com>
This commit is contained in:
mARTin 2026-03-23 21:23:24 +01:00
parent c5dc315f82
commit 1011aef639
7 changed files with 59 additions and 11 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "my-weekly-todo-list", "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", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

View File

@ -40,7 +40,7 @@ export async function POST(request: NextRequest) {
const body = await request.json(); const body = await request.json();
const { calendarId, title, description, start, end, location, allDay, recurrence, recurrenceEndDate, recurrenceCount, recurrenceInterval, recurrenceDays, url, 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 }); console.log('[API] Creating event:', { calendarId, title, start, end });
@ -67,6 +67,7 @@ export async function POST(request: NextRequest) {
recurrenceCount, recurrenceCount,
recurrenceInterval, recurrenceInterval,
recurrenceDays, recurrenceDays,
timezone,
url, url,
reminders, reminders,
busyStatus, busyStatus,

View File

@ -194,6 +194,7 @@ export default function CalendarEventModal({
allDay, allDay,
start: { dateTime: startDate.toISOString() }, start: { dateTime: startDate.toISOString() },
end: { dateTime: endDate.toISOString() }, end: { dateTime: endDate.toISOString() },
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
reminders: activeReminders.length > 0 ? activeReminders : undefined, reminders: activeReminders.length > 0 ? activeReminders : undefined,
busyStatus: busyStatus !== 'busy' ? busyStatus : undefined, busyStatus: busyStatus !== 'busy' ? busyStatus : undefined,
visibility: visibility !== 'default' ? visibility : undefined, visibility: visibility !== 'default' ? visibility : undefined,

View File

@ -3546,6 +3546,11 @@ export default function WeeklyView() {
const end = new Date(event.endTime); const end = new Date(event.endTime);
const durationMinutes = (end.getTime() - start.getTime()) / (1000 * 60); 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 // Calculate height based on duration and slot height
const pixelsPerMinute = getSlotHeight(effectiveCellDuration) / effectiveCellDuration; const pixelsPerMinute = getSlotHeight(effectiveCellDuration) / effectiveCellDuration;
return Math.max( return Math.max(

View File

@ -394,6 +394,7 @@ export const createEvent = async (
recurrenceCount?: number; recurrenceCount?: number;
recurrenceInterval?: number; recurrenceInterval?: number;
recurrenceDays?: number[]; recurrenceDays?: number[];
timezone?: string;
start: { dateTime?: string; date?: string }; start: { dateTime?: string; date?: string };
end: { dateTime?: string; date?: string }; end: { dateTime?: string; date?: string };
reminders?: Array<{ method: string; minutes: number }>; 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 // 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. // But typically UI handles this. Let's assume input is correct.
} else if (eventData.start.dateTime) { } else if (eventData.start.dateTime) {
// Timed event // For recurring events, use local time with TZID to avoid DST shifts
dtStart = new Date(eventData.start.dateTime).toISOString().replace(/[-:.]/g, '').substring(0, 15) + 'Z'; if (eventData.recurrence && eventData.timezone) {
dtEnd = eventData.end.dateTime const tz = eventData.timezone;
? new Date(eventData.end.dateTime).toISOString().replace(/[-:.]/g, '').substring(0, 15) + 'Z' const toLocalIcal = (isoStr: string) => {
: dtStart; 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` : ''; const description = eventData.description ? `DESCRIPTION:${eventData.description.replace(/\n/g, '\\n')}\r\n` : '';

View File

@ -45,6 +45,7 @@ export interface CalendarEvent {
recurrenceCount?: number; recurrenceCount?: number;
recurrenceInterval?: number; recurrenceInterval?: number;
recurrenceDays?: number[]; recurrenceDays?: number[];
timezone?: string;
recurringEventId?: string; recurringEventId?: string;
isRecurring?: boolean; isRecurring?: boolean;
source: 'google' | 'apple' | 'outlook' | 'synology' | 'notion'; source: 'google' | 'apple' | 'outlook' | 'synology' | 'notion';
@ -952,6 +953,7 @@ export const createCalendarEvent = async (
recurrenceCount: event.recurrenceCount, recurrenceCount: event.recurrenceCount,
recurrenceInterval: event.recurrenceInterval, recurrenceInterval: event.recurrenceInterval,
recurrenceDays: event.recurrenceDays, recurrenceDays: event.recurrenceDays,
timezone: event.timezone,
start, start,
end, end,
reminders: event.reminders, reminders: event.reminders,
@ -992,6 +994,7 @@ export const createCalendarEvent = async (
recurrenceCount: event.recurrenceCount, recurrenceCount: event.recurrenceCount,
recurrenceInterval: event.recurrenceInterval, recurrenceInterval: event.recurrenceInterval,
recurrenceDays: event.recurrenceDays, recurrenceDays: event.recurrenceDays,
timezone: event.timezone,
start: event.start!, start: event.start!,
end: event.end!, end: event.end!,
reminders: event.reminders, reminders: event.reminders,

View File

@ -404,6 +404,7 @@ export const createEvent = async (
recurrenceCount?: number; recurrenceCount?: number;
recurrenceInterval?: number; recurrenceInterval?: number;
recurrenceDays?: number[]; recurrenceDays?: number[];
timezone?: string;
start: { dateTime?: string; date?: string }; start: { dateTime?: string; date?: string };
end: { dateTime?: string; date?: string }; end: { dateTime?: string; date?: string };
reminders?: Array<{ method: string; minutes: number }>; reminders?: Array<{ method: string; minutes: number }>;
@ -440,10 +441,28 @@ export const createEvent = async (
dtStartParam = ';VALUE=DATE'; dtStartParam = ';VALUE=DATE';
dtEndParam = ';VALUE=DATE'; dtEndParam = ';VALUE=DATE';
} else if (eventData.start.dateTime) { } else if (eventData.start.dateTime) {
dtStart = new Date(eventData.start.dateTime).toISOString().replace(/[-:.]/g, '').substring(0, 15) + 'Z'; // For recurring events, use local time with TZID to avoid DST shifts
dtEnd = eventData.end.dateTime if (eventData.recurrence && eventData.timezone) {
? new Date(eventData.end.dateTime).toISOString().replace(/[-:.]/g, '').substring(0, 15) + 'Z' const tz = eventData.timezone;
: dtStart; 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` : ''; const description = eventData.description ? `DESCRIPTION:${eventData.description.replace(/\n/g, '\\n')}\r\n` : '';