From 6fed38381c4bc0c5ebab2dc869f3578aed8d59ce Mon Sep 17 00:00:00 2001 From: mARTin Date: Mon, 30 Mar 2026 16:02:56 +0200 Subject: [PATCH] perf: limit recurring task projection + O(1) calendar event dedup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tasks/route.ts: add per-unit instance cap for virtual task projection (daily: 60, weekly: 26, monthly: 12, yearly: 3) to prevent unbounded generation from high-frequency recurring tasks - WeeklyView: extract calendarEditabilityMap as a separate useMemo keyed on connections; replace O(n²) nested find() loop with O(1) Map.get() lookup in the calendarEvents deduplication useMemo v1.79.0 --- package.json | 2 +- src/app/api/tasks/route.ts | 16 ++++++++++++-- src/components/WeeklyView.tsx | 39 +++++++++++++++++++---------------- 3 files changed, 36 insertions(+), 21 deletions(-) diff --git a/package.json b/package.json index 3f555e9..eacc0ea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "my-weekly-todo-list", - "version": "1.78.0", + "version": "1.79.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/tasks/route.ts b/src/app/api/tasks/route.ts index a6205f6..640a866 100644 --- a/src/app/api/tasks/route.ts +++ b/src/app/api/tasks/route.ts @@ -11,6 +11,15 @@ const generateVirtualId = (originalId: string, dateStr: string) => { return `virtual-${originalId}-${dateStr}`; }; +// Max virtual instances generated per recurring series, keyed by recurrence unit. +// Caps pathological cases (e.g. a daily task with 90-day horizon = 90 instances). +const MAX_INSTANCES_PER_SERIES: Record = { + days: 60, + weeks: 26, + months: 12, + years: 3, +}; + // Helper to project future tasks const projectFutureTasks = (tasks: Task[], horizonDays = 90) => { const projectedTasks: any[] = []; @@ -53,6 +62,7 @@ const projectFutureTasks = (tasks: Task[], horizonDays = 90) => { const currentDate = new Date(baseDate); const interval = latestTask.recurrenceInterval || 1; const unit = latestTask.recurrenceUnit || 'weeks'; // 'days', 'weeks', 'months', 'years' + const maxInstances = MAX_INSTANCES_PER_SERIES[unit] ?? 26; // Parse recurrenceDays for weekly multi-day recurrence let recDays: number[] | null = null; @@ -60,9 +70,10 @@ const projectFutureTasks = (tasks: Task[], horizonDays = 90) => { try { recDays = JSON.parse(latestTask.recurrenceDays); } catch { recDays = null; } } - // Safety break + let instanceCount = 0; + // Safety break: stop at horizon OR per-unit instance cap, whichever comes first let iterations = 0; - while (currentDate < horizonDate && iterations < 500) { + while (currentDate < horizonDate && iterations < 500 && instanceCount < maxInstances) { iterations++; // Advance date @@ -123,6 +134,7 @@ const projectFutureTasks = (tasks: Task[], horizonDays = 90) => { isVirtual: true, // Flag for frontend if needed (not in Prisma type, but JS object accepts it) originalTaskId: latestTask.id // Reference }); + instanceCount++; } }); diff --git a/src/components/WeeklyView.tsx b/src/components/WeeklyView.tsx index cd1bf01..0bef9ee 100644 --- a/src/components/WeeklyView.tsx +++ b/src/components/WeeklyView.tsx @@ -1674,6 +1674,22 @@ export default function WeeklyView() { // When a recurring event is created, the master is cached. Then the sync // returns expanded instances with different IDs but the same recurringEventId. // We keep instances and discard masters that overlap with them. + + // Build calendarId → editable map once per connections change (O(connections × calendars)) + const calendarEditabilityMap = useMemo(() => { + const map = new Map(); + for (const conn of connections) { + if (conn.calendars && Array.isArray(conn.calendars)) { + for (const cal of conn.calendars as any[]) { + if (cal.id && !map.has(cal.id)) { + map.set(cal.id, !!cal.editable); + } + } + } + } + return map; + }, [connections]); + const calendarEvents = useMemo(() => { const seen = new Set(); const seenSlot = new Set(); @@ -1694,24 +1710,11 @@ export default function WeeklyView() { if (seenSlot.has(slotKey)) return false; seenSlot.add(slotKey); return true; - }).map((event) => { - let isEditable = false; - if (event.calendarId) { - for (const conn of connections) { - if (conn.calendars && Array.isArray(conn.calendars)) { - const cal = conn.calendars.find( - (c: any) => c.id === event.calendarId, - ); - if (cal && cal.editable) { - isEditable = true; - break; - } - } - } - } - return { ...event, editable: isEditable }; - }); - }, [rawCalendarEvents, connections]); + }).map((event) => ({ + ...event, + editable: event.calendarId ? (calendarEditabilityMap.get(event.calendarId) ?? false) : false, + })); + }, [rawCalendarEvents, calendarEditabilityMap]); const [currentWeekStart, setCurrentWeekStart] = useState(() => { const d = new Date(); d.setHours(0, 0, 0, 0);