perf: limit recurring task projection + O(1) calendar event dedup

- 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
This commit is contained in:
mARTin 2026-03-30 16:02:56 +02:00
parent 6e8ed34578
commit 6fed38381c
3 changed files with 36 additions and 21 deletions

View File

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

@ -11,6 +11,15 @@ const generateVirtualId = (originalId: string, dateStr: string) => {
return `virtual-${originalId}-${dateStr}`; 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<string, number> = {
days: 60,
weeks: 26,
months: 12,
years: 3,
};
// Helper to project future tasks // Helper to project future tasks
const projectFutureTasks = (tasks: Task[], horizonDays = 90) => { const projectFutureTasks = (tasks: Task[], horizonDays = 90) => {
const projectedTasks: any[] = []; const projectedTasks: any[] = [];
@ -53,6 +62,7 @@ const projectFutureTasks = (tasks: Task[], horizonDays = 90) => {
const currentDate = new Date(baseDate); const currentDate = new Date(baseDate);
const interval = latestTask.recurrenceInterval || 1; const interval = latestTask.recurrenceInterval || 1;
const unit = latestTask.recurrenceUnit || 'weeks'; // 'days', 'weeks', 'months', 'years' const unit = latestTask.recurrenceUnit || 'weeks'; // 'days', 'weeks', 'months', 'years'
const maxInstances = MAX_INSTANCES_PER_SERIES[unit] ?? 26;
// Parse recurrenceDays for weekly multi-day recurrence // Parse recurrenceDays for weekly multi-day recurrence
let recDays: number[] | null = null; let recDays: number[] | null = null;
@ -60,9 +70,10 @@ const projectFutureTasks = (tasks: Task[], horizonDays = 90) => {
try { recDays = JSON.parse(latestTask.recurrenceDays); } catch { recDays = null; } 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; let iterations = 0;
while (currentDate < horizonDate && iterations < 500) { while (currentDate < horizonDate && iterations < 500 && instanceCount < maxInstances) {
iterations++; iterations++;
// Advance date // 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) isVirtual: true, // Flag for frontend if needed (not in Prisma type, but JS object accepts it)
originalTaskId: latestTask.id // Reference originalTaskId: latestTask.id // Reference
}); });
instanceCount++;
} }
}); });

View File

@ -1674,6 +1674,22 @@ export default function WeeklyView() {
// When a recurring event is created, the master is cached. Then the sync // When a recurring event is created, the master is cached. Then the sync
// returns expanded instances with different IDs but the same recurringEventId. // returns expanded instances with different IDs but the same recurringEventId.
// We keep instances and discard masters that overlap with them. // 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<string, boolean>();
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 calendarEvents = useMemo(() => {
const seen = new Set<string>(); const seen = new Set<string>();
const seenSlot = new Set<string>(); const seenSlot = new Set<string>();
@ -1694,24 +1710,11 @@ export default function WeeklyView() {
if (seenSlot.has(slotKey)) return false; if (seenSlot.has(slotKey)) return false;
seenSlot.add(slotKey); seenSlot.add(slotKey);
return true; return true;
}).map((event) => { }).map((event) => ({
let isEditable = false; ...event,
if (event.calendarId) { editable: event.calendarId ? (calendarEditabilityMap.get(event.calendarId) ?? false) : false,
for (const conn of connections) { }));
if (conn.calendars && Array.isArray(conn.calendars)) { }, [rawCalendarEvents, calendarEditabilityMap]);
const cal = conn.calendars.find(
(c: any) => c.id === event.calendarId,
);
if (cal && cal.editable) {
isEditable = true;
break;
}
}
}
}
return { ...event, editable: isEditable };
});
}, [rawCalendarEvents, connections]);
const [currentWeekStart, setCurrentWeekStart] = useState(() => { const [currentWeekStart, setCurrentWeekStart] = useState(() => {
const d = new Date(); const d = new Date();
d.setHours(0, 0, 0, 0); d.setHours(0, 0, 0, 0);