feat: add RRULE parsing to shared package and improve ProposedEventCard UI

- Add rrule library to shared package for RRULE string parsing
- Add rruleHelpers.ts with parseRRule() returning freq, until, count, interval, byDay
- Add formatters.ts with German date/time formatters for client and server
- Extend CreateEventDTO with exceptionDates field for proposals
- Extend ChatModel schema with exceptionDates, deleteMode, occurrenceDate
- Update proposeUpdateEvent tool to support isRecurring and recurrenceRule params
- ProposedEventCard now shows green "Neue Ausnahme" and "Neues Ende" text
- Add Sport test scenario with dynamic exception and UNTIL responses
- Update CLAUDE.md documentation
This commit is contained in:
2026-01-27 21:15:19 +01:00
parent 4575483940
commit 617543a603
15 changed files with 359 additions and 51 deletions

View File

@@ -27,8 +27,34 @@ let responseIndex = 0;
// Static test responses (event proposals)
const staticResponses: TestResponse[] = [
// {{{
// === SPORT TEST SCENARIO (3 steps) ===
// Response 0: Wiederkehrendes Event - jeden Mittwoch Sport
{
content:
"Super! Ich erstelle dir einen wiederkehrenden Termin für Sport jeden Mittwoch:",
proposedChanges: [
{
id: "sport-create",
action: "create",
event: {
title: "Sport",
startTime: getDay("Wednesday", 1, 18, 0),
endTime: getDay("Wednesday", 1, 19, 30),
description: "Wöchentliches Training",
isRecurring: true,
recurrenceRule: "FREQ=WEEKLY;BYDAY=WE",
},
},
],
},
// Response 1: Ausnahme hinzufügen (2 Wochen später) - DYNAMIC placeholder
{ content: "" },
// Response 2: UNTIL hinzufügen (nach 6 Wochen) - DYNAMIC placeholder
{ content: "" },
// Response 3: Weitere Ausnahme in 2 Wochen - DYNAMIC placeholder
{ content: "" },
// === MULTI-EVENT TEST RESPONSES ===
// Response 0: 3 Meetings an verschiedenen Tagen
// Response 3: 3 Meetings an verschiedenen Tagen
{
content: "Alles klar! Ich erstelle dir 3 Team-Meetings für diese Woche:",
proposedChanges: [
@@ -319,12 +345,133 @@ async function getTestResponse(
): Promise<TestResponse> {
const responseIdx = index % staticResponses.length;
// Dynamic responses: fetch events from DB and format
// === SPORT TEST SCENARIO (Dynamic responses) ===
// Response 1: Add exception to "Sport" (2 weeks later)
if (responseIdx === 1) {
const events = await eventRepo.findByUserId(userId);
const sportEvent = events.find((e) => e.title === "Sport");
if (sportEvent) {
// Calculate date 2 weeks from the first occurrence
const exceptionDate = new Date(sportEvent.startTime);
exceptionDate.setDate(exceptionDate.getDate() + 14);
const exceptionDateStr = exceptionDate.toISOString().split("T")[0];
return {
content:
"Verstanden! Ich füge eine Ausnahme für den Sport-Termin in 2 Wochen hinzu:",
proposedChanges: [
{
id: "sport-exception",
action: "delete",
eventId: sportEvent.id,
deleteMode: "single",
occurrenceDate: exceptionDateStr,
event: {
title: sportEvent.title,
startTime: exceptionDate,
endTime: new Date(
exceptionDate.getTime() + 90 * 60 * 1000,
), // +90 min
description: sportEvent.description,
isRecurring: sportEvent.isRecurring,
recurrenceRule: sportEvent.recurrenceRule,
exceptionDates: sportEvent.exceptionDates,
},
},
],
};
}
return {
content: "Ich konnte keinen Termin 'Sport' finden. Bitte erstelle ihn zuerst.",
};
}
// Response 2: Add UNTIL to "Sport" (after 6 weeks total)
if (responseIdx === 2) {
const events = await eventRepo.findByUserId(userId);
const sportEvent = events.find((e) => e.title === "Sport");
if (sportEvent) {
// Calculate UNTIL date: 6 weeks from start
const untilDate = new Date(sportEvent.startTime);
untilDate.setDate(untilDate.getDate() + 42); // 6 weeks
const untilStr = untilDate.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z";
const newRule = `FREQ=WEEKLY;BYDAY=WE;UNTIL=${untilStr}`;
return {
content:
"Alles klar! Ich beende die Sport-Serie nach 6 Wochen:",
proposedChanges: [
{
id: "sport-until",
action: "update",
eventId: sportEvent.id,
updates: { recurrenceRule: newRule },
event: {
title: sportEvent.title,
startTime: sportEvent.startTime,
endTime: sportEvent.endTime,
description: sportEvent.description,
isRecurring: sportEvent.isRecurring,
recurrenceRule: newRule,
exceptionDates: sportEvent.exceptionDates,
},
},
],
};
}
return {
content: "Ich konnte keinen Termin 'Sport' finden. Bitte erstelle ihn zuerst.",
};
}
// Response 3: Add another exception to "Sport" (2 weeks after the first exception)
if (responseIdx === 3) {
const events = await eventRepo.findByUserId(userId);
const sportEvent = events.find((e) => e.title === "Sport");
if (sportEvent) {
// Calculate date 4 weeks from the first occurrence (2 weeks after the first exception)
const exceptionDate = new Date(sportEvent.startTime);
exceptionDate.setDate(exceptionDate.getDate() + 28); // 4 weeks
const exceptionDateStr = exceptionDate.toISOString().split("T")[0];
return {
content:
"Alles klar! Ich füge eine weitere Ausnahme für den Sport-Termin hinzu:",
proposedChanges: [
{
id: "sport-exception-2",
action: "delete",
eventId: sportEvent.id,
deleteMode: "single",
occurrenceDate: exceptionDateStr,
event: {
title: sportEvent.title,
startTime: exceptionDate,
endTime: new Date(
exceptionDate.getTime() + 90 * 60 * 1000,
), // +90 min
description: sportEvent.description,
isRecurring: sportEvent.isRecurring,
recurrenceRule: sportEvent.recurrenceRule,
exceptionDates: sportEvent.exceptionDates,
},
},
],
};
}
return {
content: "Ich konnte keinen Termin 'Sport' finden. Bitte erstelle ihn zuerst.",
};
}
// Dynamic responses: fetch events from DB and format
// (Note: indices shifted by +3 due to new sport responses)
if (responseIdx === 6) {
return { content: await getWeeksOverview(eventRepo, userId, 2) };
}
if (responseIdx === 4) {
if (responseIdx === 7) {
// Delete "Meeting mit Jens"
const events = await eventRepo.findByUserId(userId);
const jensEvent = events.find((e) => e.title === "Meeting mit Jens");
@@ -350,11 +497,11 @@ async function getTestResponse(
return { content: "Ich konnte keinen Termin 'Meeting mit Jens' finden." };
}
if (responseIdx === 8) {
if (responseIdx === 11) {
return { content: await getWeeksOverview(eventRepo, userId, 1) };
}
if (responseIdx === 10) {
if (responseIdx === 13) {
// Update "Telefonat mit Mama" +3 days and change time to 13:00
const events = await eventRepo.findByUserId(userId);
const mamaEvent = events.find((e) => e.title === "Telefonat mit Mama");
@@ -387,7 +534,7 @@ async function getTestResponse(
return { content: "Ich konnte keinen Termin 'Telefonat mit Mama' finden." };
}
if (responseIdx === 13) {
if (responseIdx === 16) {
const now = new Date();
return {
content: await getMonthOverview(