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