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 <noreply@anthropic.com>
This commit is contained in:
mARTin 2026-03-23 17:14:13 +01:00
parent 6fc0ccef74
commit 1ab98025a6
8 changed files with 153 additions and 20 deletions

View File

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

@ -39,7 +39,7 @@ export async function POST(request: NextRequest) {
if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = await request.json(); 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; reminders, busyStatus, visibility, attendees, attachments } = body;
console.log('[API] Creating event:', { calendarId, title, start, end }); console.log('[API] Creating event:', { calendarId, title, start, end });
@ -63,6 +63,8 @@ export async function POST(request: NextRequest) {
location, location,
allDay: !!allDay, allDay: !!allDay,
recurrence, recurrence,
recurrenceEndDate,
recurrenceCount,
url, url,
reminders, reminders,
busyStatus, busyStatus,
@ -92,7 +94,7 @@ export async function PATCH(request: NextRequest) {
if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = await request.json(); 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; reminders, busyStatus, visibility, attendees, attachments } = body;
console.log('[API] Updating event:', { calendarId, eventId, title }); console.log('[API] Updating event:', { calendarId, eventId, title });
@ -116,6 +118,8 @@ export async function PATCH(request: NextRequest) {
location, location,
allDay: allDay !== undefined ? !!allDay : undefined, allDay: allDay !== undefined ? !!allDay : undefined,
recurrence, recurrence,
recurrenceEndDate,
recurrenceCount,
url, url,
reminders, reminders,
busyStatus, busyStatus,

View File

@ -64,7 +64,8 @@ export async function GET(request: NextRequest) {
title: cal.name, title: cal.name,
isPrimary: cal.isDefaultCalendar, isPrimary: cal.isDefaultCalendar,
selected: true, selected: true,
editable: cal.canEdit editable: cal.canEdit,
backgroundColor: cal.hexColor || '#0078d4',
})); }));
if (existingConnection) { if (existingConnection) {

View File

@ -70,6 +70,13 @@ export default function CalendarEventModal({
const [location, setLocation] = useState(event?.location || ''); const [location, setLocation] = useState(event?.location || '');
const [url, setUrl] = useState(event?.url || ''); const [url, setUrl] = useState(event?.url || '');
const [recurrence, setRecurrence] = useState(event?.recurrence || 'none'); 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 : '')); const [calendarId, setCalendarId] = useState(event?.calendarId || (availableCalendars.length > 0 ? availableCalendars[0].id : ''));
// New fields // New fields
@ -173,6 +180,8 @@ export default function CalendarEventModal({
location, location,
url: url || undefined, url: url || undefined,
recurrence: recurrence !== 'none' ? recurrence : undefined, recurrence: recurrence !== 'none' ? recurrence : undefined,
recurrenceEndDate: recurrence !== 'none' && recurrenceEndType === 'date' && recurrenceEndDateValue ? recurrenceEndDateValue : undefined,
recurrenceCount: recurrence !== 'none' && recurrenceEndType === 'count' && recurrenceCount > 0 ? recurrenceCount : undefined,
calendarId, calendarId,
allDay, allDay,
start: { dateTime: startDate.toISOString() }, start: { dateTime: startDate.toISOString() },
@ -437,6 +446,63 @@ export default function CalendarEventModal({
</select> </select>
</div> </div>
{/* Recurrence End - only shown when repeat is set */}
{recurrence !== 'none' && (
<>
<div style={rowStyle}>
<span style={labelStyle}>End</span>
<select
value={recurrenceEndType}
onChange={e => setRecurrenceEndType(e.target.value as 'never' | 'date' | 'count')}
style={selectStyle}
>
<option value="never">Never</option>
<option value="date">On Date</option>
<option value="count">After...</option>
</select>
</div>
{recurrenceEndType === 'date' && (
<div style={rowStyle}>
<span style={labelStyle}></span>
<input
type="date"
value={recurrenceEndDateValue}
onChange={e => setRecurrenceEndDateValue(e.target.value)}
style={{
...selectStyle,
background: 'var(--weekly-bg-secondary, #f5f5f5)',
borderRadius: '6px',
padding: '4px 8px',
}}
/>
</div>
)}
{recurrenceEndType === 'count' && (
<div style={rowStyle}>
<span style={labelStyle}></span>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<input
type="number"
min={1}
max={999}
value={recurrenceCount}
onChange={e => 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',
}}
/>
<span style={{ fontSize: '0.82rem', color: 'var(--weekly-text)' }}>times</span>
</div>
</div>
)}
</>
)}
{/* Alert */} {/* Alert */}
<div style={{ padding: '0 10px' }}> <div style={{ padding: '0 10px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', minHeight: '32px' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', minHeight: '32px' }}>

View File

@ -390,6 +390,8 @@ export const createEvent = async (
location?: string; location?: string;
url?: string; url?: string;
recurrence?: string; recurrence?: string;
recurrenceEndDate?: string;
recurrenceCount?: number;
start: { dateTime?: string; date?: string }; start: { dateTime?: string; date?: string };
end: { dateTime?: string; date?: string }; end: { dateTime?: string; date?: string };
reminders?: Array<{ method: string; minutes: number }>; reminders?: Array<{ method: string; minutes: number }>;
@ -455,7 +457,15 @@ export const createEvent = async (
yearly: 'RRULE:FREQ=YEARLY', yearly: 'RRULE:FREQ=YEARLY',
}; };
if (rruleMap[eventData.recurrence]) { 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`;
} }
} }

View File

@ -41,6 +41,8 @@ export interface CalendarEvent {
location?: string; location?: string;
url?: string; url?: string;
recurrence?: string; recurrence?: string;
recurrenceEndDate?: string;
recurrenceCount?: number;
recurringEventId?: string; recurringEventId?: string;
isRecurring?: boolean; isRecurring?: boolean;
source: 'google' | 'apple' | 'outlook' | 'synology' | 'notion'; source: 'google' | 'apple' | 'outlook' | 'synology' | 'notion';
@ -58,27 +60,43 @@ export interface CalendarEvent {
/** /**
* Convert friendly recurrence name to RRULE string * 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) { switch (recurrence) {
case 'daily': return 'RRULE:FREQ=DAILY'; case 'daily': freq = 'FREQ=DAILY'; break;
case 'weekly': return 'RRULE:FREQ=WEEKLY'; case 'weekly': freq = 'FREQ=WEEKLY'; break;
case 'biweekly': return 'RRULE:FREQ=WEEKLY;INTERVAL=2'; case 'biweekly': freq = 'FREQ=WEEKLY;INTERVAL=2'; break;
case 'monthly': return 'RRULE:FREQ=MONTHLY'; case 'monthly': freq = 'FREQ=MONTHLY'; break;
case 'yearly': return 'RRULE:FREQ=YEARLY'; case 'yearly': freq = 'FREQ=YEARLY'; break;
default: return null; 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 * 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; if (!recurrence || recurrence === 'none') return undefined;
const start = startDate || new Date(); const start = startDate || new Date();
const range = { let range: any = {
type: 'noEnd', type: 'noEnd',
startDate: start.toISOString().split('T')[0], 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) { switch (recurrence) {
case 'daily': case 'daily':
return { pattern: { type: 'daily', interval: 1 }, range }; return { pattern: { type: 'daily', interval: 1 }, range };
@ -595,7 +613,7 @@ export const getCalendarEvents = async (
source: 'outlook' as const, source: 'outlook' as const,
calendarId, calendarId,
calendarTitle: calendarData?.title || 'Outlook Calendar', calendarTitle: calendarData?.title || 'Outlook Calendar',
backgroundColor: '#0078d4', backgroundColor: calendarData?.backgroundColor || calendarData?.color || '#0078d4',
reminders: event.reminders as EventReminder[] || undefined, reminders: event.reminders as EventReminder[] || undefined,
busyStatus: event.busyStatus as BusyStatus || undefined, busyStatus: event.busyStatus as BusyStatus || undefined,
visibility: event.visibility as EventVisibility || undefined, visibility: event.visibility as EventVisibility || undefined,
@ -807,7 +825,7 @@ export const createCalendarEvent = async (
); );
// Map to Google format // Map to Google format
const rrule = toRRule(event.recurrence); const rrule = toRRule(event.recurrence, event.recurrenceEndDate, event.recurrenceCount);
const googleEvent: any = { const googleEvent: any = {
summary: event.title, summary: event.title,
description: event.description, description: event.description,
@ -859,7 +877,7 @@ export const createCalendarEvent = async (
end: event.end, end: event.end,
location: event.location, location: event.location,
allDay: event.allDay, allDay: event.allDay,
recurrence: toOutlookRecurrence(event.recurrence, startDate), recurrence: toOutlookRecurrence(event.recurrence, startDate, event.recurrenceEndDate, event.recurrenceCount),
reminders: event.reminders, reminders: event.reminders,
busyStatus: event.busyStatus, busyStatus: event.busyStatus,
visibility: event.visibility, visibility: event.visibility,
@ -916,6 +934,8 @@ export const createCalendarEvent = async (
location: event.location, location: event.location,
url: event.url, url: event.url,
recurrence: event.recurrence, recurrence: event.recurrence,
recurrenceEndDate: event.recurrenceEndDate,
recurrenceCount: event.recurrenceCount,
start, start,
end, end,
reminders: event.reminders, reminders: event.reminders,
@ -952,6 +972,8 @@ export const createCalendarEvent = async (
location: event.location, location: event.location,
url: event.url, url: event.url,
recurrence: event.recurrence, recurrence: event.recurrence,
recurrenceEndDate: event.recurrenceEndDate,
recurrenceCount: event.recurrenceCount,
start: event.start!, start: event.start!,
end: event.end!, end: event.end!,
reminders: event.reminders, reminders: event.reminders,
@ -1034,7 +1056,7 @@ export const updateCalendarEvent = async (
); );
// Map to Google format // Map to Google format
const rrule = toRRule(event.recurrence); const rrule = toRRule(event.recurrence, event.recurrenceEndDate, event.recurrenceCount);
const googleEvent: any = {}; const googleEvent: any = {};
if (event.title !== undefined) googleEvent.summary = event.title; if (event.title !== undefined) googleEvent.summary = event.title;
if (event.description !== undefined) googleEvent.description = event.description; if (event.description !== undefined) googleEvent.description = event.description;
@ -1087,7 +1109,7 @@ export const updateCalendarEvent = async (
end: event.end, end: event.end,
location: event.location, location: event.location,
allDay: event.allDay, allDay: event.allDay,
recurrence: toOutlookRecurrence(event.recurrence, startDate), recurrence: toOutlookRecurrence(event.recurrence, startDate, event.recurrenceEndDate, event.recurrenceCount),
reminders: event.reminders, reminders: event.reminders,
busyStatus: event.busyStatus, busyStatus: event.busyStatus,
visibility: event.visibility, visibility: event.visibility,

View File

@ -7,12 +7,29 @@ export interface OutlookCalendar {
name: string; name: string;
isDefaultCalendar: boolean; isDefaultCalendar: boolean;
canEdit: boolean; canEdit: boolean;
color?: string;
hexColor?: string;
owner: { owner: {
name: string; name: string;
address: string; address: string;
}; };
} }
// Microsoft Graph API calendar color names → hex values
const OUTLOOK_COLOR_MAP: Record<string, string> = {
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 GRAPH_ENDPOINT = 'https://graph.microsoft.com/v1.0';
const REDIRECT_URI = process.env.MICROSOFT_REDIRECT_URI || `${process.env.NEXTAUTH_URL}/api/calendar/outlook/callback`; 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<OutlookCale
} }
const data = await response.json(); const data = await response.json();
return data.value; return (data.value || []).map((cal: any) => ({
...cal,
hexColor: cal.hexColor || OUTLOOK_COLOR_MAP[cal.color] || OUTLOOK_COLOR_MAP['auto'],
}));
}; };
/** /**

View File

@ -400,6 +400,8 @@ export const createEvent = async (
location?: string; location?: string;
url?: string; url?: string;
recurrence?: string; recurrence?: string;
recurrenceEndDate?: string;
recurrenceCount?: number;
start: { dateTime?: string; date?: string }; start: { dateTime?: string; date?: string };
end: { dateTime?: string; date?: string }; end: { dateTime?: string; date?: string };
reminders?: Array<{ method: string; minutes: number }>; reminders?: Array<{ method: string; minutes: number }>;
@ -455,7 +457,15 @@ export const createEvent = async (
yearly: 'RRULE:FREQ=YEARLY', yearly: 'RRULE:FREQ=YEARLY',
}; };
if (rruleMap[eventData.recurrence]) { 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`;
} }
} }