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:
parent
6fc0ccef74
commit
1ab98025a6
@ -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": {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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' }}>
|
||||
|
||||
@ -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`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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'],
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@ -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`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user