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",
"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": {

View File

@ -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,

View File

@ -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) {

View File

@ -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({
</select>
</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 */}
<div style={{ padding: '0 10px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', minHeight: '32px' }}>

View File

@ -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`;
}
}

View File

@ -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,

View File

@ -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<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 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();
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;
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`;
}
}