add event CRUD actions and recurring event expansion

- Implement full CRUD in MongoEventRepository (findById, findByUserId, findByDateRange, update, delete)
- Extend ChatService to handle create/update/delete actions with dynamic test responses
- Add recurrenceExpander utility using rrule library for RRULE parsing
- Add eventFormatters utility for German-localized week/month overviews
- Add German translations for days and months in shared Constants
- Update client ChatService to support all event actions (action, eventId, updates params)
This commit is contained in:
2026-01-04 16:15:30 +01:00
parent 9fecf94c7d
commit 77f15b6dd1
11 changed files with 577 additions and 174 deletions

View File

@@ -135,7 +135,9 @@ src/
│ └── ClaudeAdapter.ts # Implements AIProvider │ └── ClaudeAdapter.ts # Implements AIProvider
└── utils/ └── utils/
├── jwt.ts # signToken(), verifyToken() ├── 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:** **API Endpoints:**
@@ -169,7 +171,8 @@ src/
│ ├── ChatMessage.ts # ChatMessage, Conversation, SendMessageDTO, CreateMessageDTO, │ ├── ChatMessage.ts # ChatMessage, Conversation, SendMessageDTO, CreateMessageDTO,
│ │ # GetMessagesOptions, ChatResponse, ConversationSummary, │ │ # GetMessagesOptions, ChatResponse, ConversationSummary,
│ │ # ProposedEventChange, EventAction │ │ # 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/ └── 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
@@ -269,26 +272,28 @@ MONGODB_URI=mongodb://root:mongoose@localhost:27017/calchat?authSource=admin
- `utils/jwt`: signToken() (verifyToken() pending) - `utils/jwt`: signToken() (verifyToken() pending)
- `dotenv` integration for environment variables - `dotenv` integration for environment variables
- `ChatController`: sendMessage(), confirmEvent(), rejectEvent() - `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):** - **Stubbed (TODO):**
- `AuthMiddleware.authenticate()`: Currently uses fake user for testing - `AuthMiddleware.authenticate()`: Currently uses fake user for testing
- `AuthController`: refresh(), logout() - `AuthController`: refresh(), logout()
- `AuthService`: refreshToken() - `AuthService`: refreshToken()
- `ChatController`: getConversations(), getConversation() - `ChatController`: getConversations(), getConversation()
- `MongoChatRepository`: Database persistence for chat - `MongoChatRepository`: Database persistence for chat
- `MongoEventRepository`: Only create() implemented, rest stubbed
- **Not started:** - **Not started:**
- `EventController`, `EventService` - `EventController`, `EventService`
- `ClaudeAdapter` (AI integration - currently using test responses) - `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:** **Frontend:**
- Tab navigation (Chat, Calendar) implemented with basic UI - Tab navigation (Chat, Calendar) implemented with basic UI
- Calendar screen has month navigation and grid display (partially functional) - Calendar screen has month navigation and grid display (partially functional)
- Chat screen functional with FlashList, message sending, and event confirm/reject - Chat screen functional with FlashList, message sending, and event confirm/reject
- `ApiClient`: get(), post() implemented - `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 - `ProposedEventCard`: Displays proposed events (title, date, description, recurring indicator) with confirm/reject buttons
- `Themes.tsx`: Centralized color definitions including button colors - `Themes.tsx`: Centralized color definitions including button colors
- Auth screens (Login, Register), Event Detail, and Note screens exist as skeletons - Auth screens (Login, Register), Event Detail, and Note screens exist as skeletons

View File

@@ -5,7 +5,7 @@ import Header from "../../components/Header";
import BaseBackground from "../../components/BaseBackground"; import BaseBackground from "../../components/BaseBackground";
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import { ChatService } from "../../services"; import { ChatService } from "../../services";
import { ProposedEventChange, CreateEventDTO } from "@caldav/shared"; import { ProposedEventChange } from "@caldav/shared";
import { ProposedEventCard } from "../../components/ProposedEventCard"; import { ProposedEventCard } from "../../components/ProposedEventCard";
// TODO: better shadows for everything // TODO: better shadows for everything
@@ -40,7 +40,7 @@ const Chat = () => {
action: "confirm" | "reject", action: "confirm" | "reject",
messageId: string, messageId: string,
conversationId: string, conversationId: string,
event?: CreateEventDTO proposedChange?: ProposedEventChange
) => { ) => {
// Mark message as responded (optimistic update) // Mark message as responded (optimistic update)
setMessages((prev) => setMessages((prev) =>
@@ -51,8 +51,15 @@ const Chat = () => {
try { try {
const response = const response =
action === "confirm" && event action === "confirm" && proposedChange
? await ChatService.confirmEvent(conversationId, messageId, event) ? await ChatService.confirmEvent(
conversationId,
messageId,
proposedChange.action,
proposedChange.event,
proposedChange.eventId,
proposedChange.updates
)
: await ChatService.rejectEvent(conversationId, messageId); : await ChatService.rejectEvent(conversationId, messageId);
const botMessage: MessageData = { const botMessage: MessageData = {
@@ -112,7 +119,7 @@ const Chat = () => {
proposedChange={item.proposedChange} proposedChange={item.proposedChange}
respondedAction={item.respondedAction} respondedAction={item.respondedAction}
onConfirm={() => onConfirm={() =>
handleEventResponse("confirm", item.id, item.conversationId!, item.proposedChange?.event) handleEventResponse("confirm", item.id, item.conversationId!, item.proposedChange)
} }
onReject={() => onReject={() =>
handleEventResponse("reject", item.id, item.conversationId!) handleEventResponse("reject", item.id, item.conversationId!)

View File

@@ -5,9 +5,18 @@ import {
ConversationSummary, ConversationSummary,
GetMessagesOptions, GetMessagesOptions,
CreateEventDTO, CreateEventDTO,
UpdateEventDTO,
EventAction,
} from "@caldav/shared"; } from "@caldav/shared";
import { ApiClient } from "./ApiClient"; import { ApiClient } from "./ApiClient";
interface ConfirmEventRequest {
action: EventAction;
event?: CreateEventDTO;
eventId?: string;
updates?: UpdateEventDTO;
}
export const ChatService = { export const ChatService = {
sendMessage: async (data: SendMessageDTO): Promise<ChatResponse> => { sendMessage: async (data: SendMessageDTO): Promise<ChatResponse> => {
return ApiClient.post<ChatResponse>("/chat/message", data); return ApiClient.post<ChatResponse>("/chat/message", data);
@@ -16,9 +25,13 @@ export const ChatService = {
confirmEvent: async ( confirmEvent: async (
conversationId: string, conversationId: string,
messageId: string, messageId: string,
event: CreateEventDTO action: EventAction,
event?: CreateEventDTO,
eventId?: string,
updates?: UpdateEventDTO
): Promise<ChatResponse> => { ): Promise<ChatResponse> => {
return ApiClient.post<ChatResponse>(`/chat/confirm/${conversationId}/${messageId}`, event); const body: ConfirmEventRequest = { action, event, eventId, updates };
return ApiClient.post<ChatResponse>(`/chat/confirm/${conversationId}/${messageId}`, body);
}, },
rejectEvent: async ( rejectEvent: async (

View File

@@ -14,7 +14,8 @@
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"express": "^5.2.1", "express": "^5.2.1",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"mongoose": "^9.1.1" "mongoose": "^9.1.1",
"rrule": "^2.8.1"
}, },
"devDependencies": { "devDependencies": {
"@types/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",

View File

@@ -1,5 +1,5 @@
import { Response } from 'express'; import { Response } from 'express';
import { SendMessageDTO, CreateEventDTO } from '@caldav/shared'; import { SendMessageDTO, CreateEventDTO, UpdateEventDTO, EventAction } from '@caldav/shared';
import { ChatService } from '../services'; import { ChatService } from '../services';
import { AuthenticatedRequest } from '../middleware'; import { AuthenticatedRequest } from '../middleware';
@@ -21,8 +21,21 @@ 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;
const event: CreateEventDTO = req.body; const { action, event, eventId, updates } = req.body as {
const response = await this.chatService.confirmEvent(userId, conversationId, messageId, event); action: EventAction;
event?: CreateEventDTO;
eventId?: string;
updates?: UpdateEventDTO;
};
const response = await this.chatService.confirmEvent(
userId,
conversationId,
messageId,
action,
event,
eventId,
updates
);
res.json(response); res.json(response);
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to confirm event' }); res.status(500).json({ error: 'Failed to confirm event' });

View File

@@ -4,15 +4,22 @@ import { EventModel } from './models';
export class MongoEventRepository implements EventRepository { export class MongoEventRepository implements EventRepository {
async findById(id: string): Promise<CalendarEvent | null> { async findById(id: string): Promise<CalendarEvent | null> {
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<CalendarEvent[]> { async findByUserId(userId: string): Promise<CalendarEvent[]> {
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<CalendarEvent[]> { async findByDateRange(userId: string, startDate: Date, endDate: Date): Promise<CalendarEvent[]> {
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<CalendarEvent> { async create(userId: string, data: CreateEventDTO): Promise<CalendarEvent> {
@@ -22,10 +29,13 @@ export class MongoEventRepository implements EventRepository {
} }
async update(id: string, data: UpdateEventDTO): Promise<CalendarEvent | null> { async update(id: string, data: UpdateEventDTO): Promise<CalendarEvent | null> {
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<boolean> { async delete(id: string): Promise<boolean> {
throw new Error('Not implemented'); const result = await EventModel.findByIdAndDelete(id);
return result !== null;
} }
} }

View File

@@ -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 { ChatRepository, EventRepository, AIProvider } from './interfaces';
import { getWeeksOverview, getMonthOverview } from '../utils/eventFormatters';
// Test responses array (cycles through responses) type TestResponse = { content: string; proposedChange?: ProposedEventChange };
let responseIndex = 0;
const testResponses: Array<{ content: string; proposedChange?: ProposedEventChange }> = [ // Test response index (cycles through responses)
// {{{ let responseIndex = 8;
// Response 1: Meeting mit Jens - next Friday 14:00
{ // Static test responses (event proposals)
content: "Alles klar! Ich erstelle dir einen Termin für das Meeting mit Jens am nächsten Freitag um 14:00 Uhr:", const staticResponses: TestResponse[] = [
proposedChange: { // {{{
action: 'create', // Response 0: Meeting mit Jens - next Friday 14:00
event: { {
title: "Meeting mit Jens", content: "Alles klar! Ich erstelle dir einen Termin für das Meeting mit Jens am nächsten Freitag um 14:00 Uhr:",
startTime: getDay('Friday', 1, 14, 0), proposedChange: {
endTime: getDay('Friday', 1, 15, 0), action: 'create',
description: "Arbeitstreffen", event: {
title: "Meeting mit Jens",
startTime: getDay('Friday', 1, 14, 0),
endTime: getDay('Friday', 1, 15, 0),
description: "Arbeitstreffen",
}
} }
} },
}, // Response 1: Recurring event - every Saturday 10:00
// Response 2: Recurring event - every Saturday 10:00 {
{ content: "Verstanden! Ich erstelle einen wiederkehrenden Termin: Jeden Samstag um 10:00 Uhr Badezimmer putzen:",
content: "Verstanden! Ich erstelle einen wiederkehrenden Termin: Jeden Samstag um 10:00 Uhr Badezimmer putzen:", proposedChange: {
proposedChange: { action: 'create',
action: 'create', event: {
event: { title: "Badezimmer putzen",
title: "Badezimmer putzen", startTime: getDay('Saturday', 1, 10, 0),
startTime: getDay('Saturday', 1, 10, 0), endTime: getDay('Saturday', 1, 11, 0),
endTime: getDay('Saturday', 1, 11, 0), isRecurring: true,
isRecurring: true, recurrenceRule: "FREQ=WEEKLY;BYDAY=SA",
recurrenceRule: "FREQ=WEEKLY;BYDAY=SA", }
} }
} },
}, // Response 2: 2-week overview (DYNAMIC - placeholder)
// Response 3: Calendar overview (text only, no proposedChange) { content: '' },
{ // Response 3: Delete "Meeting mit Jens" (DYNAMIC - placeholder)
content: "Hier sind deine Termine für die nächsten 2 Wochen:\n\n" + { content: '' },
"Freitag, 10.01. - 14:00 Uhr: Meeting mit Jens\n" + // Response 4: Doctor appointment with description
"Samstag, 11.01. - 10:00 Uhr: Badezimmer putzen\n" + {
"Samstag, 18.01. - 10:00 Uhr: Badezimmer putzen\n\n" + content: "Ich habe dir einen Arzttermin eingetragen. Denk daran, deine Versichertenkarte mitzunehmen!",
"Insgesamt 3 Termine.", proposedChange: {
}, action: 'create',
// Response 4: Doctor appointment with description event: {
{ title: "Arzttermin Dr. Müller",
content: "Ich habe dir einen Arzttermin eingetragen. Denk daran, deine Versichertenkarte mitzunehmen!", startTime: getDay('Wednesday', 1, 9, 30),
proposedChange: { endTime: getDay('Wednesday', 1, 10, 30),
action: 'create', description: "Routineuntersuchung - Versichertenkarte nicht vergessen",
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
// Response 5: Birthday - yearly recurring {
{ content: "Geburtstage vergisst man leicht - aber nicht mit mir! Ich habe Mamas Geburtstag eingetragen:",
content: "Geburtstage vergisst man leicht - aber nicht mit mir! Ich habe Mamas Geburtstag eingetragen:", proposedChange: {
proposedChange: { action: 'create',
action: 'create', event: {
event: { title: "Mamas Geburtstag",
title: "Mamas Geburtstag", startTime: getDay('Thursday', 2, 0, 0),
startTime: getDay('Thursday', 2, 0, 0), endTime: getDay('Thursday', 2, 23, 59),
endTime: getDay('Thursday', 2, 23, 59), isRecurring: true,
isRecurring: true, recurrenceRule: "FREQ=YEARLY",
recurrenceRule: "FREQ=YEARLY", }
} }
} },
}, // Response 6: Gym - recurring for 2 months (8 weeks)
// 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:",
content: "Perfekt! Ich habe dein Probetraining eingetragen - jeden Dienstag für die nächsten 2 Monate:", proposedChange: {
proposedChange: { action: 'create',
action: 'create', event: {
event: { title: "Fitnessstudio Probetraining",
title: "Fitnessstudio Probetraining", startTime: getDay('Tuesday', 1, 18, 0),
startTime: getDay('Tuesday', 1, 18, 0), endTime: getDay('Tuesday', 1, 19, 30),
endTime: getDay('Tuesday', 1, 19, 30), isRecurring: true,
isRecurring: true, recurrenceRule: "FREQ=WEEKLY;BYDAY=TU;COUNT=8",
recurrenceRule: "FREQ=WEEKLY;BYDAY=TU;COUNT=8", }
} }
} },
}, // Response 7: 1-week overview (DYNAMIC - placeholder)
// Response 7: Weekly calendar overview (text only) { content: '' },
{ // Response 8: Help response (text only)
content: "Hier ist dein Überblick für nächste Woche:\n\n" + {
"Montag - Keine Termine\n" + 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" +
"Dienstag, 18:00 Uhr: Fitnessstudio\n" + "• \"Erstelle einen Termin für morgen um 15 Uhr\"\n" +
"Mittwoch, 09:30 Uhr: Arzttermin Dr. Müller\n" + "• \"Was habe ich nächste Woche vor?\"\n" +
"Donnerstag - Keine Termine\n" + "• \"Verschiebe das Meeting auf Donnerstag\"\n\n" +
"Freitag, 14:00 Uhr: Meeting mit Jens\n" + "Wie kann ich dir helfen?",
"Samstag, 10:00 Uhr: Badezimmer putzen\n" + },
"Sonntag, 11:00 Uhr: Telefonat mit Mama\n\n" + // Response 9: Phone call - short appointment
"Insgesamt 5 Termine nächste Woche.", {
}, content: "Alles klar! Ich habe das Telefonat mit deiner Mutter eingetragen:",
// Response 8: Help response (text only) proposedChange: {
{ action: 'create',
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" + event: {
"• \"Erstelle einen Termin für morgen um 15 Uhr\"\n" + title: "Telefonat mit Mama",
"• \"Was habe ich nächste Woche vor?\"\n" + startTime: getDay('Sunday', 0, 11, 0),
"• \"Verschiebe das Meeting auf Donnerstag\"\n\n" + endTime: getDay('Sunday', 0, 11, 30),
"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: Update "Telefonat mit Mama" +2 days (DYNAMIC - placeholder)
// Response 10: Birthday party - evening event { content: '' },
{ // Response 11: Birthday party - evening event
content: "Super! Die Geburtstagsfeier ist eingetragen. Viel Spaß!", {
proposedChange: { content: "Super! Die Geburtstagsfeier ist eingetragen. Viel Spaß!",
action: 'create', proposedChange: {
event: { action: 'create',
title: "Geburtstagsfeier Lisa", event: {
startTime: getDay('Saturday', 2, 19, 0), title: "Geburtstagsfeier Lisa",
endTime: getDay('Saturday', 2, 23, 0), startTime: getDay('Saturday', 2, 19, 0),
description: "Geschenk: Buch über Fotografie", endTime: getDay('Saturday', 2, 23, 0),
description: "Geschenk: Buch über Fotografie",
}
} }
} },
}, // Response 12: Language course - limited to 8 weeks
// 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:",
content: "Dein Spanischkurs ist eingetragen! Er läuft jeden Donnerstag für die nächsten 8 Wochen:", proposedChange: {
proposedChange: { action: 'create',
action: 'create', event: {
event: { title: "Spanischkurs VHS",
title: "Spanischkurs VHS", startTime: getDay('Thursday', 1, 19, 0),
startTime: getDay('Thursday', 1, 19, 0), endTime: getDay('Thursday', 1, 20, 30),
endTime: getDay('Thursday', 1, 20, 30), isRecurring: true,
isRecurring: true, recurrenceRule: "FREQ=WEEKLY;BYDAY=TH;COUNT=8",
recurrenceRule: "FREQ=WEEKLY;BYDAY=TH;COUNT=8", }
} }
} },
}, // Response 13: Monthly overview (DYNAMIC - placeholder)
// Response 12: Monthly calendar overview (text only) { content: '' },
{ // }}}
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.",
},
// }}}
]; ];
async function getTestResponse(
index: number,
eventRepo: EventRepository,
userId: string
): Promise<TestResponse> {
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 { export class ChatService {
constructor( constructor(
private chatRepo: ChatRepository, private chatRepo: ChatRepository,
@@ -166,7 +214,7 @@ export class ChatService {
) {} ) {}
async processMessage(userId: string, data: SendMessageDTO): Promise<ChatResponse> { async processMessage(userId: string, data: SendMessageDTO): Promise<ChatResponse> {
const response = testResponses[responseIndex % testResponses.length]; const response = await getTestResponse(responseIndex, this.eventRepo, userId);
responseIndex++; responseIndex++;
const message: ChatMessage = { const message: ChatMessage = {
@@ -180,14 +228,37 @@ export class ChatService {
return { message, conversationId: message.conversationId }; return { message, conversationId: message.conversationId };
} }
async confirmEvent(userId: string, conversationId: string, messageId: string, event: CreateEventDTO): Promise<ChatResponse> { async confirmEvent(
const createdEvent = await this.eventRepo.create(userId, event); userId: string,
conversationId: string,
messageId: string,
action: EventAction,
event?: CreateEventDTO,
eventId?: string,
updates?: UpdateEventDTO
): Promise<ChatResponse> {
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 = { const message: ChatMessage = {
id: Date.now().toString(), id: Date.now().toString(),
conversationId, conversationId,
sender: 'assistant', sender: 'assistant',
content: `Der Termin "${createdEvent.title}" wurde erstellt.`, content,
}; };
return { message, conversationId }; return { message, conversationId };
} }

View File

@@ -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<number, ExpandedEvent[]>();
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<string> {
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<string> {
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);
}

View File

@@ -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}`;
}

12
package-lock.json generated
View File

@@ -67,7 +67,8 @@
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"express": "^5.2.1", "express": "^5.2.1",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"mongoose": "^9.1.1" "mongoose": "^9.1.1",
"rrule": "^2.8.1"
}, },
"devDependencies": { "devDependencies": {
"@types/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",
@@ -13124,6 +13125,15 @@
"node": ">= 18" "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": { "node_modules/run-parallel": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",

View File

@@ -37,3 +37,42 @@ export const DAY_INDEX: Record<Day, number> = {
Friday: 5, Friday: 5,
Saturday: 6, 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<Day, string> = {
Monday: 'Montag',
Tuesday: 'Dienstag',
Wednesday: 'Mittwoch',
Thursday: 'Donnerstag',
Friday: 'Freitag',
Saturday: 'Samstag',
Sunday: 'Sonntag',
};
export const DAY_TO_GERMAN_SHORT: Record<Day, string> = {
Monday: 'Mo',
Tuesday: 'Di',
Wednesday: 'Mi',
Thursday: 'Do',
Friday: 'Fr',
Saturday: 'Sa',
Sunday: 'So',
};
export const MONTH_TO_GERMAN: Record<Month, string> = {
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',
};