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:
@@ -1,11 +1,12 @@
|
||||
import { CalendarEvent } from "@calchat/shared";
|
||||
import {
|
||||
CalendarEvent,
|
||||
formatDate,
|
||||
formatTime,
|
||||
formatDateTime,
|
||||
} from "@calchat/shared";
|
||||
|
||||
// German date/time formatting helpers
|
||||
export const formatDate = (d: Date) => d.toLocaleDateString("de-DE");
|
||||
export const formatTime = (d: Date) =>
|
||||
d.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
|
||||
export const formatDateTime = (d: Date) =>
|
||||
`${formatDate(d)} ${d.toLocaleTimeString("de-DE")}`;
|
||||
// Re-export for backwards compatibility
|
||||
export { formatDate, formatTime, formatDateTime };
|
||||
|
||||
/**
|
||||
* Format a list of events for display in the system prompt.
|
||||
|
||||
@@ -46,13 +46,20 @@ WICHTIG - Wiederkehrende Termine (RRULE):
|
||||
2. "Arbeit" Fr 9:00-13:00 (RRULE mit BYDAY=FR)
|
||||
- Nutze NIEMALS BYHOUR/BYMINUTE in RRULE - diese überschreiben die Startzeit nicht wie erwartet!
|
||||
- Gültige RRULE-Optionen: FREQ (DAILY/WEEKLY/MONTHLY/YEARLY), BYDAY (MO,TU,WE,TH,FR,SA,SU), INTERVAL, COUNT, UNTIL
|
||||
- UNTIL Format: YYYYMMDDTHHMMSSZ (UTC) z.B. UNTIL=20260310T000000Z
|
||||
- WICHTIG: Schreibe die RRULE NIEMALS in das description-Feld! Nutze IMMER das recurrenceRule-Feld!
|
||||
|
||||
WICHTIG - Antwortformat:
|
||||
- Halte deine Textantworten SEHR KURZ (1-2 Sätze maximal)
|
||||
- Die Event-Details (Titel, Datum, Uhrzeit, Beschreibung) werden dem Benutzer automatisch in separaten Karten angezeigt
|
||||
- Wiederhole NIEMALS die Event-Details im Text! Der Benutzer sieht sie bereits in den Karten
|
||||
- Gute Beispiele: "Alles klar!" oder "Hier sind deine Termine:"
|
||||
- Schlechte Beispiele: Lange Listen mit allen Terminen und ihren Details im Text
|
||||
- Verwende kontextbezogene Antworten in der GEGENWARTSFORM je nach Aktion:
|
||||
- Bei Termin-Erstellung: "Ich schlage folgenden Termin vor:" oder "Neuer Termin:"
|
||||
- Bei Termin-Änderung: "Ich schlage folgende Änderung vor:" oder "Änderung:"
|
||||
- Bei Termin-Löschung: "Ich schlage vor, diesen Termin zu löschen:" oder "Löschung:"
|
||||
- Bei Übersichten: "Hier sind deine Termine:"
|
||||
- WICHTIG: Verwende NIEMALS Vergangenheitsform wie "Ich habe ... vorgeschlagen" - immer Gegenwartsform!
|
||||
- Schlechte Beispiele: "Alles klar!" (zu unspezifisch), lange Listen mit Termin-Details im Text
|
||||
- Bei Rückfragen oder wenn keine Termine erstellt werden, kannst du ausführlicher antworten
|
||||
|
||||
Existierende Termine des Benutzers:
|
||||
|
||||
@@ -131,7 +131,16 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
||||
},
|
||||
description: {
|
||||
type: "string",
|
||||
description: "New description (optional)",
|
||||
description: "New description (optional). NEVER put RRULE here!",
|
||||
},
|
||||
isRecurring: {
|
||||
type: "boolean",
|
||||
description: "Whether this is a recurring event (optional)",
|
||||
},
|
||||
recurrenceRule: {
|
||||
type: "string",
|
||||
description:
|
||||
"RRULE format string (optional). Use to add UNTIL or modify recurrence. Format: FREQ=DAILY;UNTIL=20260310T000000Z",
|
||||
},
|
||||
},
|
||||
required: ["eventId"],
|
||||
|
||||
@@ -88,6 +88,9 @@ export function executeToolCall(
|
||||
updates.startTime = new Date(args.startTime as string);
|
||||
if (args.endTime) updates.endTime = new Date(args.endTime as string);
|
||||
if (args.description) updates.description = args.description;
|
||||
if (args.isRecurring !== undefined)
|
||||
updates.isRecurring = args.isRecurring;
|
||||
if (args.recurrenceRule) updates.recurrenceRule = args.recurrenceRule;
|
||||
|
||||
// Build event object for display (merge existing with updates)
|
||||
const displayEvent = {
|
||||
@@ -96,7 +99,11 @@ export function executeToolCall(
|
||||
endTime: (updates.endTime as Date) || existingEvent.endTime,
|
||||
description:
|
||||
(updates.description as string) || existingEvent.description,
|
||||
isRecurring: existingEvent.isRecurring,
|
||||
isRecurring:
|
||||
(updates.isRecurring as boolean) ?? existingEvent.isRecurring,
|
||||
recurrenceRule:
|
||||
(updates.recurrenceRule as string) || existingEvent.recurrenceRule,
|
||||
exceptionDates: existingEvent.exceptionDates,
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -149,6 +156,8 @@ export function executeToolCall(
|
||||
endTime: existingEvent.endTime,
|
||||
description: existingEvent.description,
|
||||
isRecurring: existingEvent.isRecurring,
|
||||
recurrenceRule: existingEvent.recurrenceRule,
|
||||
exceptionDates: existingEvent.exceptionDates,
|
||||
},
|
||||
deleteMode: existingEvent.isRecurring ? deleteMode : undefined,
|
||||
occurrenceDate: existingEvent.isRecurring
|
||||
|
||||
@@ -35,6 +35,10 @@ export class ChatController {
|
||||
try {
|
||||
const userId = req.user!.userId;
|
||||
const { conversationId, messageId } = req.params;
|
||||
|
||||
// DEBUG: Log incoming request body to trace deleteMode issue
|
||||
log.debug({ body: req.body }, "confirmEvent request body");
|
||||
|
||||
const {
|
||||
proposalId,
|
||||
action,
|
||||
|
||||
@@ -25,6 +25,7 @@ const EventSchema = new Schema<CreateEventDTO>(
|
||||
note: { type: String },
|
||||
isRecurring: { type: Boolean },
|
||||
recurrenceRule: { type: String },
|
||||
exceptionDates: { type: [String] },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
@@ -57,6 +58,11 @@ const ProposedChangeSchema = new Schema<ProposedEventChange>(
|
||||
type: String,
|
||||
enum: ["confirm", "reject"],
|
||||
},
|
||||
deleteMode: {
|
||||
type: String,
|
||||
enum: ["single", "future", "all"],
|
||||
},
|
||||
occurrenceDate: { type: String },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user