fix: detect iCloud all-day events and bypass cache on manual refresh

- Apple calendar parser now checks ICAL.js isDate flag and emits
  date-only strings (YYYY-MM-DD) for all-day events instead of
  full ISO datetime — fixes all-day detection in the frontend
- Calendar events mapper routes date-only Apple events to the
  date field instead of always using dateTime
- Manual refresh button now sends forceRefresh=true which makes
  the sync endpoint wait for live data instead of returning stale cache
- Automatic background syncs still use fire-and-forget for performance

v1.2.2

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin 2026-02-24 00:26:04 +01:00
parent 1f202eb8c9
commit 866f514f10
5 changed files with 43 additions and 18 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "my-weekly-todo-list", "name": "my-weekly-todo-list",
"version": "1.2.1", "version": "1.2.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": {

View File

@ -47,9 +47,9 @@ export async function POST(request: NextRequest) {
); );
const staleConnections = staleChecks.filter(c => c.stale).map(c => c.conn); const staleConnections = staleChecks.filter(c => c.stale).map(c => c.conn);
// BACKGROUND REFRESH: fire-and-forget for stale connections // Refresh stale connections
if (staleConnections.length > 0) { if (staleConnections.length > 0) {
const refreshWork = Promise.allSettled( const doRefresh = () => Promise.allSettled(
staleConnections.map(conn => { staleConnections.map(conn => {
const rc: RefreshableConnection = { const rc: RefreshableConnection = {
dbId: conn.id, dbId: conn.id,
@ -64,8 +64,24 @@ export async function POST(request: NextRequest) {
return refreshConnectionCache(rc, timeMinDate, timeMaxDate); return refreshConnectionCache(rc, timeMinDate, timeMaxDate);
}) })
); );
if (forceRefresh) {
// BLOCKING: wait for fresh data when user explicitly requests refresh
await doRefresh();
const freshEvents = await readCachedEvents(user.id, timeMinDate, timeMaxDate);
return NextResponse.json({
success: true,
events: freshEvents,
count: freshEvents.length,
fromCache: false,
staleConnectionCount: 0,
});
} else {
// BACKGROUND: fire-and-forget for automatic refresh
const refreshWork = doRefresh();
refreshWork.catch(e => console.error('[CACHE] Background refresh error:', e)); refreshWork.catch(e => console.error('[CACHE] Background refresh error:', e));
} }
}
return NextResponse.json({ return NextResponse.json({
success: true, success: true,

View File

@ -857,7 +857,7 @@ export default function WeeklyView() {
const workingHoursEnd = endHour; const workingHoursEnd = endHour;
// Fetch calendar events // Fetch calendar events
const fetchCalendarEvents = useCallback(async () => { const fetchCalendarEvents = useCallback(async (forceRefresh = false) => {
setIsSyncing(true); setIsSyncing(true);
try { try {
const response = await fetch("/api/calendar/sync", { const response = await fetch("/api/calendar/sync", {
@ -868,6 +868,7 @@ export default function WeeklyView() {
timeMax: new Date( timeMax: new Date(
currentWeekStart.getTime() + 7 * 24 * 60 * 60 * 1000, currentWeekStart.getTime() + 7 * 24 * 60 * 60 * 1000,
).toISOString(), ).toISOString(),
forceRefresh,
}), }),
}); });
@ -3394,7 +3395,7 @@ export default function WeeklyView() {
<div className="weekly-spinner" title="Syncing..."></div> <div className="weekly-spinner" title="Syncing..."></div>
) : ( ) : (
<button <button
onClick={() => { fetchCalendarEvents(); fetchTasks(); }} onClick={() => { fetchCalendarEvents(true); fetchTasks(); }}
className="p-1 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-all text-gray-400 hover:text-gray-600 opacity-0 group-hover:opacity-100" className="p-1 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-all text-gray-400 hover:text-gray-600 opacity-0 group-hover:opacity-100"
title="Refresh Calendar & Tasks" title="Refresh Calendar & Tasks"
> >

View File

@ -145,6 +145,8 @@ export const getUpcomingEvents = async (
const exceptionDates = new Set<string>(); const exceptionDates = new Set<string>();
// First, process exceptions that fall in our range // First, process exceptions that fall in our range
const isAllDayRecurring = event.startDate.isDate === true;
exceptionVevents.forEach((exVevent: any) => { exceptionVevents.forEach((exVevent: any) => {
const exEvent = new ICAL.Event(exVevent); const exEvent = new ICAL.Event(exVevent);
const recId = exVevent.getFirstPropertyValue('recurrence-id'); const recId = exVevent.getFirstPropertyValue('recurrence-id');
@ -154,13 +156,14 @@ export const getUpcomingEvents = async (
const exStart = exEvent.startDate.toJSDate(); const exStart = exEvent.startDate.toJSDate();
const exEnd = exEvent.endDate.toJSDate(); const exEnd = exEvent.endDate.toJSDate();
const exIsAllDay = exEvent.startDate.isDate === true;
if (exEnd.getTime() >= minTime && exStart.getTime() <= maxTime) { if (exEnd.getTime() >= minTime && exStart.getTime() <= maxTime) {
parsedEvents.push({ parsedEvents.push({
id: `${exEvent.uid}-${exStart.toISOString()}`, id: `${exEvent.uid}-${exStart.toISOString()}`,
title: exEvent.summary || 'Untitled Event', title: exEvent.summary || 'Untitled Event',
startDate: exStart.toISOString(), startDate: exIsAllDay ? exStart.toISOString().slice(0, 10) : exStart.toISOString(),
endDate: exEnd.toISOString(), endDate: exIsAllDay ? exEnd.toISOString().slice(0, 10) : exEnd.toISOString(),
description: exEvent.description, description: exEvent.description,
location: exEvent.location location: exEvent.location
}); });
@ -191,8 +194,8 @@ export const getUpcomingEvents = async (
parsedEvents.push({ parsedEvents.push({
id: `${event.uid}-${occStart.toISOString()}`, id: `${event.uid}-${occStart.toISOString()}`,
title: event.summary || 'Untitled Event', title: event.summary || 'Untitled Event',
startDate: occStart.toISOString(), startDate: isAllDayRecurring ? occStart.toISOString().slice(0, 10) : occStart.toISOString(),
endDate: occEnd.toISOString(), endDate: isAllDayRecurring ? occEnd.toISOString().slice(0, 10) : occEnd.toISOString(),
description: event.description, description: event.description,
location: event.location location: event.location
}); });
@ -204,14 +207,15 @@ export const getUpcomingEvents = async (
// Simple non-recurring event // Simple non-recurring event
const start = event.startDate.toJSDate(); const start = event.startDate.toJSDate();
const end = event.endDate.toJSDate(); const end = event.endDate.toJSDate();
const isAllDay = event.startDate.isDate === true;
if (end.getTime() < minTime || start.getTime() > maxTime) return; if (end.getTime() < minTime || start.getTime() > maxTime) return;
parsedEvents.push({ parsedEvents.push({
id: event.uid || eventObj.url, id: event.uid || eventObj.url,
title: event.summary || 'Untitled Event', title: event.summary || 'Untitled Event',
startDate: start.toISOString(), startDate: isAllDay ? start.toISOString().slice(0, 10) : start.toISOString(),
endDate: end.toISOString(), endDate: isAllDay ? end.toISOString().slice(0, 10) : end.toISOString(),
description: event.description, description: event.description,
location: event.location location: event.location
}); });

View File

@ -301,24 +301,28 @@ export const getCalendarEvents = async (
console.log(`[CALENDAR] Fetched ${calendarEvents.length} events from calendar ${calendarId}`); console.log(`[CALENDAR] Fetched ${calendarEvents.length} events from calendar ${calendarId}`);
events = events.concat(calendarEvents.map((event: any) => ({ events = events.concat(calendarEvents.map((event: any) => {
// Date-only strings (YYYY-MM-DD) indicate all-day events
const isDateOnly = (s: string) => s && !s.includes('T');
const startIsAllDay = isDateOnly(event.startDate);
return {
id: event.id, id: event.id,
title: event.title, title: event.title,
description: event.description, description: event.description,
start: { start: {
dateTime: event.startDate, dateTime: startIsAllDay ? undefined : event.startDate,
date: undefined // Apple usually returns date-time date: startIsAllDay ? event.startDate : undefined,
}, },
end: { end: {
dateTime: event.endDate, dateTime: startIsAllDay ? undefined : event.endDate,
date: undefined date: startIsAllDay ? event.endDate : undefined,
}, },
location: event.location, location: event.location,
source: 'apple' as const, source: 'apple' as const,
calendarId, calendarId,
calendarTitle: calendars.find(c => c.id === calendarId)?.title || 'Apple Calendar', calendarTitle: calendars.find(c => c.id === calendarId)?.title || 'Apple Calendar',
backgroundColor: calendars.find(c => c.id === calendarId)?.color || '#FF3B30' backgroundColor: calendars.find(c => c.id === calendarId)?.color || '#FF3B30'
}))); };}));
} }
} else if (connection.provider === 'outlook') { } else if (connection.provider === 'outlook') {
console.log('[CALENDAR] Processing Outlook connection:', connection.id); console.log('[CALENDAR] Processing Outlook connection:', connection.id);