diff --git a/CLAUDE.md b/CLAUDE.md index 45ec031..d8ce1b0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -135,7 +135,9 @@ src/ │ └── ClaudeAdapter.ts # Implements AIProvider └── utils/ ├── jwt.ts # signToken(), verifyToken() - └── password.ts # hash(), compare() + ├── password.ts # hash(), compare() + ├── eventFormatters.ts # getWeeksOverview(), getMonthOverview() - formatted event listings + └── recurrenceExpander.ts # expandRecurringEvents() - expand recurring events into occurrences ``` **API Endpoints:** @@ -169,7 +171,8 @@ src/ │ ├── ChatMessage.ts # ChatMessage, Conversation, SendMessageDTO, CreateMessageDTO, │ │ # GetMessagesOptions, ChatResponse, ConversationSummary, │ │ # ProposedEventChange, EventAction -│ └── Constants.ts # DAYS, MONTHS, Day, Month, DAY_INDEX +│ └── Constants.ts # DAYS, MONTHS, Day, Month, DAY_INDEX, DAY_INDEX_TO_DAY, +│ # DAY_TO_GERMAN, DAY_TO_GERMAN_SHORT, MONTH_TO_GERMAN └── utils/ ├── index.ts └── dateHelpers.ts # getDay() - get date for specific weekday relative to today @@ -269,26 +272,28 @@ MONGODB_URI=mongodb://root:mongoose@localhost:27017/calchat?authSource=admin - `utils/jwt`: signToken() (verifyToken() pending) - `dotenv` integration for environment variables - `ChatController`: sendMessage(), confirmEvent(), rejectEvent() - - `ChatService`: processMessage() with test responses, confirmEvent() saves events to DB, rejectEvent() + - `ChatService`: processMessage() with test responses (create, update, delete actions), confirmEvent() handles all CRUD actions + - `MongoEventRepository`: Full CRUD implemented (findById, findByUserId, findByDateRange, create, update, delete) + - `utils/eventFormatters`: getWeeksOverview(), getMonthOverview() with German localization + - `utils/recurrenceExpander`: expandRecurringEvents() using rrule library for RRULE parsing - **Stubbed (TODO):** - `AuthMiddleware.authenticate()`: Currently uses fake user for testing - `AuthController`: refresh(), logout() - `AuthService`: refreshToken() - `ChatController`: getConversations(), getConversation() - `MongoChatRepository`: Database persistence for chat - - `MongoEventRepository`: Only create() implemented, rest stubbed - **Not started:** - `EventController`, `EventService` - `ClaudeAdapter` (AI integration - currently using test responses) -**Shared:** Types, DTOs, constants (Day, Month), and date utilities defined and exported. +**Shared:** Types, DTOs, constants (Day, Month with German translations), and date utilities defined and exported. **Frontend:** - Tab navigation (Chat, Calendar) implemented with basic UI - Calendar screen has month navigation and grid display (partially functional) - Chat screen functional with FlashList, message sending, and event confirm/reject - `ApiClient`: get(), post() implemented -- `ChatService`: sendMessage(), confirmEvent(convId, msgId, event), rejectEvent() - confirmEvent sends CreateEventDTO in body +- `ChatService`: sendMessage(), confirmEvent(convId, msgId, action, event?, eventId?, updates?), rejectEvent() - supports create/update/delete actions - `ProposedEventCard`: Displays proposed events (title, date, description, recurring indicator) with confirm/reject buttons - `Themes.tsx`: Centralized color definitions including button colors - Auth screens (Login, Register), Event Detail, and Note screens exist as skeletons diff --git a/apps/client/src/app/(tabs)/chat.tsx b/apps/client/src/app/(tabs)/chat.tsx index 43711b0..8bb6e87 100644 --- a/apps/client/src/app/(tabs)/chat.tsx +++ b/apps/client/src/app/(tabs)/chat.tsx @@ -5,7 +5,7 @@ import Header from "../../components/Header"; import BaseBackground from "../../components/BaseBackground"; import { FlashList } from "@shopify/flash-list"; import { ChatService } from "../../services"; -import { ProposedEventChange, CreateEventDTO } from "@caldav/shared"; +import { ProposedEventChange } from "@caldav/shared"; import { ProposedEventCard } from "../../components/ProposedEventCard"; // TODO: better shadows for everything @@ -40,7 +40,7 @@ const Chat = () => { action: "confirm" | "reject", messageId: string, conversationId: string, - event?: CreateEventDTO + proposedChange?: ProposedEventChange ) => { // Mark message as responded (optimistic update) setMessages((prev) => @@ -51,8 +51,15 @@ const Chat = () => { try { const response = - action === "confirm" && event - ? await ChatService.confirmEvent(conversationId, messageId, event) + action === "confirm" && proposedChange + ? await ChatService.confirmEvent( + conversationId, + messageId, + proposedChange.action, + proposedChange.event, + proposedChange.eventId, + proposedChange.updates + ) : await ChatService.rejectEvent(conversationId, messageId); const botMessage: MessageData = { @@ -112,7 +119,7 @@ const Chat = () => { proposedChange={item.proposedChange} respondedAction={item.respondedAction} onConfirm={() => - handleEventResponse("confirm", item.id, item.conversationId!, item.proposedChange?.event) + handleEventResponse("confirm", item.id, item.conversationId!, item.proposedChange) } onReject={() => handleEventResponse("reject", item.id, item.conversationId!) diff --git a/apps/client/src/services/ChatService.ts b/apps/client/src/services/ChatService.ts index dfb50a2..333e561 100644 --- a/apps/client/src/services/ChatService.ts +++ b/apps/client/src/services/ChatService.ts @@ -5,9 +5,18 @@ import { ConversationSummary, GetMessagesOptions, CreateEventDTO, + UpdateEventDTO, + EventAction, } from "@caldav/shared"; import { ApiClient } from "./ApiClient"; +interface ConfirmEventRequest { + action: EventAction; + event?: CreateEventDTO; + eventId?: string; + updates?: UpdateEventDTO; +} + export const ChatService = { sendMessage: async (data: SendMessageDTO): Promise => { return ApiClient.post("/chat/message", data); @@ -16,9 +25,13 @@ export const ChatService = { confirmEvent: async ( conversationId: string, messageId: string, - event: CreateEventDTO + action: EventAction, + event?: CreateEventDTO, + eventId?: string, + updates?: UpdateEventDTO ): Promise => { - return ApiClient.post(`/chat/confirm/${conversationId}/${messageId}`, event); + const body: ConfirmEventRequest = { action, event, eventId, updates }; + return ApiClient.post(`/chat/confirm/${conversationId}/${messageId}`, body); }, rejectEvent: async ( diff --git a/apps/server/package.json b/apps/server/package.json index 264c285..b28ab79 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -14,7 +14,8 @@ "dotenv": "^16.4.7", "express": "^5.2.1", "jsonwebtoken": "^9.0.3", - "mongoose": "^9.1.1" + "mongoose": "^9.1.1", + "rrule": "^2.8.1" }, "devDependencies": { "@types/bcrypt": "^6.0.0", diff --git a/apps/server/src/controllers/ChatController.ts b/apps/server/src/controllers/ChatController.ts index dde2336..f69f9dd 100644 --- a/apps/server/src/controllers/ChatController.ts +++ b/apps/server/src/controllers/ChatController.ts @@ -1,5 +1,5 @@ import { Response } from 'express'; -import { SendMessageDTO, CreateEventDTO } from '@caldav/shared'; +import { SendMessageDTO, CreateEventDTO, UpdateEventDTO, EventAction } from '@caldav/shared'; import { ChatService } from '../services'; import { AuthenticatedRequest } from '../middleware'; @@ -21,8 +21,21 @@ export class ChatController { try { const userId = req.user!.userId; const { conversationId, messageId } = req.params; - const event: CreateEventDTO = req.body; - const response = await this.chatService.confirmEvent(userId, conversationId, messageId, event); + const { action, event, eventId, updates } = req.body as { + action: EventAction; + event?: CreateEventDTO; + eventId?: string; + updates?: UpdateEventDTO; + }; + const response = await this.chatService.confirmEvent( + userId, + conversationId, + messageId, + action, + event, + eventId, + updates + ); res.json(response); } catch (error) { res.status(500).json({ error: 'Failed to confirm event' }); diff --git a/apps/server/src/repositories/mongo/MongoEventRepository.ts b/apps/server/src/repositories/mongo/MongoEventRepository.ts index 8312abb..33ebb6c 100644 --- a/apps/server/src/repositories/mongo/MongoEventRepository.ts +++ b/apps/server/src/repositories/mongo/MongoEventRepository.ts @@ -4,15 +4,22 @@ import { EventModel } from './models'; export class MongoEventRepository implements EventRepository { async findById(id: string): Promise { - throw new Error('Not implemented'); + const event = await EventModel.findById(id); + if (!event) return null; + return event.toJSON() as unknown as CalendarEvent; } async findByUserId(userId: string): Promise { - throw new Error('Not implemented'); + const events = await EventModel.find({ userId }).sort({ startTime: 1 }); + return events.map(e => e.toJSON() as unknown as CalendarEvent); } async findByDateRange(userId: string, startDate: Date, endDate: Date): Promise { - throw new Error('Not implemented'); + const events = await EventModel.find({ + userId, + startTime: { $gte: startDate, $lte: endDate } + }).sort({ startTime: 1 }); + return events.map(e => e.toJSON() as unknown as CalendarEvent); } async create(userId: string, data: CreateEventDTO): Promise { @@ -22,10 +29,13 @@ export class MongoEventRepository implements EventRepository { } async update(id: string, data: UpdateEventDTO): Promise { - throw new Error('Not implemented'); + const event = await EventModel.findByIdAndUpdate(id, data, { new: true }); + if (!event) return null; + return event.toJSON() as unknown as CalendarEvent; } async delete(id: string): Promise { - throw new Error('Not implemented'); + const result = await EventModel.findByIdAndDelete(id); + return result !== null; } } diff --git a/apps/server/src/services/ChatService.ts b/apps/server/src/services/ChatService.ts index 038ac15..24f8806 100644 --- a/apps/server/src/services/ChatService.ts +++ b/apps/server/src/services/ChatService.ts @@ -1,163 +1,211 @@ -import { ChatMessage, ChatResponse, SendMessageDTO, ConversationSummary, GetMessagesOptions, ProposedEventChange, getDay, CreateEventDTO } from '@caldav/shared'; +import { ChatMessage, ChatResponse, SendMessageDTO, ConversationSummary, GetMessagesOptions, ProposedEventChange, getDay, CreateEventDTO, UpdateEventDTO, EventAction } from '@caldav/shared'; import { ChatRepository, EventRepository, AIProvider } from './interfaces'; +import { getWeeksOverview, getMonthOverview } from '../utils/eventFormatters'; -// Test responses array (cycles through responses) -let responseIndex = 0; +type TestResponse = { content: string; proposedChange?: ProposedEventChange }; -const testResponses: Array<{ content: string; proposedChange?: ProposedEventChange }> = [ - // {{{ - // Response 1: Meeting mit Jens - next Friday 14:00 - { - content: "Alles klar! Ich erstelle dir einen Termin für das Meeting mit Jens am nächsten Freitag um 14:00 Uhr:", - proposedChange: { - action: 'create', - event: { - title: "Meeting mit Jens", - startTime: getDay('Friday', 1, 14, 0), - endTime: getDay('Friday', 1, 15, 0), - description: "Arbeitstreffen", +// Test response index (cycles through responses) +let responseIndex = 8; + +// Static test responses (event proposals) +const staticResponses: TestResponse[] = [ + // {{{ + // Response 0: Meeting mit Jens - next Friday 14:00 + { + content: "Alles klar! Ich erstelle dir einen Termin für das Meeting mit Jens am nächsten Freitag um 14:00 Uhr:", + proposedChange: { + action: 'create', + event: { + title: "Meeting mit Jens", + startTime: getDay('Friday', 1, 14, 0), + endTime: getDay('Friday', 1, 15, 0), + description: "Arbeitstreffen", + } } - } - }, - // Response 2: Recurring event - every Saturday 10:00 - { - content: "Verstanden! Ich erstelle einen wiederkehrenden Termin: Jeden Samstag um 10:00 Uhr Badezimmer putzen:", - proposedChange: { - action: 'create', - event: { - title: "Badezimmer putzen", - startTime: getDay('Saturday', 1, 10, 0), - endTime: getDay('Saturday', 1, 11, 0), - isRecurring: true, - recurrenceRule: "FREQ=WEEKLY;BYDAY=SA", + }, + // Response 1: Recurring event - every Saturday 10:00 + { + content: "Verstanden! Ich erstelle einen wiederkehrenden Termin: Jeden Samstag um 10:00 Uhr Badezimmer putzen:", + proposedChange: { + action: 'create', + event: { + title: "Badezimmer putzen", + startTime: getDay('Saturday', 1, 10, 0), + endTime: getDay('Saturday', 1, 11, 0), + isRecurring: true, + recurrenceRule: "FREQ=WEEKLY;BYDAY=SA", + } } - } - }, - // Response 3: Calendar overview (text only, no proposedChange) - { - content: "Hier sind deine Termine für die nächsten 2 Wochen:\n\n" + - "Freitag, 10.01. - 14:00 Uhr: Meeting mit Jens\n" + - "Samstag, 11.01. - 10:00 Uhr: Badezimmer putzen\n" + - "Samstag, 18.01. - 10:00 Uhr: Badezimmer putzen\n\n" + - "Insgesamt 3 Termine.", - }, - // Response 4: Doctor appointment with description - { - content: "Ich habe dir einen Arzttermin eingetragen. Denk daran, deine Versichertenkarte mitzunehmen!", - proposedChange: { - action: 'create', - event: { - title: "Arzttermin Dr. Müller", - startTime: getDay('Wednesday', 1, 9, 30), - endTime: getDay('Wednesday', 1, 10, 30), - description: "Routineuntersuchung - Versichertenkarte nicht vergessen", + }, + // Response 2: 2-week overview (DYNAMIC - placeholder) + { content: '' }, + // Response 3: Delete "Meeting mit Jens" (DYNAMIC - placeholder) + { content: '' }, + // Response 4: Doctor appointment with description + { + content: "Ich habe dir einen Arzttermin eingetragen. Denk daran, deine Versichertenkarte mitzunehmen!", + proposedChange: { + action: 'create', + event: { + title: "Arzttermin Dr. Müller", + startTime: getDay('Wednesday', 1, 9, 30), + endTime: getDay('Wednesday', 1, 10, 30), + description: "Routineuntersuchung - Versichertenkarte nicht vergessen", + } } - } - }, - // Response 5: Birthday - yearly recurring - { - content: "Geburtstage vergisst man leicht - aber nicht mit mir! Ich habe Mamas Geburtstag eingetragen:", - proposedChange: { - action: 'create', - event: { - title: "Mamas Geburtstag", - startTime: getDay('Thursday', 2, 0, 0), - endTime: getDay('Thursday', 2, 23, 59), - isRecurring: true, - recurrenceRule: "FREQ=YEARLY", + }, + // Response 5: Birthday - yearly recurring + { + content: "Geburtstage vergisst man leicht - aber nicht mit mir! Ich habe Mamas Geburtstag eingetragen:", + proposedChange: { + action: 'create', + event: { + title: "Mamas Geburtstag", + startTime: getDay('Thursday', 2, 0, 0), + endTime: getDay('Thursday', 2, 23, 59), + isRecurring: true, + recurrenceRule: "FREQ=YEARLY", + } } - } - }, - // Response 6: Gym - recurring for 2 months (8 weeks) - { - content: "Perfekt! Ich habe dein Probetraining eingetragen - jeden Dienstag für die nächsten 2 Monate:", - proposedChange: { - action: 'create', - event: { - title: "Fitnessstudio Probetraining", - startTime: getDay('Tuesday', 1, 18, 0), - endTime: getDay('Tuesday', 1, 19, 30), - isRecurring: true, - recurrenceRule: "FREQ=WEEKLY;BYDAY=TU;COUNT=8", + }, + // Response 6: Gym - recurring for 2 months (8 weeks) + { + content: "Perfekt! Ich habe dein Probetraining eingetragen - jeden Dienstag für die nächsten 2 Monate:", + proposedChange: { + action: 'create', + event: { + title: "Fitnessstudio Probetraining", + startTime: getDay('Tuesday', 1, 18, 0), + endTime: getDay('Tuesday', 1, 19, 30), + isRecurring: true, + recurrenceRule: "FREQ=WEEKLY;BYDAY=TU;COUNT=8", + } } - } - }, - // Response 7: Weekly calendar overview (text only) - { - content: "Hier ist dein Überblick für nächste Woche:\n\n" + - "Montag - Keine Termine\n" + - "Dienstag, 18:00 Uhr: Fitnessstudio\n" + - "Mittwoch, 09:30 Uhr: Arzttermin Dr. Müller\n" + - "Donnerstag - Keine Termine\n" + - "Freitag, 14:00 Uhr: Meeting mit Jens\n" + - "Samstag, 10:00 Uhr: Badezimmer putzen\n" + - "Sonntag, 11:00 Uhr: Telefonat mit Mama\n\n" + - "Insgesamt 5 Termine nächste Woche.", - }, - // Response 8: Help response (text only) - { - content: "Ich bin dein Kalender-Assistent! Du kannst mir einfach sagen, welche Termine du erstellen, ändern oder löschen möchtest. Zum Beispiel:\n\n" + - "• \"Erstelle einen Termin für morgen um 15 Uhr\"\n" + - "• \"Was habe ich nächste Woche vor?\"\n" + - "• \"Verschiebe das Meeting auf Donnerstag\"\n\n" + - "Wie kann ich dir helfen?", - }, - // Response 9: Phone call - short appointment - { - content: "Alles klar! Ich habe das Telefonat mit deiner Mutter eingetragen:", - proposedChange: { - action: 'create', - event: { - title: "Telefonat mit Mama", - startTime: getDay('Sunday', 0, 11, 0), - endTime: getDay('Sunday', 0, 11, 30), + }, + // Response 7: 1-week overview (DYNAMIC - placeholder) + { content: '' }, + // Response 8: Help response (text only) + { + content: "Ich bin dein Kalender-Assistent! Du kannst mir einfach sagen, welche Termine du erstellen, ändern oder löschen möchtest. Zum Beispiel:\n\n" + + "• \"Erstelle einen Termin für morgen um 15 Uhr\"\n" + + "• \"Was habe ich nächste Woche vor?\"\n" + + "• \"Verschiebe das Meeting auf Donnerstag\"\n\n" + + "Wie kann ich dir helfen?", + }, + // Response 9: Phone call - short appointment + { + content: "Alles klar! Ich habe das Telefonat mit deiner Mutter eingetragen:", + proposedChange: { + action: 'create', + event: { + title: "Telefonat mit Mama", + startTime: getDay('Sunday', 0, 11, 0), + endTime: getDay('Sunday', 0, 11, 30), + } } - } - }, - // Response 10: Birthday party - evening event - { - content: "Super! Die Geburtstagsfeier ist eingetragen. Viel Spaß!", - proposedChange: { - action: 'create', - event: { - title: "Geburtstagsfeier Lisa", - startTime: getDay('Saturday', 2, 19, 0), - endTime: getDay('Saturday', 2, 23, 0), - description: "Geschenk: Buch über Fotografie", + }, + // Response 10: Update "Telefonat mit Mama" +2 days (DYNAMIC - placeholder) + { content: '' }, + // Response 11: Birthday party - evening event + { + content: "Super! Die Geburtstagsfeier ist eingetragen. Viel Spaß!", + proposedChange: { + action: 'create', + event: { + title: "Geburtstagsfeier Lisa", + startTime: getDay('Saturday', 2, 19, 0), + endTime: getDay('Saturday', 2, 23, 0), + description: "Geschenk: Buch über Fotografie", + } } - } - }, - // Response 11: Language course - limited to 8 weeks - { - content: "Dein Spanischkurs ist eingetragen! Er läuft jeden Donnerstag für die nächsten 8 Wochen:", - proposedChange: { - action: 'create', - event: { - title: "Spanischkurs VHS", - startTime: getDay('Thursday', 1, 19, 0), - endTime: getDay('Thursday', 1, 20, 30), - isRecurring: true, - recurrenceRule: "FREQ=WEEKLY;BYDAY=TH;COUNT=8", + }, + // Response 12: Language course - limited to 8 weeks + { + content: "Dein Spanischkurs ist eingetragen! Er läuft jeden Donnerstag für die nächsten 8 Wochen:", + proposedChange: { + action: 'create', + event: { + title: "Spanischkurs VHS", + startTime: getDay('Thursday', 1, 19, 0), + endTime: getDay('Thursday', 1, 20, 30), + isRecurring: true, + recurrenceRule: "FREQ=WEEKLY;BYDAY=TH;COUNT=8", + } } - } - }, - // Response 12: Monthly calendar overview (text only) - { - content: "Hier ist deine Monatsübersicht für Januar:\n\n" + - "KW 2: 3 Termine\n" + - " • Mi 08.01., 09:30: Arzttermin Dr. Müller\n" + - " • Fr 10.01., 14:00: Meeting mit Jens\n" + - " • Sa 11.01., 10:00: Badezimmer putzen\n\n" + - "KW 3: 4 Termine\n" + - " • Di 14.01., 18:00: Fitnessstudio\n" + - " • Do 16.01., 19:00: Spanischkurs VHS\n" + - " • Sa 18.01., 10:00: Badezimmer putzen\n" + - " • Sa 18.01., 19:00: Geburtstagsfeier Lisa\n\n" + - "Insgesamt 7 Termine im Januar.", - }, - // }}} + }, + // Response 13: Monthly overview (DYNAMIC - placeholder) + { content: '' }, + // }}} ]; +async function getTestResponse( + index: number, + eventRepo: EventRepository, + userId: string +): Promise { + const responseIdx = index % staticResponses.length; + + // Dynamic responses: fetch events from DB and format + if (responseIdx === 2) { + return { content: await getWeeksOverview(eventRepo, userId, 2) }; + } + + if (responseIdx === 3) { + // Delete "Meeting mit Jens" + const events = await eventRepo.findByUserId(userId); + const jensEvent = events.find(e => e.title === 'Meeting mit Jens'); + if (jensEvent) { + return { + content: "Alles klar, ich lösche den Termin 'Meeting mit Jens' für dich:", + proposedChange: { + action: 'delete', + eventId: jensEvent.id, + } + }; + } + return { content: "Ich konnte keinen Termin 'Meeting mit Jens' finden." }; + } + + if (responseIdx === 7) { + return { content: await getWeeksOverview(eventRepo, userId, 1) }; + } + + if (responseIdx === 10) { + // Update "Telefonat mit Mama" +2 days + const events = await eventRepo.findByUserId(userId); + const mamaEvent = events.find(e => e.title === 'Telefonat mit Mama'); + if (mamaEvent) { + const newStart = new Date(mamaEvent.startTime); + newStart.setDate(newStart.getDate() + 2); + const newEnd = new Date(mamaEvent.endTime); + newEnd.setDate(newEnd.getDate() + 2); + return { + content: "Alles klar, ich verschiebe das Telefonat mit Mama um 2 Tage nach hinten:", + proposedChange: { + action: 'update', + eventId: mamaEvent.id, + updates: { startTime: newStart, endTime: newEnd }, + // Include event with new times for display + event: { + title: mamaEvent.title, + startTime: newStart, + endTime: newEnd, + description: mamaEvent.description, + } + } + }; + } + return { content: "Ich konnte keinen Termin 'Telefonat mit Mama' finden." }; + } + + if (responseIdx === 13) { + const now = new Date(); + return { content: await getMonthOverview(eventRepo, userId, now.getFullYear(), now.getMonth()) }; + } + + return staticResponses[responseIdx]; +} + export class ChatService { constructor( private chatRepo: ChatRepository, @@ -166,7 +214,7 @@ export class ChatService { ) {} async processMessage(userId: string, data: SendMessageDTO): Promise { - const response = testResponses[responseIndex % testResponses.length]; + const response = await getTestResponse(responseIndex, this.eventRepo, userId); responseIndex++; const message: ChatMessage = { @@ -180,14 +228,37 @@ export class ChatService { return { message, conversationId: message.conversationId }; } - async confirmEvent(userId: string, conversationId: string, messageId: string, event: CreateEventDTO): Promise { - const createdEvent = await this.eventRepo.create(userId, event); + async confirmEvent( + userId: string, + conversationId: string, + messageId: string, + action: EventAction, + event?: CreateEventDTO, + eventId?: string, + updates?: UpdateEventDTO + ): Promise { + let content: string; + + if (action === 'create' && event) { + const createdEvent = await this.eventRepo.create(userId, event); + content = `Der Termin "${createdEvent.title}" wurde erstellt.`; + } else if (action === 'update' && eventId && updates) { + const updatedEvent = await this.eventRepo.update(eventId, updates); + content = updatedEvent + ? `Der Termin "${updatedEvent.title}" wurde aktualisiert.` + : 'Termin nicht gefunden.'; + } else if (action === 'delete' && eventId) { + await this.eventRepo.delete(eventId); + content = 'Der Termin wurde gelöscht.'; + } else { + content = 'Ungültige Aktion.'; + } const message: ChatMessage = { id: Date.now().toString(), conversationId, sender: 'assistant', - content: `Der Termin "${createdEvent.title}" wurde erstellt.`, + content, }; return { message, conversationId }; } diff --git a/apps/server/src/utils/eventFormatters.ts b/apps/server/src/utils/eventFormatters.ts new file mode 100644 index 0000000..c879dbf --- /dev/null +++ b/apps/server/src/utils/eventFormatters.ts @@ -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(); + 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 { + 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 { + 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); +} diff --git a/apps/server/src/utils/recurrenceExpander.ts b/apps/server/src/utils/recurrenceExpander.ts new file mode 100644 index 0000000..adf16b0 --- /dev/null +++ b/apps/server/src/utils/recurrenceExpander.ts @@ -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}`; +} diff --git a/package-lock.json b/package-lock.json index 8bfae17..8fada5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -67,7 +67,8 @@ "dotenv": "^16.4.7", "express": "^5.2.1", "jsonwebtoken": "^9.0.3", - "mongoose": "^9.1.1" + "mongoose": "^9.1.1", + "rrule": "^2.8.1" }, "devDependencies": { "@types/bcrypt": "^6.0.0", @@ -13124,6 +13125,15 @@ "node": ">= 18" } }, + "node_modules/rrule": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/rrule/-/rrule-2.8.1.tgz", + "integrity": "sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", diff --git a/packages/shared/src/models/Constants.ts b/packages/shared/src/models/Constants.ts index a78ec3b..42d8203 100644 --- a/packages/shared/src/models/Constants.ts +++ b/packages/shared/src/models/Constants.ts @@ -37,3 +37,42 @@ export const DAY_INDEX: Record = { Friday: 5, Saturday: 6, }; + +// Mapping from Date.getDay() index (0=Sunday) to Day type +export const DAY_INDEX_TO_DAY: Day[] = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + +// German translations +export const DAY_TO_GERMAN: Record = { + Monday: 'Montag', + Tuesday: 'Dienstag', + Wednesday: 'Mittwoch', + Thursday: 'Donnerstag', + Friday: 'Freitag', + Saturday: 'Samstag', + Sunday: 'Sonntag', +}; + +export const DAY_TO_GERMAN_SHORT: Record = { + Monday: 'Mo', + Tuesday: 'Di', + Wednesday: 'Mi', + Thursday: 'Do', + Friday: 'Fr', + Saturday: 'Sa', + Sunday: 'So', +}; + +export const MONTH_TO_GERMAN: Record = { + January: 'Januar', + February: 'Februar', + March: 'März', + April: 'April', + May: 'Mai', + June: 'Juni', + July: 'Juli', + August: 'August', + September: 'September', + October: 'Oktober', + November: 'November', + December: 'Dezember', +};