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:
17
CLAUDE.md
17
CLAUDE.md
@@ -309,7 +309,9 @@ src/
|
|||||||
│ # DAY_TO_GERMAN, DAY_TO_GERMAN_SHORT, MONTH_TO_GERMAN
|
│ # DAY_TO_GERMAN, DAY_TO_GERMAN_SHORT, MONTH_TO_GERMAN
|
||||||
└── utils/
|
└── utils/
|
||||||
├── index.ts
|
├── index.ts
|
||||||
└── dateHelpers.ts # getDay() - get date for specific weekday relative to today
|
├── dateHelpers.ts # getDay() - get date for specific weekday relative to today
|
||||||
|
├── formatters.ts # formatDate(), formatTime(), formatDateTime(), formatDateWithWeekday() - German locale
|
||||||
|
└── rruleHelpers.ts # parseRRule() - parse RRULE strings to extract freq, until, count, interval, byDay
|
||||||
```
|
```
|
||||||
|
|
||||||
**Key Types:**
|
**Key Types:**
|
||||||
@@ -326,7 +328,7 @@ src/
|
|||||||
- `Conversation`: id, userId, createdAt?, updatedAt? (messages loaded separately via lazy loading)
|
- `Conversation`: id, userId, createdAt?, updatedAt? (messages loaded separately via lazy loading)
|
||||||
- `CreateUserDTO`: email, userName, password (for registration)
|
- `CreateUserDTO`: email, userName, password (for registration)
|
||||||
- `LoginDTO`: identifier (email OR userName), password
|
- `LoginDTO`: identifier (email OR userName), password
|
||||||
- `CreateEventDTO`: Used for creating events AND for AI-proposed events
|
- `CreateEventDTO`: Used for creating events AND for AI-proposed events, includes optional `exceptionDates` for proposals
|
||||||
- `GetMessagesOptions`: Cursor-based pagination with `before?: string` and `limit?: number`
|
- `GetMessagesOptions`: Cursor-based pagination with `before?: string` and `limit?: number`
|
||||||
- `ConversationSummary`: id, lastMessage?, createdAt? (for conversation list)
|
- `ConversationSummary`: id, lastMessage?, createdAt? (for conversation list)
|
||||||
- `UpdateMessageDTO`: proposalId?, respondedAction? (for marking individual proposals as confirmed/rejected)
|
- `UpdateMessageDTO`: proposalId?, respondedAction? (for marking individual proposals as confirmed/rejected)
|
||||||
@@ -467,7 +469,8 @@ NODE_ENV=development # development = pretty logs, production = JSON
|
|||||||
- `ChatRepository` interface: updateMessage() and updateProposalResponse() for per-proposal respondedAction tracking
|
- `ChatRepository` interface: updateMessage() and updateProposalResponse() for per-proposal respondedAction tracking
|
||||||
- `GPTAdapter`: Full implementation with OpenAI GPT (gpt-4o-mini model), function calling for calendar operations, collects multiple proposals per response
|
- `GPTAdapter`: Full implementation with OpenAI GPT (gpt-4o-mini model), function calling for calendar operations, collects multiple proposals per response
|
||||||
- `ai/utils/`: Provider-agnostic shared utilities (systemPrompt, toolDefinitions, toolExecutor, eventFormatter)
|
- `ai/utils/`: Provider-agnostic shared utilities (systemPrompt, toolDefinitions, toolExecutor, eventFormatter)
|
||||||
- `ai/utils/systemPrompt`: Includes RRULE documentation - AI knows to create separate events when times differ by day
|
- `ai/utils/systemPrompt`: Includes RRULE documentation - AI knows to create separate events when times differ by day, warns AI not to put RRULE in description field
|
||||||
|
- `ai/utils/toolDefinitions`: proposeUpdateEvent supports `isRecurring` and `recurrenceRule` parameters for adding UNTIL or modifying recurrence
|
||||||
- `utils/recurrenceExpander`: Handles RRULE parsing, strips `RRULE:` prefix if present (AI may include it), filters out exceptionDates
|
- `utils/recurrenceExpander`: Handles RRULE parsing, strips `RRULE:` prefix if present (AI may include it), filters out exceptionDates
|
||||||
- `logging/`: Structured logging with pino, pino-http middleware, @Logged decorator
|
- `logging/`: Structured logging with pino, pino-http middleware, @Logged decorator
|
||||||
- All repositories and GPTAdapter decorated with @Logged for automatic method logging
|
- All repositories and GPTAdapter decorated with @Logged for automatic method logging
|
||||||
@@ -477,7 +480,11 @@ NODE_ENV=development # development = pretty logs, production = JSON
|
|||||||
- `AuthService`: refreshToken()
|
- `AuthService`: refreshToken()
|
||||||
- JWT authentication (currently using simple X-User-Id header)
|
- JWT authentication (currently using simple X-User-Id header)
|
||||||
|
|
||||||
**Shared:** Types, DTOs, constants (Day, Month with German translations), ExpandedEvent type, and date utilities defined and exported.
|
**Shared:**
|
||||||
|
- Types, DTOs, constants (Day, Month with German translations), ExpandedEvent type defined and exported
|
||||||
|
- `rruleHelpers.ts`: `parseRRule()` parses RRULE strings using rrule library, returns `ParsedRRule` with freq, until, count, interval, byDay
|
||||||
|
- `formatters.ts`: German date/time formatters (`formatDate`, `formatTime`, `formatDateTime`, `formatDateWithWeekday`) used by both client and server
|
||||||
|
- rrule library added as dependency for RRULE parsing
|
||||||
|
|
||||||
**Frontend:**
|
**Frontend:**
|
||||||
- **Authentication fully implemented:**
|
- **Authentication fully implemented:**
|
||||||
@@ -525,7 +532,7 @@ NODE_ENV=development # development = pretty logs, production = JSON
|
|||||||
- `ModalBase`: Reusable modal wrapper with backdrop (absolute-positioned behind card), uses CardBase internally - provides click-outside-to-close, Android back button support, and proper scrolling on Android
|
- `ModalBase`: Reusable modal wrapper with backdrop (absolute-positioned behind card), uses CardBase internally - provides click-outside-to-close, Android back button support, and proper scrolling on Android
|
||||||
- `EventCardBase`: Event card with date/time/recurring icons - uses CardBase for structure
|
- `EventCardBase`: Event card with date/time/recurring icons - uses CardBase for structure
|
||||||
- `EventCard`: Uses EventCardBase + edit/delete buttons (TouchableOpacity with delayPressIn for scroll-friendly touch handling)
|
- `EventCard`: Uses EventCardBase + edit/delete buttons (TouchableOpacity with delayPressIn for scroll-friendly touch handling)
|
||||||
- `ProposedEventCard`: Uses EventCardBase + confirm/reject buttons for chat proposals (supports create/update/delete actions with deleteMode display)
|
- `ProposedEventCard`: Uses EventCardBase + confirm/reject buttons for chat proposals, displays green highlighted text for new changes ("Neue Ausnahme: [date]" for single delete, "Neues Ende: [date]" for UNTIL updates)
|
||||||
- `DeleteEventModal`: Delete confirmation modal using ModalBase - shows three options for recurring events (single/future/all), simple confirm for non-recurring
|
- `DeleteEventModal`: Delete confirmation modal using ModalBase - shows three options for recurring events (single/future/all), simple confirm for non-recurring
|
||||||
- `EventOverlay` (in calendar.tsx): Day events overlay using ModalBase - shows EventCards for selected day
|
- `EventOverlay` (in calendar.tsx): Day events overlay using ModalBase - shows EventCards for selected day
|
||||||
- `Themes.tsx`: Theme definitions with THEMES object (defaultLight, defaultDark) including all color tokens (textPrimary, borderPrimary, eventIndicator, secondaryBg, shadowColor, etc.)
|
- `Themes.tsx`: Theme definitions with THEMES object (defaultLight, defaultDark) including all color tokens (textPrimary, borderPrimary, eventIndicator, secondaryBg, shadowColor, etc.)
|
||||||
|
|||||||
@@ -1,34 +1,15 @@
|
|||||||
import { View, Text, Pressable } from "react-native";
|
import { View, Text, Pressable } from "react-native";
|
||||||
import { ProposedEventChange, RecurringDeleteMode } from "@calchat/shared";
|
import { Feather } from "@expo/vector-icons";
|
||||||
|
import { ProposedEventChange, parseRRule, formatDate } from "@calchat/shared";
|
||||||
import { useThemeStore } from "../stores/ThemeStore";
|
import { useThemeStore } from "../stores/ThemeStore";
|
||||||
import { EventCardBase } from "./EventCardBase";
|
import { EventCardBase } from "./EventCardBase";
|
||||||
|
|
||||||
const DELETE_MODE_LABELS: Record<RecurringDeleteMode, string> = {
|
|
||||||
single: "Nur dieses Vorkommen",
|
|
||||||
future: "Dieses & zukuenftige",
|
|
||||||
all: "Alle Vorkommen",
|
|
||||||
};
|
|
||||||
|
|
||||||
type ProposedEventCardProps = {
|
type ProposedEventCardProps = {
|
||||||
proposedChange: ProposedEventChange;
|
proposedChange: ProposedEventChange;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
onReject: () => void;
|
onReject: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DeleteModeBadge = ({ mode }: { mode: RecurringDeleteMode }) => {
|
|
||||||
const { theme } = useThemeStore();
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
className="self-start px-2 py-1 rounded-md mb-2"
|
|
||||||
style={{ backgroundColor: theme.rejectButton }}
|
|
||||||
>
|
|
||||||
<Text style={{ color: theme.buttonText }} className="text-xs font-medium">
|
|
||||||
{DELETE_MODE_LABELS[mode]}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ConfirmRejectButtons = ({
|
const ConfirmRejectButtons = ({
|
||||||
isDisabled,
|
isDisabled,
|
||||||
respondedAction,
|
respondedAction,
|
||||||
@@ -84,15 +65,21 @@ export const ProposedEventCard = ({
|
|||||||
onConfirm,
|
onConfirm,
|
||||||
onReject,
|
onReject,
|
||||||
}: ProposedEventCardProps) => {
|
}: ProposedEventCardProps) => {
|
||||||
|
const { theme } = useThemeStore();
|
||||||
const event = proposedChange.event;
|
const event = proposedChange.event;
|
||||||
// respondedAction is now part of the proposedChange
|
|
||||||
const isDisabled = !!proposedChange.respondedAction;
|
const isDisabled = !!proposedChange.respondedAction;
|
||||||
|
|
||||||
// Show delete mode badge for delete actions on recurring events
|
// For delete/single action, the occurrenceDate becomes a new exception
|
||||||
const showDeleteModeBadge =
|
const newExceptionDate =
|
||||||
proposedChange.action === "delete" &&
|
proposedChange.action === "delete" &&
|
||||||
event?.isRecurring &&
|
proposedChange.deleteMode === "single" &&
|
||||||
proposedChange.deleteMode;
|
proposedChange.occurrenceDate;
|
||||||
|
|
||||||
|
// For update actions, check if a new UNTIL date is being set
|
||||||
|
const newUntilDate =
|
||||||
|
proposedChange.action === "update" &&
|
||||||
|
event?.recurrenceRule &&
|
||||||
|
parseRRule(event.recurrenceRule)?.until;
|
||||||
|
|
||||||
if (!event) {
|
if (!event) {
|
||||||
return null;
|
return null;
|
||||||
@@ -108,8 +95,33 @@ export const ProposedEventCard = ({
|
|||||||
description={event.description}
|
description={event.description}
|
||||||
isRecurring={event.isRecurring}
|
isRecurring={event.isRecurring}
|
||||||
>
|
>
|
||||||
{showDeleteModeBadge && (
|
{/* Show new exception date for delete/single actions */}
|
||||||
<DeleteModeBadge mode={proposedChange.deleteMode!} />
|
{newExceptionDate && (
|
||||||
|
<View className="flex-row items-center mb-2">
|
||||||
|
<Feather
|
||||||
|
name="plus-circle"
|
||||||
|
size={16}
|
||||||
|
color={theme.confirmButton}
|
||||||
|
style={{ marginRight: 8 }}
|
||||||
|
/>
|
||||||
|
<Text style={{ color: theme.confirmButton }} className="font-medium">
|
||||||
|
Neue Ausnahme: {formatDate(new Date(proposedChange.occurrenceDate!))}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{/* Show new UNTIL date for update actions */}
|
||||||
|
{newUntilDate && (
|
||||||
|
<View className="flex-row items-center mb-2">
|
||||||
|
<Feather
|
||||||
|
name="plus-circle"
|
||||||
|
size={16}
|
||||||
|
color={theme.confirmButton}
|
||||||
|
style={{ marginRight: 8 }}
|
||||||
|
/>
|
||||||
|
<Text style={{ color: theme.confirmButton }} className="font-medium">
|
||||||
|
Neues Ende: {formatDate(newUntilDate)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
)}
|
)}
|
||||||
<ConfirmRejectButtons
|
<ConfirmRejectButtons
|
||||||
isDisabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { CalendarEvent } from "@calchat/shared";
|
import {
|
||||||
|
CalendarEvent,
|
||||||
|
formatDate,
|
||||||
|
formatTime,
|
||||||
|
formatDateTime,
|
||||||
|
} from "@calchat/shared";
|
||||||
|
|
||||||
// German date/time formatting helpers
|
// Re-export for backwards compatibility
|
||||||
export const formatDate = (d: Date) => d.toLocaleDateString("de-DE");
|
export { formatDate, formatTime, formatDateTime };
|
||||||
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")}`;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format a list of events for display in the system prompt.
|
* 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)
|
2. "Arbeit" Fr 9:00-13:00 (RRULE mit BYDAY=FR)
|
||||||
- Nutze NIEMALS BYHOUR/BYMINUTE in RRULE - diese überschreiben die Startzeit nicht wie erwartet!
|
- 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
|
- 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:
|
WICHTIG - Antwortformat:
|
||||||
- Halte deine Textantworten SEHR KURZ (1-2 Sätze maximal)
|
- 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
|
- 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
|
- Wiederhole NIEMALS die Event-Details im Text! Der Benutzer sieht sie bereits in den Karten
|
||||||
- Gute Beispiele: "Alles klar!" oder "Hier sind deine Termine:"
|
- Verwende kontextbezogene Antworten in der GEGENWARTSFORM je nach Aktion:
|
||||||
- Schlechte Beispiele: Lange Listen mit allen Terminen und ihren Details im Text
|
- 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
|
- Bei Rückfragen oder wenn keine Termine erstellt werden, kannst du ausführlicher antworten
|
||||||
|
|
||||||
Existierende Termine des Benutzers:
|
Existierende Termine des Benutzers:
|
||||||
|
|||||||
@@ -131,7 +131,16 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|||||||
},
|
},
|
||||||
description: {
|
description: {
|
||||||
type: "string",
|
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"],
|
required: ["eventId"],
|
||||||
|
|||||||
@@ -88,6 +88,9 @@ export function executeToolCall(
|
|||||||
updates.startTime = new Date(args.startTime as string);
|
updates.startTime = new Date(args.startTime as string);
|
||||||
if (args.endTime) updates.endTime = new Date(args.endTime as string);
|
if (args.endTime) updates.endTime = new Date(args.endTime as string);
|
||||||
if (args.description) updates.description = args.description;
|
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)
|
// Build event object for display (merge existing with updates)
|
||||||
const displayEvent = {
|
const displayEvent = {
|
||||||
@@ -96,7 +99,11 @@ export function executeToolCall(
|
|||||||
endTime: (updates.endTime as Date) || existingEvent.endTime,
|
endTime: (updates.endTime as Date) || existingEvent.endTime,
|
||||||
description:
|
description:
|
||||||
(updates.description as string) || existingEvent.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 {
|
return {
|
||||||
@@ -149,6 +156,8 @@ export function executeToolCall(
|
|||||||
endTime: existingEvent.endTime,
|
endTime: existingEvent.endTime,
|
||||||
description: existingEvent.description,
|
description: existingEvent.description,
|
||||||
isRecurring: existingEvent.isRecurring,
|
isRecurring: existingEvent.isRecurring,
|
||||||
|
recurrenceRule: existingEvent.recurrenceRule,
|
||||||
|
exceptionDates: existingEvent.exceptionDates,
|
||||||
},
|
},
|
||||||
deleteMode: existingEvent.isRecurring ? deleteMode : undefined,
|
deleteMode: existingEvent.isRecurring ? deleteMode : undefined,
|
||||||
occurrenceDate: existingEvent.isRecurring
|
occurrenceDate: existingEvent.isRecurring
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ export class ChatController {
|
|||||||
try {
|
try {
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
const { conversationId, messageId } = req.params;
|
const { conversationId, messageId } = req.params;
|
||||||
|
|
||||||
|
// DEBUG: Log incoming request body to trace deleteMode issue
|
||||||
|
log.debug({ body: req.body }, "confirmEvent request body");
|
||||||
|
|
||||||
const {
|
const {
|
||||||
proposalId,
|
proposalId,
|
||||||
action,
|
action,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const EventSchema = new Schema<CreateEventDTO>(
|
|||||||
note: { type: String },
|
note: { type: String },
|
||||||
isRecurring: { type: Boolean },
|
isRecurring: { type: Boolean },
|
||||||
recurrenceRule: { type: String },
|
recurrenceRule: { type: String },
|
||||||
|
exceptionDates: { type: [String] },
|
||||||
},
|
},
|
||||||
{ _id: false },
|
{ _id: false },
|
||||||
);
|
);
|
||||||
@@ -57,6 +58,11 @@ const ProposedChangeSchema = new Schema<ProposedEventChange>(
|
|||||||
type: String,
|
type: String,
|
||||||
enum: ["confirm", "reject"],
|
enum: ["confirm", "reject"],
|
||||||
},
|
},
|
||||||
|
deleteMode: {
|
||||||
|
type: String,
|
||||||
|
enum: ["single", "future", "all"],
|
||||||
|
},
|
||||||
|
occurrenceDate: { type: String },
|
||||||
},
|
},
|
||||||
{ _id: false },
|
{ _id: false },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -27,8 +27,34 @@ let responseIndex = 0;
|
|||||||
// Static test responses (event proposals)
|
// Static test responses (event proposals)
|
||||||
const staticResponses: TestResponse[] = [
|
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 ===
|
// === 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:",
|
content: "Alles klar! Ich erstelle dir 3 Team-Meetings für diese Woche:",
|
||||||
proposedChanges: [
|
proposedChanges: [
|
||||||
@@ -319,12 +345,133 @@ async function getTestResponse(
|
|||||||
): Promise<TestResponse> {
|
): Promise<TestResponse> {
|
||||||
const responseIdx = index % staticResponses.length;
|
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) {
|
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) };
|
return { content: await getWeeksOverview(eventRepo, userId, 2) };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (responseIdx === 4) {
|
if (responseIdx === 7) {
|
||||||
// Delete "Meeting mit Jens"
|
// Delete "Meeting mit Jens"
|
||||||
const events = await eventRepo.findByUserId(userId);
|
const events = await eventRepo.findByUserId(userId);
|
||||||
const jensEvent = events.find((e) => e.title === "Meeting mit Jens");
|
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." };
|
return { content: "Ich konnte keinen Termin 'Meeting mit Jens' finden." };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (responseIdx === 8) {
|
if (responseIdx === 11) {
|
||||||
return { content: await getWeeksOverview(eventRepo, userId, 1) };
|
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
|
// Update "Telefonat mit Mama" +3 days and change time to 13:00
|
||||||
const events = await eventRepo.findByUserId(userId);
|
const events = await eventRepo.findByUserId(userId);
|
||||||
const mamaEvent = events.find((e) => e.title === "Telefonat mit Mama");
|
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." };
|
return { content: "Ich konnte keinen Termin 'Telefonat mit Mama' finden." };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (responseIdx === 13) {
|
if (responseIdx === 16) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
return {
|
return {
|
||||||
content: await getMonthOverview(
|
content: await getMonthOverview(
|
||||||
|
|||||||
5
package-lock.json
generated
5
package-lock.json
generated
@@ -12535,7 +12535,10 @@
|
|||||||
},
|
},
|
||||||
"packages/shared": {
|
"packages/shared": {
|
||||||
"name": "@calchat/shared",
|
"name": "@calchat/shared",
|
||||||
"version": "1.0.0"
|
"version": "1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"rrule": "^2.8.1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,5 +7,8 @@
|
|||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./*": "./src/*"
|
"./*": "./src/*"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"rrule": "^2.8.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface CreateEventDTO {
|
|||||||
note?: string;
|
note?: string;
|
||||||
isRecurring?: boolean;
|
isRecurring?: boolean;
|
||||||
recurrenceRule?: string;
|
recurrenceRule?: string;
|
||||||
|
exceptionDates?: string[]; // For display in proposals
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateEventDTO {
|
export interface UpdateEventDTO {
|
||||||
|
|||||||
48
packages/shared/src/utils/formatters.ts
Normal file
48
packages/shared/src/utils/formatters.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* German date/time formatting helpers.
|
||||||
|
* Used across client and server.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date as DD.MM.YYYY
|
||||||
|
*/
|
||||||
|
export function formatDate(date: Date): string {
|
||||||
|
const d = new Date(date);
|
||||||
|
return d.toLocaleDateString("de-DE", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format time as HH:MM
|
||||||
|
*/
|
||||||
|
export function formatTime(date: Date): string {
|
||||||
|
const d = new Date(date);
|
||||||
|
return d.toLocaleTimeString("de-DE", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date and time as DD.MM.YYYY HH:MM:SS
|
||||||
|
*/
|
||||||
|
export function formatDateTime(date: Date): string {
|
||||||
|
const d = new Date(date);
|
||||||
|
return `${formatDate(d)} ${d.toLocaleTimeString("de-DE")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date with weekday as "Mo., DD.MM.YYYY"
|
||||||
|
*/
|
||||||
|
export function formatDateWithWeekday(date: Date): string {
|
||||||
|
const d = new Date(date);
|
||||||
|
return d.toLocaleDateString("de-DE", {
|
||||||
|
weekday: "short",
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1 +1,3 @@
|
|||||||
export * from "./dateHelpers";
|
export * from "./dateHelpers";
|
||||||
|
export * from "./rruleHelpers";
|
||||||
|
export * from "./formatters";
|
||||||
|
|||||||
49
packages/shared/src/utils/rruleHelpers.ts
Normal file
49
packages/shared/src/utils/rruleHelpers.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { rrulestr, Frequency } from "rrule";
|
||||||
|
|
||||||
|
export interface ParsedRRule {
|
||||||
|
freq: string; // "YEARLY", "MONTHLY", "WEEKLY", "DAILY", etc.
|
||||||
|
until?: Date;
|
||||||
|
count?: number;
|
||||||
|
interval?: number;
|
||||||
|
byDay?: string[]; // ["MO", "WE", "FR"]
|
||||||
|
}
|
||||||
|
|
||||||
|
const FREQ_NAMES: Record<Frequency, string> = {
|
||||||
|
[Frequency.YEARLY]: "YEARLY",
|
||||||
|
[Frequency.MONTHLY]: "MONTHLY",
|
||||||
|
[Frequency.WEEKLY]: "WEEKLY",
|
||||||
|
[Frequency.DAILY]: "DAILY",
|
||||||
|
[Frequency.HOURLY]: "HOURLY",
|
||||||
|
[Frequency.MINUTELY]: "MINUTELY",
|
||||||
|
[Frequency.SECONDLY]: "SECONDLY",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses an RRULE string and extracts the relevant fields.
|
||||||
|
* Handles both with and without "RRULE:" prefix.
|
||||||
|
*/
|
||||||
|
export function parseRRule(ruleString: string): ParsedRRule | null {
|
||||||
|
if (!ruleString) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Ensure RRULE: prefix is present
|
||||||
|
const normalized = ruleString.startsWith("RRULE:")
|
||||||
|
? ruleString
|
||||||
|
: `RRULE:${ruleString}`;
|
||||||
|
|
||||||
|
const rule = rrulestr(normalized);
|
||||||
|
const options = rule.options;
|
||||||
|
|
||||||
|
return {
|
||||||
|
freq: FREQ_NAMES[options.freq] || "UNKNOWN",
|
||||||
|
until: options.until || undefined,
|
||||||
|
count: options.count || undefined,
|
||||||
|
interval: options.interval > 1 ? options.interval : undefined,
|
||||||
|
byDay: options.byweekday?.map((d) => d.toString()) || undefined,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user