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:
mARTin 2026-03-24 10:24:38 +01:00
parent 346583fe78
commit fb7484c357
5 changed files with 110 additions and 15 deletions

View File

@ -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": {

View File

@ -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;

View File

@ -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>
);
})}

View File

@ -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;

View File

@ -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;