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",
|
"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",
|
"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": {
|
||||||
|
|||||||
@ -2491,6 +2491,7 @@ h3 {
|
|||||||
|
|
||||||
/* Calendar Events in Day Column */
|
/* Calendar Events in Day Column */
|
||||||
.weekly-calendar-event {
|
.weekly-calendar-event {
|
||||||
|
position: relative;
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
border-left: 3px solid var(--weekly-teal);
|
border-left: 3px solid var(--weekly-teal);
|
||||||
background: #f8fafa;
|
background: #f8fafa;
|
||||||
|
|||||||
@ -7636,6 +7636,17 @@ export default function WeeklyView() {
|
|||||||
>
|
>
|
||||||
{event.title}
|
{event.title}
|
||||||
</div>
|
</div>
|
||||||
|
{event.isRecurring && (
|
||||||
|
<Repeat
|
||||||
|
size={12}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 3,
|
||||||
|
right: 5,
|
||||||
|
opacity: 0.5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -1,6 +1,32 @@
|
|||||||
import { DAVClient } from 'tsdav';
|
import { DAVClient } from 'tsdav';
|
||||||
import ICAL from 'ical.js';
|
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
|
// iCloud CalDAV Server URL
|
||||||
const ICLOUD_CALDAV_URL = 'https://caldav.icloud.com';
|
const ICLOUD_CALDAV_URL = 'https://caldav.icloud.com';
|
||||||
|
|
||||||
@ -283,13 +309,15 @@ export const getUpcomingEvents = async (
|
|||||||
|
|
||||||
exceptionVevents.forEach((exVevent: any) => {
|
exceptionVevents.forEach((exVevent: any) => {
|
||||||
const exEvent = new ICAL.Event(exVevent);
|
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');
|
const recId = exVevent.getFirstPropertyValue('recurrence-id');
|
||||||
if (recId) {
|
if (recId) {
|
||||||
exceptionDates.add(recId.toJSDate().toISOString());
|
exceptionDates.add(icalTimeToUtcDate(recId, exTzid).toISOString());
|
||||||
}
|
}
|
||||||
|
|
||||||
const exStart = exEvent.startDate.toJSDate();
|
const exStart = icalTimeToUtcDate(exEvent.startDate, exTzid);
|
||||||
const exEnd = exEvent.endDate.toJSDate();
|
const exEnd = icalTimeToUtcDate(exEvent.endDate, exTzid);
|
||||||
const exIsAllDay = exEvent.startDate.isDate === true;
|
const exIsAllDay = exEvent.startDate.isDate === true;
|
||||||
|
|
||||||
if (exEnd.getTime() >= minTime && exStart.getTime() <= maxTime) {
|
if (exEnd.getTime() >= minTime && exStart.getTime() <= maxTime) {
|
||||||
@ -310,14 +338,18 @@ export const getUpcomingEvents = async (
|
|||||||
|
|
||||||
// Then expand the recurrence rule
|
// Then expand the recurrence rule
|
||||||
try {
|
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();
|
const iter = event.iterator();
|
||||||
let next;
|
let next;
|
||||||
let safetyCount = 0;
|
let safetyCount = 0;
|
||||||
|
|
||||||
while ((next = iter.next()) && safetyCount < 500) {
|
while ((next = iter.next()) && safetyCount < 500) {
|
||||||
safetyCount++;
|
safetyCount++;
|
||||||
const occStart = next.toJSDate();
|
const occStart = icalTimeToUtcDate(next, dtStartTzid);
|
||||||
const occEnd = new Date(occStart.getTime() + duration);
|
const occEnd = new Date(occStart.getTime() + duration);
|
||||||
|
|
||||||
// Stop if we've gone past the range
|
// Stop if we've gone past the range
|
||||||
@ -347,8 +379,10 @@ export const getUpcomingEvents = async (
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Simple non-recurring event
|
// Simple non-recurring event
|
||||||
const start = event.startDate.toJSDate();
|
const nrDtStartProp = vevent.getFirstProperty('dtstart');
|
||||||
const end = event.endDate.toJSDate();
|
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;
|
const isAllDay = event.startDate.isDate === true;
|
||||||
|
|
||||||
if (end.getTime() < minTime || start.getTime() > maxTime) return;
|
if (end.getTime() < minTime || start.getTime() > maxTime) return;
|
||||||
|
|||||||
@ -1,6 +1,47 @@
|
|||||||
import { DAVClient } from 'tsdav';
|
import { DAVClient } from 'tsdav';
|
||||||
import ICAL from 'ical.js';
|
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 {
|
export interface SynologyCalendarEvent {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@ -287,13 +328,15 @@ export const getUpcomingEvents = async (
|
|||||||
|
|
||||||
exceptionVevents.forEach((exVevent: any) => {
|
exceptionVevents.forEach((exVevent: any) => {
|
||||||
const exEvent = new ICAL.Event(exVevent);
|
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');
|
const recId = exVevent.getFirstPropertyValue('recurrence-id');
|
||||||
if (recId) {
|
if (recId) {
|
||||||
exceptionDates.add(recId.toJSDate().toISOString());
|
exceptionDates.add(icalTimeToUtcDate(recId, exTzid).toISOString());
|
||||||
}
|
}
|
||||||
|
|
||||||
const exStart = exEvent.startDate.toJSDate();
|
const exStart = icalTimeToUtcDate(exEvent.startDate, exTzid);
|
||||||
const exEnd = exEvent.endDate.toJSDate();
|
const exEnd = icalTimeToUtcDate(exEvent.endDate, exTzid);
|
||||||
const exIsAllDay = exEvent.startDate.isDate === true;
|
const exIsAllDay = exEvent.startDate.isDate === true;
|
||||||
|
|
||||||
if (exEnd.getTime() >= minTime && exStart.getTime() <= maxTime) {
|
if (exEnd.getTime() >= minTime && exStart.getTime() <= maxTime) {
|
||||||
@ -313,14 +356,18 @@ export const getUpcomingEvents = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
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();
|
const iter = event.iterator();
|
||||||
let next;
|
let next;
|
||||||
let safetyCount = 0;
|
let safetyCount = 0;
|
||||||
|
|
||||||
while ((next = iter.next()) && safetyCount < 500) {
|
while ((next = iter.next()) && safetyCount < 500) {
|
||||||
safetyCount++;
|
safetyCount++;
|
||||||
const occStart = next.toJSDate();
|
const occStart = icalTimeToUtcDate(next, dtStartTzid);
|
||||||
const occEnd = new Date(occStart.getTime() + duration);
|
const occEnd = new Date(occStart.getTime() + duration);
|
||||||
|
|
||||||
if (occStart.getTime() > maxTime) break;
|
if (occStart.getTime() > maxTime) break;
|
||||||
@ -344,8 +391,10 @@ export const getUpcomingEvents = async (
|
|||||||
console.error(`[SYNOLOGY CALENDAR] Error expanding recurrence for "${event.summary}":`, expandErr);
|
console.error(`[SYNOLOGY CALENDAR] Error expanding recurrence for "${event.summary}":`, expandErr);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const start = event.startDate.toJSDate();
|
const nrDtStartProp = vevent.getFirstProperty('dtstart');
|
||||||
const end = event.endDate.toJSDate();
|
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;
|
const isAllDay = event.startDate.isDate === true;
|
||||||
|
|
||||||
if (end.getTime() < minTime || start.getTime() > maxTime) return;
|
if (end.getTime() < minTime || start.getTime() > maxTime) return;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user