diff --git a/package.json b/package.json index d04583d..f8ad1bc 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/app/globals.css b/src/app/globals.css index 0d6ae23..98ef9bc 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; diff --git a/src/components/WeeklyView.tsx b/src/components/WeeklyView.tsx index 98b3433..5039489 100644 --- a/src/components/WeeklyView.tsx +++ b/src/components/WeeklyView.tsx @@ -7636,6 +7636,17 @@ export default function WeeklyView() { > {event.title} + {event.isRecurring && ( + + )} ); })} diff --git a/src/lib/apple-calendar.ts b/src/lib/apple-calendar.ts index 12de543..15d39c5 100644 --- a/src/lib/apple-calendar.ts +++ b/src/lib/apple-calendar.ts @@ -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; diff --git a/src/lib/synology-calendar.ts b/src/lib/synology-calendar.ts index d224897..09b4d4e 100644 --- a/src/lib/synology-calendar.ts +++ b/src/lib/synology-calendar.ts @@ -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;