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

View File

@ -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<string, number> = {
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++;
}
});

View File

@ -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<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 seen = new Set<string>();
const seenSlot = new Set<string>();
@ -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);