add event CRUD actions and recurring event expansion

- Implement full CRUD in MongoEventRepository (findById, findByUserId, findByDateRange, update, delete)
- Extend ChatService to handle create/update/delete actions with dynamic test responses
- Add recurrenceExpander utility using rrule library for RRULE parsing
- Add eventFormatters utility for German-localized week/month overviews
- Add German translations for days and months in shared Constants
- Update client ChatService to support all event actions (action, eventId, updates params)
This commit is contained in:
2026-01-04 16:15:30 +01:00
parent 9fecf94c7d
commit 77f15b6dd1
11 changed files with 577 additions and 174 deletions

View File

@@ -0,0 +1,109 @@
import { RRule, rrulestr } from 'rrule';
import { CalendarEvent } from '@caldav/shared';
export interface ExpandedEvent extends CalendarEvent {
occurrenceStart: Date;
occurrenceEnd: Date;
}
// Convert local time to "fake UTC" for rrule
// rrule interprets all dates as UTC internally, so we need to trick it
function toRRuleDate(date: Date): Date {
return new Date(Date.UTC(
date.getFullYear(),
date.getMonth(),
date.getDate(),
date.getHours(),
date.getMinutes(),
date.getSeconds()
));
}
// Convert rrule result back to local time
function fromRRuleDate(date: Date): Date {
return new Date(
date.getUTCFullYear(),
date.getUTCMonth(),
date.getUTCDate(),
date.getUTCHours(),
date.getUTCMinutes(),
date.getUTCSeconds()
);
}
/**
* Expand recurring events into individual occurrences within a date range.
* Non-recurring events are returned as-is with occurrenceStart/End = startTime/endTime.
*/
export function expandRecurringEvents(
events: CalendarEvent[],
rangeStart: Date,
rangeEnd: Date
): ExpandedEvent[] {
const expanded: ExpandedEvent[] = [];
for (const event of events) {
const startTime = new Date(event.startTime);
const endTime = new Date(event.endTime);
const duration = endTime.getTime() - startTime.getTime();
if (!event.isRecurring || !event.recurrenceRule) {
// Non-recurring event: add as-is if within range
if (startTime >= rangeStart && startTime <= rangeEnd) {
expanded.push({
...event,
occurrenceStart: startTime,
occurrenceEnd: endTime,
});
}
continue;
}
// Recurring event: parse RRULE and expand
try {
const rule = rrulestr(`DTSTART:${formatRRuleDateString(startTime)}\nRRULE:${event.recurrenceRule}`);
// Get occurrences within the range (using fake UTC dates)
const occurrences = rule.between(
toRRuleDate(rangeStart),
toRRuleDate(rangeEnd),
true // inclusive
);
for (const occurrence of occurrences) {
const occurrenceStart = fromRRuleDate(occurrence);
const occurrenceEnd = new Date(occurrenceStart.getTime() + duration);
expanded.push({
...event,
occurrenceStart,
occurrenceEnd,
});
}
} catch (error) {
// If RRULE parsing fails, include the event as a single occurrence
console.error(`Failed to parse recurrence rule for event ${event.id}:`, error);
expanded.push({
...event,
occurrenceStart: startTime,
occurrenceEnd: endTime,
});
}
}
// Sort by occurrence start time
expanded.sort((a, b) => a.occurrenceStart.getTime() - b.occurrenceStart.getTime());
return expanded;
}
// Format date as RRULE DTSTART string (YYYYMMDDTHHMMSS)
function formatRRuleDateString(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}${month}${day}T${hours}${minutes}${seconds}`;
}