fix: CalDAV TZID parsing + recurring event icon on all providers
- Add icalTimeToUtcDate() helper to Apple/Synology parsers that correctly converts ICAL.Time with unresolved TZIDs to UTC dates (fixes +1h shift on Synology/Apple recurring events across DST) - Add recurring icon (Repeat) to bottom-right of all calendar event blocks when event.isRecurring is true v1.63.2 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
346583fe78
commit
fb7484c357
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "my-weekly-todo-list",
|
||||
"version": "1.63.1",
|
||||
"version": "1.63.2",
|
||||
"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": {
|
||||
|
||||
@ -2491,6 +2491,7 @@ h3 {
|
||||
|
||||
/* Calendar Events in Day Column */
|
||||
.weekly-calendar-event {
|
||||
position: relative;
|
||||
padding: 0.5rem 1rem;
|
||||
border-left: 3px solid var(--weekly-teal);
|
||||
background: #f8fafa;
|
||||
|
||||
@ -7636,6 +7636,17 @@ export default function WeeklyView() {
|
||||
>
|
||||
{event.title}
|
||||
</div>
|
||||
{event.isRecurring && (
|
||||
<Repeat
|
||||
size={12}
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 3,
|
||||
right: 5,
|
||||
opacity: 0.5,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -1,6 +1,32 @@
|
||||
import { DAVClient } from 'tsdav';
|
||||
import ICAL from 'ical.js';
|
||||
|
||||
/**
|
||||
* Convert an ICAL.Time to a proper UTC JS Date, handling unresolved TZIDs.
|
||||
*/
|
||||
function icalTimeToUtcDate(icalTime: any, tzid?: string): Date {
|
||||
const jsDate = icalTime.toJSDate();
|
||||
if (!tzid || tzid === 'UTC' || tzid === 'Z') return jsDate;
|
||||
if (icalTime.zone && icalTime.zone !== ICAL.Timezone.utcTimezone && icalTime.zone !== ICAL.Timezone.localTimezone) {
|
||||
return jsDate;
|
||||
}
|
||||
try {
|
||||
const localStr = `${icalTime.year}-${String(icalTime.month).padStart(2, '0')}-${String(icalTime.day).padStart(2, '0')}T${String(icalTime.hour).padStart(2, '0')}:${String(icalTime.minute).padStart(2, '0')}:${String(icalTime.second || 0).padStart(2, '0')}`;
|
||||
const naiveUtc = new Date(localStr + 'Z');
|
||||
const formatter = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: tzid, year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
|
||||
});
|
||||
const parts = formatter.formatToParts(naiveUtc);
|
||||
const get = (t: string) => parts.find(p => p.type === t)?.value || '00';
|
||||
const actualLocal = `${get('year')}-${get('month')}-${get('day')}T${get('hour')}:${get('minute')}:${get('second')}`;
|
||||
const offsetMs = new Date(actualLocal + 'Z').getTime() - naiveUtc.getTime();
|
||||
return new Date(naiveUtc.getTime() - offsetMs);
|
||||
} catch {
|
||||
return jsDate;
|
||||
}
|
||||
}
|
||||
|
||||
// iCloud CalDAV Server URL
|
||||
const ICLOUD_CALDAV_URL = 'https://caldav.icloud.com';
|
||||
|
||||
@ -283,13 +309,15 @@ export const getUpcomingEvents = async (
|
||||
|
||||
exceptionVevents.forEach((exVevent: any) => {
|
||||
const exEvent = new ICAL.Event(exVevent);
|
||||
const exDtStartProp = exVevent.getFirstProperty('dtstart');
|
||||
const exTzid = exDtStartProp?.getParameter('tzid') as string | undefined;
|
||||
const recId = exVevent.getFirstPropertyValue('recurrence-id');
|
||||
if (recId) {
|
||||
exceptionDates.add(recId.toJSDate().toISOString());
|
||||
exceptionDates.add(icalTimeToUtcDate(recId, exTzid).toISOString());
|
||||
}
|
||||
|
||||
const exStart = exEvent.startDate.toJSDate();
|
||||
const exEnd = exEvent.endDate.toJSDate();
|
||||
const exStart = icalTimeToUtcDate(exEvent.startDate, exTzid);
|
||||
const exEnd = icalTimeToUtcDate(exEvent.endDate, exTzid);
|
||||
const exIsAllDay = exEvent.startDate.isDate === true;
|
||||
|
||||
if (exEnd.getTime() >= minTime && exStart.getTime() <= maxTime) {
|
||||
@ -310,14 +338,18 @@ export const getUpcomingEvents = async (
|
||||
|
||||
// Then expand the recurrence rule
|
||||
try {
|
||||
const duration = event.endDate.toJSDate().getTime() - event.startDate.toJSDate().getTime();
|
||||
const dtStartProp = vevent.getFirstProperty('dtstart');
|
||||
const dtStartTzid = dtStartProp?.getParameter('tzid') as string | undefined;
|
||||
const startUtc = icalTimeToUtcDate(event.startDate, dtStartTzid);
|
||||
const endUtc = icalTimeToUtcDate(event.endDate, dtStartTzid);
|
||||
const duration = endUtc.getTime() - startUtc.getTime();
|
||||
const iter = event.iterator();
|
||||
let next;
|
||||
let safetyCount = 0;
|
||||
|
||||
while ((next = iter.next()) && safetyCount < 500) {
|
||||
safetyCount++;
|
||||
const occStart = next.toJSDate();
|
||||
const occStart = icalTimeToUtcDate(next, dtStartTzid);
|
||||
const occEnd = new Date(occStart.getTime() + duration);
|
||||
|
||||
// Stop if we've gone past the range
|
||||
@ -347,8 +379,10 @@ export const getUpcomingEvents = async (
|
||||
}
|
||||
} else {
|
||||
// Simple non-recurring event
|
||||
const start = event.startDate.toJSDate();
|
||||
const end = event.endDate.toJSDate();
|
||||
const nrDtStartProp = vevent.getFirstProperty('dtstart');
|
||||
const nrTzid = nrDtStartProp?.getParameter('tzid') as string | undefined;
|
||||
const start = icalTimeToUtcDate(event.startDate, nrTzid);
|
||||
const end = icalTimeToUtcDate(event.endDate, nrTzid);
|
||||
const isAllDay = event.startDate.isDate === true;
|
||||
|
||||
if (end.getTime() < minTime || start.getTime() > maxTime) return;
|
||||
|
||||
@ -1,6 +1,47 @@
|
||||
import { DAVClient } from 'tsdav';
|
||||
import ICAL from 'ical.js';
|
||||
|
||||
/**
|
||||
* Convert an ICAL.Time to a proper UTC JS Date.
|
||||
* When ICAL.js doesn't have VTIMEZONE info, toJSDate() may treat
|
||||
* TZID'd times as UTC. This helper detects that case and corrects it
|
||||
* using Intl.DateTimeFormat to find the real UTC offset.
|
||||
*/
|
||||
function icalTimeToUtcDate(icalTime: any, tzid?: string): Date {
|
||||
const jsDate = icalTime.toJSDate();
|
||||
// If no TZID, or it's already UTC, or the zone is properly resolved, just return
|
||||
if (!tzid || tzid === 'UTC' || tzid === 'Z') return jsDate;
|
||||
// Check if ICAL.js actually resolved the timezone (zone !== utcTimezone when resolved)
|
||||
if (icalTime.zone && icalTime.zone !== ICAL.Timezone.utcTimezone && icalTime.zone !== ICAL.Timezone.localTimezone) {
|
||||
return jsDate; // Properly resolved
|
||||
}
|
||||
// ICAL.js didn't resolve the TZID — manually convert local time components to UTC
|
||||
// icalTime has year, month, day, hour, minute, second as local-in-TZID values
|
||||
// but toJSDate() treated them as UTC. We need to find the UTC offset for this TZID.
|
||||
try {
|
||||
// Create a date string that we can parse in the target timezone
|
||||
const localStr = `${icalTime.year}-${String(icalTime.month).padStart(2, '0')}-${String(icalTime.day).padStart(2, '0')}T${String(icalTime.hour).padStart(2, '0')}:${String(icalTime.minute).padStart(2, '0')}:${String(icalTime.second || 0).padStart(2, '0')}`;
|
||||
// Find what UTC time corresponds to this local time in the given timezone
|
||||
// Use a binary search approach: start with the naive UTC interpretation and adjust
|
||||
const naiveUtc = new Date(localStr + 'Z');
|
||||
// Get what the local time would be at naiveUtc in the target timezone
|
||||
const formatter = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: tzid, year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
|
||||
});
|
||||
const parts = formatter.formatToParts(naiveUtc);
|
||||
const get = (t: string) => parts.find(p => p.type === t)?.value || '00';
|
||||
const actualLocal = `${get('year')}-${get('month')}-${get('day')}T${get('hour')}:${get('minute')}:${get('second')}`;
|
||||
// The difference between what we wanted and what we got is the offset
|
||||
const wantedMs = naiveUtc.getTime();
|
||||
const actualMs = new Date(actualLocal + 'Z').getTime();
|
||||
const offsetMs = actualMs - wantedMs;
|
||||
return new Date(wantedMs - offsetMs);
|
||||
} catch {
|
||||
return jsDate; // Fallback
|
||||
}
|
||||
}
|
||||
|
||||
export interface SynologyCalendarEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
@ -287,13 +328,15 @@ export const getUpcomingEvents = async (
|
||||
|
||||
exceptionVevents.forEach((exVevent: any) => {
|
||||
const exEvent = new ICAL.Event(exVevent);
|
||||
const exDtStartProp = exVevent.getFirstProperty('dtstart');
|
||||
const exTzid = exDtStartProp?.getParameter('tzid') as string | undefined;
|
||||
const recId = exVevent.getFirstPropertyValue('recurrence-id');
|
||||
if (recId) {
|
||||
exceptionDates.add(recId.toJSDate().toISOString());
|
||||
exceptionDates.add(icalTimeToUtcDate(recId, exTzid).toISOString());
|
||||
}
|
||||
|
||||
const exStart = exEvent.startDate.toJSDate();
|
||||
const exEnd = exEvent.endDate.toJSDate();
|
||||
const exStart = icalTimeToUtcDate(exEvent.startDate, exTzid);
|
||||
const exEnd = icalTimeToUtcDate(exEvent.endDate, exTzid);
|
||||
const exIsAllDay = exEvent.startDate.isDate === true;
|
||||
|
||||
if (exEnd.getTime() >= minTime && exStart.getTime() <= maxTime) {
|
||||
@ -313,14 +356,18 @@ export const getUpcomingEvents = async (
|
||||
});
|
||||
|
||||
try {
|
||||
const duration = event.endDate.toJSDate().getTime() - event.startDate.toJSDate().getTime();
|
||||
const dtStartProp = vevent.getFirstProperty('dtstart');
|
||||
const dtStartTzid = dtStartProp?.getParameter('tzid') as string | undefined;
|
||||
const startUtc = icalTimeToUtcDate(event.startDate, dtStartTzid);
|
||||
const endUtc = icalTimeToUtcDate(event.endDate, dtStartTzid);
|
||||
const duration = endUtc.getTime() - startUtc.getTime();
|
||||
const iter = event.iterator();
|
||||
let next;
|
||||
let safetyCount = 0;
|
||||
|
||||
while ((next = iter.next()) && safetyCount < 500) {
|
||||
safetyCount++;
|
||||
const occStart = next.toJSDate();
|
||||
const occStart = icalTimeToUtcDate(next, dtStartTzid);
|
||||
const occEnd = new Date(occStart.getTime() + duration);
|
||||
|
||||
if (occStart.getTime() > maxTime) break;
|
||||
@ -344,8 +391,10 @@ export const getUpcomingEvents = async (
|
||||
console.error(`[SYNOLOGY CALENDAR] Error expanding recurrence for "${event.summary}":`, expandErr);
|
||||
}
|
||||
} else {
|
||||
const start = event.startDate.toJSDate();
|
||||
const end = event.endDate.toJSDate();
|
||||
const nrDtStartProp = vevent.getFirstProperty('dtstart');
|
||||
const nrTzid = nrDtStartProp?.getParameter('tzid') as string | undefined;
|
||||
const start = icalTimeToUtcDate(event.startDate, nrTzid);
|
||||
const end = icalTimeToUtcDate(event.endDate, nrTzid);
|
||||
const isAllDay = event.startDate.isDate === true;
|
||||
|
||||
if (end.getTime() < minTime || start.getTime() > maxTime) return;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user