From 1ab98025a62e1e04f3f55a214e41fe4bbe8f98cc Mon Sep 17 00:00:00 2001 From: mARTin Date: Mon, 23 Mar 2026 17:14:13 +0100 Subject: [PATCH] feat: add Outlook calendar colors and recurrence end date/count - Fetch and display Outlook calendar colors from Microsoft Graph API instead of hardcoded blue - Add recurrence end options to calendar event modal: Never, On Date (UNTIL), or After X occurrences (COUNT) - Pass recurrence end parameters through to RRULE generation for all providers (Google, Apple, Synology, Outlook) v1.60.0 Co-Authored-By: Claude Opus 4.6 --- package.json | 2 +- src/app/api/calendar/events/route.ts | 8 ++- .../api/calendar/outlook/callback/route.ts | 3 +- src/components/CalendarEventModal.tsx | 66 +++++++++++++++++++ src/lib/apple-calendar.ts | 12 +++- src/lib/calendar-events.ts | 48 ++++++++++---- src/lib/outlook-calendar.ts | 22 ++++++- src/lib/synology-calendar.ts | 12 +++- 8 files changed, 153 insertions(+), 20 deletions(-) diff --git a/package.json b/package.json index 14490f9..1659121 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "my-weekly-todo-list", - "version": "1.59.4", + "version": "1.60.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/events/route.ts b/src/app/api/calendar/events/route.ts index 40bb73d..eff8fba 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, url, + const { calendarId, title, description, start, end, location, allDay, recurrence, recurrenceEndDate, recurrenceCount, url, reminders, busyStatus, visibility, attendees, attachments } = body; console.log('[API] Creating event:', { calendarId, title, start, end }); @@ -63,6 +63,8 @@ export async function POST(request: NextRequest) { location, allDay: !!allDay, recurrence, + recurrenceEndDate, + recurrenceCount, url, reminders, busyStatus, @@ -92,7 +94,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, url, + const { calendarId, eventId, title, description, start, end, location, allDay, recurrence, recurrenceEndDate, recurrenceCount, url, reminders, busyStatus, visibility, attendees, attachments } = body; console.log('[API] Updating event:', { calendarId, eventId, title }); @@ -116,6 +118,8 @@ export async function PATCH(request: NextRequest) { location, allDay: allDay !== undefined ? !!allDay : undefined, recurrence, + recurrenceEndDate, + recurrenceCount, url, reminders, busyStatus, diff --git a/src/app/api/calendar/outlook/callback/route.ts b/src/app/api/calendar/outlook/callback/route.ts index 22c7fdc..4d5a7fb 100644 --- a/src/app/api/calendar/outlook/callback/route.ts +++ b/src/app/api/calendar/outlook/callback/route.ts @@ -64,7 +64,8 @@ export async function GET(request: NextRequest) { title: cal.name, isPrimary: cal.isDefaultCalendar, selected: true, - editable: cal.canEdit + editable: cal.canEdit, + backgroundColor: cal.hexColor || '#0078d4', })); if (existingConnection) { diff --git a/src/components/CalendarEventModal.tsx b/src/components/CalendarEventModal.tsx index 8a69c9c..f6d801f 100644 --- a/src/components/CalendarEventModal.tsx +++ b/src/components/CalendarEventModal.tsx @@ -70,6 +70,13 @@ export default function CalendarEventModal({ const [location, setLocation] = useState(event?.location || ''); const [url, setUrl] = useState(event?.url || ''); const [recurrence, setRecurrence] = useState(event?.recurrence || 'none'); + const [recurrenceEndType, setRecurrenceEndType] = useState<'never' | 'date' | 'count'>( + event?.recurrenceCount ? 'count' : event?.recurrenceEndDate ? 'date' : 'never' + ); + const [recurrenceEndDateValue, setRecurrenceEndDateValue] = useState( + event?.recurrenceEndDate || '' + ); + const [recurrenceCount, setRecurrenceCount] = useState(event?.recurrenceCount || 10); const [calendarId, setCalendarId] = useState(event?.calendarId || (availableCalendars.length > 0 ? availableCalendars[0].id : '')); // New fields @@ -173,6 +180,8 @@ export default function CalendarEventModal({ location, url: url || undefined, recurrence: recurrence !== 'none' ? recurrence : undefined, + recurrenceEndDate: recurrence !== 'none' && recurrenceEndType === 'date' && recurrenceEndDateValue ? recurrenceEndDateValue : undefined, + recurrenceCount: recurrence !== 'none' && recurrenceEndType === 'count' && recurrenceCount > 0 ? recurrenceCount : undefined, calendarId, allDay, start: { dateTime: startDate.toISOString() }, @@ -437,6 +446,63 @@ export default function CalendarEventModal({ + {/* Recurrence End - only shown when repeat is set */} + {recurrence !== 'none' && ( + <> +
+ End + +
+ {recurrenceEndType === 'date' && ( +
+ + setRecurrenceEndDateValue(e.target.value)} + style={{ + ...selectStyle, + background: 'var(--weekly-bg-secondary, #f5f5f5)', + borderRadius: '6px', + padding: '4px 8px', + }} + /> +
+ )} + {recurrenceEndType === 'count' && ( +
+ +
+ setRecurrenceCount(Math.max(1, parseInt(e.target.value) || 1))} + style={{ + ...selectStyle, + width: '50px', + background: 'var(--weekly-bg-secondary, #f5f5f5)', + borderRadius: '6px', + padding: '4px 8px', + textAlign: 'center', + }} + /> + times +
+
+ )} + + )} + {/* Alert */}
diff --git a/src/lib/apple-calendar.ts b/src/lib/apple-calendar.ts index 95eaf6c..8a9420a 100644 --- a/src/lib/apple-calendar.ts +++ b/src/lib/apple-calendar.ts @@ -390,6 +390,8 @@ export const createEvent = async ( location?: string; url?: string; recurrence?: string; + recurrenceEndDate?: string; + recurrenceCount?: number; start: { dateTime?: string; date?: string }; end: { dateTime?: string; date?: string }; reminders?: Array<{ method: string; minutes: number }>; @@ -455,7 +457,15 @@ export const createEvent = async ( yearly: 'RRULE:FREQ=YEARLY', }; if (rruleMap[eventData.recurrence]) { - rruleLine = `${rruleMap[eventData.recurrence]}\r\n`; + let rrule = rruleMap[eventData.recurrence]; + if (eventData.recurrenceCount && eventData.recurrenceCount > 0) { + rrule += `;COUNT=${eventData.recurrenceCount}`; + } else if (eventData.recurrenceEndDate) { + const d = new Date(eventData.recurrenceEndDate); + d.setHours(23, 59, 59); + rrule += `;UNTIL=${d.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z')}`; + } + rruleLine = `${rrule}\r\n`; } } diff --git a/src/lib/calendar-events.ts b/src/lib/calendar-events.ts index d00734a..2d406dd 100644 --- a/src/lib/calendar-events.ts +++ b/src/lib/calendar-events.ts @@ -41,6 +41,8 @@ export interface CalendarEvent { location?: string; url?: string; recurrence?: string; + recurrenceEndDate?: string; + recurrenceCount?: number; recurringEventId?: string; isRecurring?: boolean; source: 'google' | 'apple' | 'outlook' | 'synology' | 'notion'; @@ -58,27 +60,43 @@ export interface CalendarEvent { /** * Convert friendly recurrence name to RRULE string */ -function toRRule(recurrence?: string): string | null { +function toRRule(recurrence?: string, recurrenceEndDate?: string, recurrenceCount?: number): string | null { + let freq: string; switch (recurrence) { - case 'daily': return 'RRULE:FREQ=DAILY'; - case 'weekly': return 'RRULE:FREQ=WEEKLY'; - case 'biweekly': return 'RRULE:FREQ=WEEKLY;INTERVAL=2'; - case 'monthly': return 'RRULE:FREQ=MONTHLY'; - case 'yearly': return 'RRULE:FREQ=YEARLY'; + case 'daily': freq = 'FREQ=DAILY'; break; + case 'weekly': freq = 'FREQ=WEEKLY'; break; + case 'biweekly': freq = 'FREQ=WEEKLY;INTERVAL=2'; break; + case 'monthly': freq = 'FREQ=MONTHLY'; break; + case 'yearly': freq = 'FREQ=YEARLY'; break; default: return null; } + let rrule = `RRULE:${freq}`; + 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')}`; + } + return rrule; } /** * Convert friendly recurrence name to Outlook Graph recurrence object */ -function toOutlookRecurrence(recurrence?: string, startDate?: Date): any { +function toOutlookRecurrence(recurrence?: string, startDate?: Date, recurrenceEndDate?: string, recurrenceCount?: number): any { if (!recurrence || recurrence === 'none') return undefined; const start = startDate || new Date(); - const range = { + let range: any = { type: 'noEnd', startDate: start.toISOString().split('T')[0], }; + if (recurrenceCount && recurrenceCount > 0) { + range = { type: 'numbered', startDate: start.toISOString().split('T')[0], numberOfOccurrences: recurrenceCount }; + } else if (recurrenceEndDate) { + range = { type: 'endDate', startDate: start.toISOString().split('T')[0], endDate: recurrenceEndDate }; + } switch (recurrence) { case 'daily': return { pattern: { type: 'daily', interval: 1 }, range }; @@ -595,7 +613,7 @@ export const getCalendarEvents = async ( source: 'outlook' as const, calendarId, calendarTitle: calendarData?.title || 'Outlook Calendar', - backgroundColor: '#0078d4', + backgroundColor: calendarData?.backgroundColor || calendarData?.color || '#0078d4', reminders: event.reminders as EventReminder[] || undefined, busyStatus: event.busyStatus as BusyStatus || undefined, visibility: event.visibility as EventVisibility || undefined, @@ -807,7 +825,7 @@ export const createCalendarEvent = async ( ); // Map to Google format - const rrule = toRRule(event.recurrence); + const rrule = toRRule(event.recurrence, event.recurrenceEndDate, event.recurrenceCount); const googleEvent: any = { summary: event.title, description: event.description, @@ -859,7 +877,7 @@ export const createCalendarEvent = async ( end: event.end, location: event.location, allDay: event.allDay, - recurrence: toOutlookRecurrence(event.recurrence, startDate), + recurrence: toOutlookRecurrence(event.recurrence, startDate, event.recurrenceEndDate, event.recurrenceCount), reminders: event.reminders, busyStatus: event.busyStatus, visibility: event.visibility, @@ -916,6 +934,8 @@ export const createCalendarEvent = async ( location: event.location, url: event.url, recurrence: event.recurrence, + recurrenceEndDate: event.recurrenceEndDate, + recurrenceCount: event.recurrenceCount, start, end, reminders: event.reminders, @@ -952,6 +972,8 @@ export const createCalendarEvent = async ( location: event.location, url: event.url, recurrence: event.recurrence, + recurrenceEndDate: event.recurrenceEndDate, + recurrenceCount: event.recurrenceCount, start: event.start!, end: event.end!, reminders: event.reminders, @@ -1034,7 +1056,7 @@ export const updateCalendarEvent = async ( ); // Map to Google format - const rrule = toRRule(event.recurrence); + const rrule = toRRule(event.recurrence, event.recurrenceEndDate, event.recurrenceCount); const googleEvent: any = {}; if (event.title !== undefined) googleEvent.summary = event.title; if (event.description !== undefined) googleEvent.description = event.description; @@ -1087,7 +1109,7 @@ export const updateCalendarEvent = async ( end: event.end, location: event.location, allDay: event.allDay, - recurrence: toOutlookRecurrence(event.recurrence, startDate), + recurrence: toOutlookRecurrence(event.recurrence, startDate, event.recurrenceEndDate, event.recurrenceCount), reminders: event.reminders, busyStatus: event.busyStatus, visibility: event.visibility, diff --git a/src/lib/outlook-calendar.ts b/src/lib/outlook-calendar.ts index e04dea0..946f370 100644 --- a/src/lib/outlook-calendar.ts +++ b/src/lib/outlook-calendar.ts @@ -7,12 +7,29 @@ export interface OutlookCalendar { name: string; isDefaultCalendar: boolean; canEdit: boolean; + color?: string; + hexColor?: string; owner: { name: string; address: string; }; } +// Microsoft Graph API calendar color names → hex values +const OUTLOOK_COLOR_MAP: Record = { + auto: '#0078d4', + lightBlue: '#69afe5', + lightGreen: '#7bd148', + lightOrange: '#ffb878', + lightGray: '#b3b3b3', + lightYellow: '#fbd75b', + lightTeal: '#92e1c0', + lightPink: '#f691b2', + lightBrown: '#c2a282', + lightRed: '#ff887c', + maxColor: '#0078d4', +}; + const GRAPH_ENDPOINT = 'https://graph.microsoft.com/v1.0'; const REDIRECT_URI = process.env.MICROSOFT_REDIRECT_URI || `${process.env.NEXTAUTH_URL}/api/calendar/outlook/callback`; @@ -132,7 +149,10 @@ export const getUserCalendars = async (accessToken: string): Promise ({ + ...cal, + hexColor: cal.hexColor || OUTLOOK_COLOR_MAP[cal.color] || OUTLOOK_COLOR_MAP['auto'], + })); }; /** diff --git a/src/lib/synology-calendar.ts b/src/lib/synology-calendar.ts index 76c2921..7683c93 100644 --- a/src/lib/synology-calendar.ts +++ b/src/lib/synology-calendar.ts @@ -400,6 +400,8 @@ export const createEvent = async ( location?: string; url?: string; recurrence?: string; + recurrenceEndDate?: string; + recurrenceCount?: number; start: { dateTime?: string; date?: string }; end: { dateTime?: string; date?: string }; reminders?: Array<{ method: string; minutes: number }>; @@ -455,7 +457,15 @@ export const createEvent = async ( yearly: 'RRULE:FREQ=YEARLY', }; if (rruleMap[eventData.recurrence]) { - rruleLine = `${rruleMap[eventData.recurrence]}\r\n`; + let rrule = rruleMap[eventData.recurrence]; + if (eventData.recurrenceCount && eventData.recurrenceCount > 0) { + rrule += `;COUNT=${eventData.recurrenceCount}`; + } else if (eventData.recurrenceEndDate) { + const d = new Date(eventData.recurrenceEndDate); + d.setHours(23, 59, 59); + rrule += `;UNTIL=${d.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z')}`; + } + rruleLine = `${rrule}\r\n`; } }