From 617543a603fcddec4af571cd092e51990faf7b0a Mon Sep 17 00:00:00 2001 From: Linus Waldowsky Date: Tue, 27 Jan 2026 21:15:19 +0100 Subject: [PATCH] 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 --- CLAUDE.md | 17 +- .../src/components/ProposedEventCard.tsx | 68 +++++--- apps/server/src/ai/utils/eventFormatter.ts | 15 +- apps/server/src/ai/utils/systemPrompt.ts | 11 +- apps/server/src/ai/utils/toolDefinitions.ts | 11 +- apps/server/src/ai/utils/toolExecutor.ts | 11 +- apps/server/src/controllers/ChatController.ts | 4 + .../repositories/mongo/models/ChatModel.ts | 6 + apps/server/src/services/ChatService.ts | 159 +++++++++++++++++- package-lock.json | 5 +- packages/shared/package.json | 3 + packages/shared/src/models/CalendarEvent.ts | 1 + packages/shared/src/utils/formatters.ts | 48 ++++++ packages/shared/src/utils/index.ts | 2 + packages/shared/src/utils/rruleHelpers.ts | 49 ++++++ 15 files changed, 359 insertions(+), 51 deletions(-) create mode 100644 packages/shared/src/utils/formatters.ts create mode 100644 packages/shared/src/utils/rruleHelpers.ts diff --git a/CLAUDE.md b/CLAUDE.md index 8c6cccd..0c58e24 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -309,7 +309,9 @@ src/ │ # DAY_TO_GERMAN, DAY_TO_GERMAN_SHORT, MONTH_TO_GERMAN └── utils/ ├── 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:** @@ -326,7 +328,7 @@ src/ - `Conversation`: id, userId, createdAt?, updatedAt? (messages loaded separately via lazy loading) - `CreateUserDTO`: email, userName, password (for registration) - `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` - `ConversationSummary`: id, lastMessage?, createdAt? (for conversation list) - `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 - `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/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 - `logging/`: Structured logging with pino, pino-http middleware, @Logged decorator - 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() - 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:** - **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 - `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) -- `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 - `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.) diff --git a/apps/client/src/components/ProposedEventCard.tsx b/apps/client/src/components/ProposedEventCard.tsx index c0fb736..64dd673 100644 --- a/apps/client/src/components/ProposedEventCard.tsx +++ b/apps/client/src/components/ProposedEventCard.tsx @@ -1,34 +1,15 @@ 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 { EventCardBase } from "./EventCardBase"; -const DELETE_MODE_LABELS: Record = { - single: "Nur dieses Vorkommen", - future: "Dieses & zukuenftige", - all: "Alle Vorkommen", -}; - type ProposedEventCardProps = { proposedChange: ProposedEventChange; onConfirm: () => void; onReject: () => void; }; -const DeleteModeBadge = ({ mode }: { mode: RecurringDeleteMode }) => { - const { theme } = useThemeStore(); - return ( - - - {DELETE_MODE_LABELS[mode]} - - - ); -}; - const ConfirmRejectButtons = ({ isDisabled, respondedAction, @@ -84,15 +65,21 @@ export const ProposedEventCard = ({ onConfirm, onReject, }: ProposedEventCardProps) => { + const { theme } = useThemeStore(); const event = proposedChange.event; - // respondedAction is now part of the proposedChange const isDisabled = !!proposedChange.respondedAction; - // Show delete mode badge for delete actions on recurring events - const showDeleteModeBadge = + // For delete/single action, the occurrenceDate becomes a new exception + const newExceptionDate = proposedChange.action === "delete" && - event?.isRecurring && - proposedChange.deleteMode; + proposedChange.deleteMode === "single" && + 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) { return null; @@ -108,8 +95,33 @@ export const ProposedEventCard = ({ description={event.description} isRecurring={event.isRecurring} > - {showDeleteModeBadge && ( - + {/* Show new exception date for delete/single actions */} + {newExceptionDate && ( + + + + Neue Ausnahme: {formatDate(new Date(proposedChange.occurrenceDate!))} + + + )} + {/* Show new UNTIL date for update actions */} + {newUntilDate && ( + + + + Neues Ende: {formatDate(newUntilDate)} + + )} 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. diff --git a/apps/server/src/ai/utils/systemPrompt.ts b/apps/server/src/ai/utils/systemPrompt.ts index ec0fe90..701e16c 100644 --- a/apps/server/src/ai/utils/systemPrompt.ts +++ b/apps/server/src/ai/utils/systemPrompt.ts @@ -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: diff --git a/apps/server/src/ai/utils/toolDefinitions.ts b/apps/server/src/ai/utils/toolDefinitions.ts index 27e18d0..e9d602a 100644 --- a/apps/server/src/ai/utils/toolDefinitions.ts +++ b/apps/server/src/ai/utils/toolDefinitions.ts @@ -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"], diff --git a/apps/server/src/ai/utils/toolExecutor.ts b/apps/server/src/ai/utils/toolExecutor.ts index 64a350b..a337ec3 100644 --- a/apps/server/src/ai/utils/toolExecutor.ts +++ b/apps/server/src/ai/utils/toolExecutor.ts @@ -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 diff --git a/apps/server/src/controllers/ChatController.ts b/apps/server/src/controllers/ChatController.ts index baa96a3..cab9159 100644 --- a/apps/server/src/controllers/ChatController.ts +++ b/apps/server/src/controllers/ChatController.ts @@ -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, diff --git a/apps/server/src/repositories/mongo/models/ChatModel.ts b/apps/server/src/repositories/mongo/models/ChatModel.ts index 1c4e73f..ccb206b 100644 --- a/apps/server/src/repositories/mongo/models/ChatModel.ts +++ b/apps/server/src/repositories/mongo/models/ChatModel.ts @@ -25,6 +25,7 @@ const EventSchema = new Schema( note: { type: String }, isRecurring: { type: Boolean }, recurrenceRule: { type: String }, + exceptionDates: { type: [String] }, }, { _id: false }, ); @@ -57,6 +58,11 @@ const ProposedChangeSchema = new Schema( type: String, enum: ["confirm", "reject"], }, + deleteMode: { + type: String, + enum: ["single", "future", "all"], + }, + occurrenceDate: { type: String }, }, { _id: false }, ); diff --git a/apps/server/src/services/ChatService.ts b/apps/server/src/services/ChatService.ts index 56c806f..9ad7322 100644 --- a/apps/server/src/services/ChatService.ts +++ b/apps/server/src/services/ChatService.ts @@ -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 { 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( diff --git a/package-lock.json b/package-lock.json index 4fa9cf9..a86bfcc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12535,7 +12535,10 @@ }, "packages/shared": { "name": "@calchat/shared", - "version": "1.0.0" + "version": "1.0.0", + "dependencies": { + "rrule": "^2.8.1" + } } } } diff --git a/packages/shared/package.json b/packages/shared/package.json index bb52b26..4721b79 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -7,5 +7,8 @@ "exports": { ".": "./src/index.ts", "./*": "./src/*" + }, + "dependencies": { + "rrule": "^2.8.1" } } diff --git a/packages/shared/src/models/CalendarEvent.ts b/packages/shared/src/models/CalendarEvent.ts index 00c0d29..1baf29d 100644 --- a/packages/shared/src/models/CalendarEvent.ts +++ b/packages/shared/src/models/CalendarEvent.ts @@ -28,6 +28,7 @@ export interface CreateEventDTO { note?: string; isRecurring?: boolean; recurrenceRule?: string; + exceptionDates?: string[]; // For display in proposals } export interface UpdateEventDTO { diff --git a/packages/shared/src/utils/formatters.ts b/packages/shared/src/utils/formatters.ts new file mode 100644 index 0000000..1bb9ebe --- /dev/null +++ b/packages/shared/src/utils/formatters.ts @@ -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", + }); +} diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts index dabda35..2783c54 100644 --- a/packages/shared/src/utils/index.ts +++ b/packages/shared/src/utils/index.ts @@ -1 +1,3 @@ export * from "./dateHelpers"; +export * from "./rruleHelpers"; +export * from "./formatters"; diff --git a/packages/shared/src/utils/rruleHelpers.ts b/packages/shared/src/utils/rruleHelpers.ts new file mode 100644 index 0000000..44171a3 --- /dev/null +++ b/packages/shared/src/utils/rruleHelpers.ts @@ -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.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; + } +}