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:
125
apps/server/src/utils/eventFormatters.ts
Normal file
125
apps/server/src/utils/eventFormatters.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
MONTHS,
|
||||
DAY_INDEX_TO_DAY,
|
||||
DAY_TO_GERMAN,
|
||||
DAY_TO_GERMAN_SHORT,
|
||||
MONTH_TO_GERMAN,
|
||||
} from '@caldav/shared';
|
||||
import { EventRepository } from '../services/interfaces';
|
||||
import { expandRecurringEvents, ExpandedEvent } from './recurrenceExpander';
|
||||
|
||||
// Private formatting helpers
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
return `${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
function formatDateShort(date: Date): string {
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
return `${day}.${month}.`;
|
||||
}
|
||||
|
||||
function getWeekNumber(date: Date): number {
|
||||
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
||||
const dayNum = d.getUTCDay() || 7;
|
||||
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
||||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||
return Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
|
||||
}
|
||||
|
||||
function formatWeeksText(events: ExpandedEvent[], weeks: number): string {
|
||||
const weeksText = weeks === 1 ? 'die nächste Woche' : `die nächsten ${weeks} Wochen`;
|
||||
|
||||
if (events.length === 0) {
|
||||
return `Du hast für ${weeksText} keine Termine.`;
|
||||
}
|
||||
|
||||
const lines: string[] = [`Hier sind deine Termine für ${weeksText}:\n`];
|
||||
|
||||
for (const event of events) {
|
||||
const day = DAY_INDEX_TO_DAY[event.occurrenceStart.getDay()];
|
||||
const weekday = DAY_TO_GERMAN[day];
|
||||
const dateStr = formatDateShort(event.occurrenceStart);
|
||||
const timeStr = formatTime(event.occurrenceStart);
|
||||
lines.push(`${weekday}, ${dateStr} - ${timeStr} Uhr: ${event.title}`);
|
||||
}
|
||||
|
||||
lines.push(`\nInsgesamt ${events.length} Termin${events.length === 1 ? '' : 'e'}.`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function formatMonthText(events: ExpandedEvent[], monthName: string): string {
|
||||
if (events.length === 0) {
|
||||
return `Du hast im ${monthName} keine Termine.`;
|
||||
}
|
||||
|
||||
// Group events by calendar week
|
||||
const weekGroups = new Map<number, ExpandedEvent[]>();
|
||||
for (const event of events) {
|
||||
const weekNum = getWeekNumber(event.occurrenceStart);
|
||||
if (!weekGroups.has(weekNum)) {
|
||||
weekGroups.set(weekNum, []);
|
||||
}
|
||||
weekGroups.get(weekNum)!.push(event);
|
||||
}
|
||||
|
||||
const lines: string[] = [`Hier ist deine Monatsübersicht für ${monthName}:\n`];
|
||||
|
||||
// Sort weeks and format
|
||||
const sortedWeeks = Array.from(weekGroups.keys()).sort((a, b) => a - b);
|
||||
for (const weekNum of sortedWeeks) {
|
||||
const weekEvents = weekGroups.get(weekNum)!;
|
||||
lines.push(`KW ${weekNum}: ${weekEvents.length} Termin${weekEvents.length === 1 ? '' : 'e'}`);
|
||||
|
||||
for (const event of weekEvents) {
|
||||
const day = DAY_INDEX_TO_DAY[event.occurrenceStart.getDay()];
|
||||
const weekdayShort = DAY_TO_GERMAN_SHORT[day];
|
||||
const dateStr = formatDateShort(event.occurrenceStart);
|
||||
const timeStr = formatTime(event.occurrenceStart);
|
||||
lines.push(` • ${weekdayShort} ${dateStr}, ${timeStr}: ${event.title}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push(`Insgesamt ${events.length} Termin${events.length === 1 ? '' : 'e'} im ${monthName}.`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Public API
|
||||
|
||||
/**
|
||||
* Get a formatted overview of events for the next x weeks.
|
||||
* Recurring events are expanded to show all occurrences within the range.
|
||||
*/
|
||||
export async function getWeeksOverview(
|
||||
eventRepo: EventRepository,
|
||||
userId: string,
|
||||
weeks: number
|
||||
): Promise<string> {
|
||||
const now = new Date();
|
||||
const endDate = new Date(now.getTime() + weeks * 7 * 24 * 60 * 60 * 1000);
|
||||
const events = await eventRepo.findByUserId(userId);
|
||||
const expanded = expandRecurringEvents(events, now, endDate);
|
||||
return formatWeeksText(expanded, weeks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a formatted overview of events for a specific month.
|
||||
* Recurring events are expanded to show all occurrences within the month.
|
||||
*/
|
||||
export async function getMonthOverview(
|
||||
eventRepo: EventRepository,
|
||||
userId: string,
|
||||
year: number,
|
||||
month: number
|
||||
): Promise<string> {
|
||||
const startOfMonth = new Date(year, month, 1);
|
||||
const endOfMonth = new Date(year, month + 1, 0, 23, 59, 59);
|
||||
const events = await eventRepo.findByUserId(userId);
|
||||
const expanded = expandRecurringEvents(events, startOfMonth, endOfMonth);
|
||||
const monthName = MONTH_TO_GERMAN[MONTHS[month]];
|
||||
return formatMonthText(expanded, monthName);
|
||||
}
|
||||
109
apps/server/src/utils/recurrenceExpander.ts
Normal file
109
apps/server/src/utils/recurrenceExpander.ts
Normal 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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user