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:
parent
c5dc315f82
commit
1011aef639
@ -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": {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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` : '';
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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` : '';
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user