From c5dc315f82c1fb3a06610ea305a1e62143ddd11b Mon Sep 17 00:00:00 2001 From: mARTin Date: Mon, 23 Mar 2026 17:35:22 +0100 Subject: [PATCH] feat: sync calendar colors from all providers and add custom recurrence - Store calendar colors (backgroundColor) from Apple and Synology during connection setup - Auto-refresh missing colors for existing Apple/Synology connections - Show color dots next to calendar names in settings - Add "Custom..." recurrence option with interval (every N days/weeks/ months/years) and day-of-week selection for weekly recurrence - Generate proper RRULE with INTERVAL and BYDAY for all providers - Generate proper Outlook Graph recurrence with custom intervals v1.61.0 Co-Authored-By: Claude Opus 4.6 --- package.json | 2 +- src/app/api/calendar/apple/connect/route.ts | 1 + src/app/api/calendar/connections/route.ts | 50 ++++++++++++--- src/app/api/calendar/events/route.ts | 8 ++- .../api/calendar/synology/connect/route.ts | 1 + src/components/CalendarEventModal.tsx | 63 ++++++++++++++++++- src/components/WeeklyView.tsx | 9 ++- src/lib/apple-calendar.ts | 9 +++ src/lib/calendar-events.ts | 46 +++++++++----- src/lib/synology-calendar.ts | 9 +++ 10 files changed, 172 insertions(+), 26 deletions(-) diff --git a/package.json b/package.json index 1659121..0d45958 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "my-weekly-todo-list", - "version": "1.60.0", + "version": "1.61.0", "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/api/calendar/apple/connect/route.ts b/src/app/api/calendar/apple/connect/route.ts index ba9d96a..57f4845 100644 --- a/src/app/api/calendar/apple/connect/route.ts +++ b/src/app/api/calendar/apple/connect/route.ts @@ -57,6 +57,7 @@ export async function POST(req: Request) { id: cal.id, title: cal.title, isPrimary: cal.isPrimary, + backgroundColor: cal.color || undefined, selected: true // Default to selected })) as any, updatedAt: new Date() diff --git a/src/app/api/calendar/connections/route.ts b/src/app/api/calendar/connections/route.ts index 0a48753..2c71f70 100644 --- a/src/app/api/calendar/connections/route.ts +++ b/src/app/api/calendar/connections/route.ts @@ -4,6 +4,7 @@ import { authOptions } from "@/lib/auth"; import { prisma } from '@/lib/prisma'; import { refreshConnectionCache, RefreshableConnection } from '@/lib/calendar-cache'; import { getUserCalendars as getSynologyCalendars } from '@/lib/synology-calendar'; +import { getUserCalendars as getAppleCalendars } from '@/lib/apple-calendar'; // Get user's calendar connections export async function GET(request: NextRequest) { @@ -42,7 +43,7 @@ export async function GET(request: NextRequest) { ); } - // For Synology connections, fire-and-forget prune of deleted calendars + // For Synology connections, fire-and-forget prune of deleted calendars + refresh colors if (conn.provider === 'synology' && Array.isArray(calendars) && calendars.length > 0) { const connId = conn.id; const storedCalendars = calendars; @@ -52,14 +53,20 @@ export async function GET(request: NextRequest) { const serverUrl = conn.refreshToken; if (username && password && serverUrl) { const freshCalendars = await getSynologyCalendars(serverUrl, username, password); - const freshIds = new Set(freshCalendars.map(c => c.id)); - const staleIds = storedCalendars.filter((c: any) => !freshIds.has(c.id)).map((c: any) => c.id); - if (staleIds.length > 0) { - console.log('[CONNECTIONS] Synology stale calendars to prune:', staleIds); - const pruned = storedCalendars.filter((c: any) => freshIds.has(c.id)); + const freshMap = new Map(freshCalendars.map(c => [c.id, c])); + const staleIds = storedCalendars.filter((c: any) => !freshMap.has(c.id)).map((c: any) => c.id); + const hasMissingColors = storedCalendars.some((c: any) => !c.backgroundColor); + if (staleIds.length > 0 || hasMissingColors) { + console.log('[CONNECTIONS] Synology refresh: pruning', staleIds.length, 'stale, refreshing colors:', hasMissingColors); + const updated = storedCalendars + .filter((c: any) => freshMap.has(c.id)) + .map((c: any) => { + const fresh = freshMap.get(c.id); + return { ...c, backgroundColor: c.backgroundColor || fresh?.color || undefined }; + }); await prisma.calendarConnection.update({ where: { id: connId }, - data: { calendars: pruned }, + data: { calendars: updated }, }); } } @@ -69,6 +76,35 @@ export async function GET(request: NextRequest) { })(); } + // For Apple connections, fire-and-forget refresh of missing colors + if (conn.provider === 'apple' && Array.isArray(calendars) && calendars.length > 0) { + const connId = conn.id; + const storedCalendars = calendars; + const hasMissingColors = storedCalendars.some((c: any) => !c.backgroundColor); + if (hasMissingColors) { + (async () => { + try { + const [email, appPassword] = conn.accessToken.split(':'); + if (email && appPassword) { + const freshCalendars = await getAppleCalendars(email, appPassword); + const freshMap = new Map(freshCalendars.map(c => [c.id, c])); + const updated = storedCalendars.map((c: any) => { + const fresh = freshMap.get(c.id); + return { ...c, backgroundColor: c.backgroundColor || fresh?.color || undefined }; + }); + await prisma.calendarConnection.update({ + where: { id: connId }, + data: { calendars: updated }, + }); + console.log('[CONNECTIONS] Apple calendars refreshed with colors'); + } + } catch (err) { + console.error('[CONNECTIONS] Apple calendar color refresh failed:', err); + } + })(); + } + } + return { id: conn.id, provider: conn.provider, diff --git a/src/app/api/calendar/events/route.ts b/src/app/api/calendar/events/route.ts index eff8fba..bc2f0be 100644 --- a/src/app/api/calendar/events/route.ts +++ b/src/app/api/calendar/events/route.ts @@ -39,7 +39,7 @@ export async function POST(request: NextRequest) { if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); const body = await request.json(); - const { calendarId, title, description, start, end, location, allDay, recurrence, recurrenceEndDate, recurrenceCount, url, + const { calendarId, title, description, start, end, location, allDay, recurrence, recurrenceEndDate, recurrenceCount, recurrenceInterval, recurrenceDays, url, reminders, busyStatus, visibility, attendees, attachments } = body; console.log('[API] Creating event:', { calendarId, title, start, end }); @@ -65,6 +65,8 @@ export async function POST(request: NextRequest) { recurrence, recurrenceEndDate, recurrenceCount, + recurrenceInterval, + recurrenceDays, url, reminders, busyStatus, @@ -94,7 +96,7 @@ export async function PATCH(request: NextRequest) { if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); const body = await request.json(); - const { calendarId, eventId, title, description, start, end, location, allDay, recurrence, recurrenceEndDate, recurrenceCount, url, + const { calendarId, eventId, title, description, start, end, location, allDay, recurrence, recurrenceEndDate, recurrenceCount, recurrenceInterval, recurrenceDays, url, reminders, busyStatus, visibility, attendees, attachments } = body; console.log('[API] Updating event:', { calendarId, eventId, title }); @@ -120,6 +122,8 @@ export async function PATCH(request: NextRequest) { recurrence, recurrenceEndDate, recurrenceCount, + recurrenceInterval, + recurrenceDays, url, reminders, busyStatus, diff --git a/src/app/api/calendar/synology/connect/route.ts b/src/app/api/calendar/synology/connect/route.ts index fc89f80..b70755b 100644 --- a/src/app/api/calendar/synology/connect/route.ts +++ b/src/app/api/calendar/synology/connect/route.ts @@ -71,6 +71,7 @@ export async function POST(req: Request) { id: cal.id, title: cal.title, isPrimary: cal.isPrimary, + backgroundColor: cal.color || undefined, selected: true })) as any } diff --git a/src/components/CalendarEventModal.tsx b/src/components/CalendarEventModal.tsx index f6d801f..63d680e 100644 --- a/src/components/CalendarEventModal.tsx +++ b/src/components/CalendarEventModal.tsx @@ -77,6 +77,11 @@ export default function CalendarEventModal({ event?.recurrenceEndDate || '' ); const [recurrenceCount, setRecurrenceCount] = useState(event?.recurrenceCount || 10); + // Custom recurrence fields + const [customInterval, setCustomInterval] = useState(event?.recurrenceInterval || 1); + const [customUnit, setCustomUnit] = useState<'days' | 'weeks' | 'months' | 'years'>(event?.recurrenceUnit || 'weeks'); + const startDow = (() => { const d = event?.start?.dateTime ? new Date(event.start.dateTime) : new Date(); return d.getDay(); })(); + const [customDays, setCustomDays] = useState(event?.recurrenceDays || [startDow]); const [calendarId, setCalendarId] = useState(event?.calendarId || (availableCalendars.length > 0 ? availableCalendars[0].id : '')); // New fields @@ -179,7 +184,10 @@ export default function CalendarEventModal({ description, location, url: url || undefined, - recurrence: recurrence !== 'none' ? recurrence : undefined, + recurrence: recurrence === 'custom' ? customUnit.replace(/s$/, '') === 'day' ? 'daily' : customUnit.replace(/s$/, '') === 'week' ? 'weekly' : customUnit.replace(/s$/, '') === 'month' ? 'monthly' : 'yearly' + : recurrence !== 'none' ? recurrence : undefined, + recurrenceInterval: recurrence === 'custom' ? customInterval : undefined, + recurrenceDays: recurrence === 'custom' && customUnit === 'weeks' ? customDays : undefined, recurrenceEndDate: recurrence !== 'none' && recurrenceEndType === 'date' && recurrenceEndDateValue ? recurrenceEndDateValue : undefined, recurrenceCount: recurrence !== 'none' && recurrenceEndType === 'count' && recurrenceCount > 0 ? recurrenceCount : undefined, calendarId, @@ -443,9 +451,62 @@ export default function CalendarEventModal({ + + {/* Custom recurrence options */} + {recurrence === 'custom' && ( + <> +
+ Every +
+ setCustomInterval(Math.max(1, parseInt(e.target.value) || 1))} + style={{ + ...selectStyle, width: '45px', textAlign: 'center', + background: 'var(--weekly-bg-secondary, #f5f5f5)', borderRadius: '6px', padding: '4px 6px', + }} + /> + +
+
+ {customUnit === 'weeks' && ( +
+ Repeat on +
+ {['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((day, i) => ( + + ))} +
+
+ )} + + )} + {/* Recurrence End - only shown when repeat is set */} {recurrence !== 'none' && ( <> diff --git a/src/components/WeeklyView.tsx b/src/components/WeeklyView.tsx index ebf1e52..7887991 100644 --- a/src/components/WeeklyView.tsx +++ b/src/components/WeeklyView.tsx @@ -12405,7 +12405,7 @@ function SettingsSidebar({ padding: "3px 0", }} > - {/* Calendar Name */} + {/* Calendar Color + Name */} + {cleanTitle} {isShared && ( diff --git a/src/lib/apple-calendar.ts b/src/lib/apple-calendar.ts index 8a9420a..27ba6cd 100644 --- a/src/lib/apple-calendar.ts +++ b/src/lib/apple-calendar.ts @@ -392,6 +392,8 @@ export const createEvent = async ( recurrence?: string; recurrenceEndDate?: string; recurrenceCount?: number; + recurrenceInterval?: number; + recurrenceDays?: number[]; start: { dateTime?: string; date?: string }; end: { dateTime?: string; date?: string }; reminders?: Array<{ method: string; minutes: number }>; @@ -458,6 +460,13 @@ export const createEvent = async ( }; if (rruleMap[eventData.recurrence]) { let rrule = rruleMap[eventData.recurrence]; + if (eventData.recurrenceInterval && eventData.recurrenceInterval > 1 && eventData.recurrence !== 'biweekly') { + rrule += `;INTERVAL=${eventData.recurrenceInterval}`; + } + if (eventData.recurrenceDays && eventData.recurrenceDays.length > 0 && eventData.recurrence === 'weekly') { + const dayMap = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA']; + rrule += `;BYDAY=${eventData.recurrenceDays.map(d => dayMap[d]).join(',')}`; + } if (eventData.recurrenceCount && eventData.recurrenceCount > 0) { rrule += `;COUNT=${eventData.recurrenceCount}`; } else if (eventData.recurrenceEndDate) { diff --git a/src/lib/calendar-events.ts b/src/lib/calendar-events.ts index 2d406dd..ea1b848 100644 --- a/src/lib/calendar-events.ts +++ b/src/lib/calendar-events.ts @@ -43,6 +43,8 @@ export interface CalendarEvent { recurrence?: string; recurrenceEndDate?: string; recurrenceCount?: number; + recurrenceInterval?: number; + recurrenceDays?: number[]; recurringEventId?: string; isRecurring?: boolean; source: 'google' | 'apple' | 'outlook' | 'synology' | 'notion'; @@ -60,7 +62,7 @@ export interface CalendarEvent { /** * Convert friendly recurrence name to RRULE string */ -function toRRule(recurrence?: string, recurrenceEndDate?: string, recurrenceCount?: number): string | null { +function toRRule(recurrence?: string, recurrenceEndDate?: string, recurrenceCount?: number, recurrenceInterval?: number, recurrenceDays?: number[]): string | null { let freq: string; switch (recurrence) { case 'daily': freq = 'FREQ=DAILY'; break; @@ -71,10 +73,16 @@ function toRRule(recurrence?: string, recurrenceEndDate?: string, recurrenceCoun default: return null; } let rrule = `RRULE:${freq}`; + if (recurrenceInterval && recurrenceInterval > 1 && recurrence !== 'biweekly') { + rrule += `;INTERVAL=${recurrenceInterval}`; + } + if (recurrenceDays && recurrenceDays.length > 0 && recurrence === 'weekly') { + const dayMap = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA']; + rrule += `;BYDAY=${recurrenceDays.map(d => dayMap[d]).join(',')}`; + } if (recurrenceCount && recurrenceCount > 0) { rrule += `;COUNT=${recurrenceCount}`; } else if (recurrenceEndDate) { - // UNTIL format: YYYYMMDDTHHMMSSZ const d = new Date(recurrenceEndDate); d.setHours(23, 59, 59); rrule += `;UNTIL=${d.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z')}`; @@ -85,9 +93,11 @@ function toRRule(recurrence?: string, recurrenceEndDate?: string, recurrenceCoun /** * Convert friendly recurrence name to Outlook Graph recurrence object */ -function toOutlookRecurrence(recurrence?: string, startDate?: Date, recurrenceEndDate?: string, recurrenceCount?: number): any { +function toOutlookRecurrence(recurrence?: string, startDate?: Date, recurrenceEndDate?: string, recurrenceCount?: number, recurrenceInterval?: number, recurrenceDays?: number[]): any { if (!recurrence || recurrence === 'none') return undefined; const start = startDate || new Date(); + const interval = recurrenceInterval || (recurrence === 'biweekly' ? 2 : 1); + const dayNames = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']; let range: any = { type: 'noEnd', startDate: start.toISOString().split('T')[0], @@ -99,15 +109,18 @@ function toOutlookRecurrence(recurrence?: string, startDate?: Date, recurrenceEn } switch (recurrence) { case 'daily': - return { pattern: { type: 'daily', interval: 1 }, range }; + return { pattern: { type: 'daily', interval }, range }; case 'weekly': - return { pattern: { type: 'weekly', interval: 1, daysOfWeek: [['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'][start.getDay()]] }, range }; - case 'biweekly': - return { pattern: { type: 'weekly', interval: 2, daysOfWeek: [['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'][start.getDay()]] }, range }; + case 'biweekly': { + const days = recurrenceDays && recurrenceDays.length > 0 + ? recurrenceDays.map(d => dayNames[d]) + : [dayNames[start.getDay()]]; + return { pattern: { type: 'weekly', interval, daysOfWeek: days }, range }; + } case 'monthly': - return { pattern: { type: 'absoluteMonthly', interval: 1, dayOfMonth: start.getDate() }, range }; + return { pattern: { type: 'absoluteMonthly', interval, dayOfMonth: start.getDate() }, range }; case 'yearly': - return { pattern: { type: 'absoluteYearly', interval: 1, dayOfMonth: start.getDate(), month: start.getMonth() + 1 }, range }; + return { pattern: { type: 'absoluteYearly', interval, dayOfMonth: start.getDate(), month: start.getMonth() + 1 }, range }; default: return undefined; } } @@ -516,6 +529,7 @@ export const getCalendarEvents = async ( console.log(`[CALENDAR] Synology: fetched ${calendarEvents.length} events from calendar ${calendarId}`); const calendarData = calendars.find(c => c.id === calendarId); + const freshCal = freshCalendars.find(c => c.id === calendarId); events = events.concat(calendarEvents.map((event: any) => { const isDateOnly = (s: string) => s && !s.includes('T'); @@ -539,7 +553,7 @@ export const getCalendarEvents = async ( source: 'synology' as const, calendarId, calendarTitle: calendarData?.title || 'Synology Calendar', - backgroundColor: calendarData?.color || '#1b85ff', + backgroundColor: calendarData?.backgroundColor || calendarData?.color || freshCal?.color || '#1b85ff', reminders: event.reminders as EventReminder[] || undefined, busyStatus: event.busyStatus as BusyStatus || undefined, visibility: event.visibility as EventVisibility || undefined, @@ -825,7 +839,7 @@ export const createCalendarEvent = async ( ); // Map to Google format - const rrule = toRRule(event.recurrence, event.recurrenceEndDate, event.recurrenceCount); + const rrule = toRRule(event.recurrence, event.recurrenceEndDate, event.recurrenceCount, event.recurrenceInterval, event.recurrenceDays); const googleEvent: any = { summary: event.title, description: event.description, @@ -877,7 +891,7 @@ export const createCalendarEvent = async ( end: event.end, location: event.location, allDay: event.allDay, - recurrence: toOutlookRecurrence(event.recurrence, startDate, event.recurrenceEndDate, event.recurrenceCount), + recurrence: toOutlookRecurrence(event.recurrence, startDate, event.recurrenceEndDate, event.recurrenceCount, event.recurrenceInterval, event.recurrenceDays), reminders: event.reminders, busyStatus: event.busyStatus, visibility: event.visibility, @@ -936,6 +950,8 @@ export const createCalendarEvent = async ( recurrence: event.recurrence, recurrenceEndDate: event.recurrenceEndDate, recurrenceCount: event.recurrenceCount, + recurrenceInterval: event.recurrenceInterval, + recurrenceDays: event.recurrenceDays, start, end, reminders: event.reminders, @@ -974,6 +990,8 @@ export const createCalendarEvent = async ( recurrence: event.recurrence, recurrenceEndDate: event.recurrenceEndDate, recurrenceCount: event.recurrenceCount, + recurrenceInterval: event.recurrenceInterval, + recurrenceDays: event.recurrenceDays, start: event.start!, end: event.end!, reminders: event.reminders, @@ -1056,7 +1074,7 @@ export const updateCalendarEvent = async ( ); // Map to Google format - const rrule = toRRule(event.recurrence, event.recurrenceEndDate, event.recurrenceCount); + const rrule = toRRule(event.recurrence, event.recurrenceEndDate, event.recurrenceCount, event.recurrenceInterval, event.recurrenceDays); const googleEvent: any = {}; if (event.title !== undefined) googleEvent.summary = event.title; if (event.description !== undefined) googleEvent.description = event.description; @@ -1109,7 +1127,7 @@ export const updateCalendarEvent = async ( end: event.end, location: event.location, allDay: event.allDay, - recurrence: toOutlookRecurrence(event.recurrence, startDate, event.recurrenceEndDate, event.recurrenceCount), + recurrence: toOutlookRecurrence(event.recurrence, startDate, event.recurrenceEndDate, event.recurrenceCount, event.recurrenceInterval, event.recurrenceDays), reminders: event.reminders, busyStatus: event.busyStatus, visibility: event.visibility, diff --git a/src/lib/synology-calendar.ts b/src/lib/synology-calendar.ts index 7683c93..8b35d52 100644 --- a/src/lib/synology-calendar.ts +++ b/src/lib/synology-calendar.ts @@ -402,6 +402,8 @@ export const createEvent = async ( recurrence?: string; recurrenceEndDate?: string; recurrenceCount?: number; + recurrenceInterval?: number; + recurrenceDays?: number[]; start: { dateTime?: string; date?: string }; end: { dateTime?: string; date?: string }; reminders?: Array<{ method: string; minutes: number }>; @@ -458,6 +460,13 @@ export const createEvent = async ( }; if (rruleMap[eventData.recurrence]) { let rrule = rruleMap[eventData.recurrence]; + if (eventData.recurrenceInterval && eventData.recurrenceInterval > 1 && eventData.recurrence !== 'biweekly') { + rrule += `;INTERVAL=${eventData.recurrenceInterval}`; + } + if (eventData.recurrenceDays && eventData.recurrenceDays.length > 0 && eventData.recurrence === 'weekly') { + const dayMap = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA']; + rrule += `;BYDAY=${eventData.recurrenceDays.map(d => dayMap[d]).join(',')}`; + } if (eventData.recurrenceCount && eventData.recurrenceCount > 0) { rrule += `;COUNT=${eventData.recurrenceCount}`; } else if (eventData.recurrenceEndDate) {