diff --git a/CLAUDE.md b/CLAUDE.md index f8e2bb6..ef2724c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -334,7 +334,7 @@ src/ ├── index.ts ├── 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 + └── rruleHelpers.ts # parseRRule(), buildRRule(), formatRecurrenceRule() - RRULE parsing, building, and German formatting ``` **Key Types:** @@ -408,6 +408,10 @@ CalDAV sync with external calendar servers (e.g., Radicale) using `tsdav` and `i - **Calendar timer** (`calendar.tsx`): Every 10s while Calendar tab is focused, via `setInterval` in `useFocusEffect` - **Sync button** (`settings.tsx`): Manual trigger in CaldavSettings +**Lazy sync (server-side in ChatService):** +- AI data access callbacks (`fetchEventsInRange`, `searchEvents`, `fetchEventById`) trigger `syncOnce()` before the first DB query +- Uses `CaldavService.sync()` which checks config internally (silent no-op without config) + **Single-event sync (server-side in controllers):** - `EventController`: `pushToCaldav()` after create/update, `deleteFromCaldav()` after delete - `ChatController`: `pushAll()` after confirming an event proposal @@ -419,7 +423,7 @@ CalDAV sync with external calendar servers (e.g., Radicale) using `tsdav` and `i **Architecture:** - `CaldavService` depends on `CaldavRepository` (config storage) and `EventService` (event CRUD) -- `ChatService` depends only on `EventService` (not EventRepository) for all event operations +- `ChatService` depends on `EventService` and `CaldavService` (lazy CalDAV sync on AI data access) - `EventController` and `ChatController` both receive `CaldavService` for CalDAV push on mutations ### Database Abstraction @@ -568,12 +572,12 @@ NODE_ENV=development # development = pretty logs, production = JSON - `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 - - `CaldavService`: Full CalDAV sync (connect, pullEvents, pushEvent, pushAll, deleteEvent, getConfig, saveConfig, deleteConfig) + - `CaldavService`: Full CalDAV sync (connect, pullEvents, pushEvent, pushAll, deleteEvent, sync, getConfig, saveConfig, deleteConfig). `sync()` checks config internally and is a silent no-op without config. - `CaldavController`: REST endpoints for config CRUD, pull, push - `MongoCaldavRepository`: Config persistence with createOrUpdate, findByUserId, deleteByUserId - `EventController`: CalDAV push on create/update, CalDAV delete on delete (via pushToCaldav/deleteFromCaldav helpers) - `ChatController`: CalDAV pushAll after confirmEvent (ensures chat-created events sync) - - `ChatService`: Refactored to use only EventService (no direct EventRepository dependency) + - `ChatService`: Uses EventService + CaldavService (lazy sync on AI data access via syncOnce pattern) - `EventService`: Extended with searchByTitle(), findByCaldavUUID() - `utils/eventFormatters`: Refactored to use EventService instead of EventRepository - CORS configured to allow X-User-Id header @@ -584,7 +588,7 @@ NODE_ENV=development # development = pretty logs, production = JSON **Shared:** - Types, DTOs, constants (Day, Month with German translations), ExpandedEvent type, CaldavConfig, CaldavSyncStatus defined and exported -- `rruleHelpers.ts`: `parseRRule()` parses RRULE strings using rrule library, returns `ParsedRRule` with freq, until, count, interval, byDay +- `rruleHelpers.ts`: `parseRRule()` parses RRULE strings using rrule library, returns `ParsedRRule` with freq, until, count, interval, byDay. `buildRRule()` builds RRULE from RepeatType + interval. `formatRecurrenceRule()` formats RRULE into German description (e.g., "Jede Woche", "Alle 2 Monate"). Exports `REPEAT_TYPE_LABELS` and `RepeatType`. - `formatters.ts`: German date/time formatters (`formatDate`, `formatTime`, `formatDateTime`, `formatDateWithWeekday`, `formatDateKey`) used by both client and server - rrule library added as dependency for RRULE parsing @@ -634,7 +638,7 @@ NODE_ENV=development # development = pretty logs, production = JSON - `CustomTextInput`: Themed text input component with focus border highlight, supports controlled value via `text` prop - `CardBase`: Reusable card component with header (title/subtitle), content area, and optional footer button - configurable padding, border, text size via props, ScrollView uses `nestedScrollEnabled` for Android - `ModalBase`: Reusable modal wrapper with backdrop (absolute-positioned behind card), uses CardBase internally - provides click-outside-to-close, Android back button support, and proper scrolling on Android -- `EventCardBase`: Event card with date/time/recurring icons - uses CardBase for structure +- `EventCardBase`: Event card with date/time/recurring icons - uses CardBase for structure. Accepts `recurrenceRule` string (not boolean) and displays German-formatted recurrence via `formatRecurrenceRule()` - `EventCard`: Uses EventCardBase + edit/delete buttons (TouchableOpacity with delayPressIn for scroll-friendly touch handling) - `ProposedEventCard`: Uses EventCardBase + confirm/reject/edit buttons for chat proposals, displays green highlighted text for new changes ("Neue Ausnahme: [date]" for single delete, "Neues Ende: [date]" for UNTIL updates), shows yellow conflict warnings when proposed time overlaps with existing events. Edit button allows modifying proposals before confirming. - `DeleteEventModal`: Delete confirmation modal using ModalBase - shows three options for recurring events (single/future/all), simple confirm for non-recurring diff --git a/apps/client/src/app/(tabs)/calendar.tsx b/apps/client/src/app/(tabs)/calendar.tsx index 0ac2258..1f00961 100644 --- a/apps/client/src/app/(tabs)/calendar.tsx +++ b/apps/client/src/app/(tabs)/calendar.tsx @@ -122,17 +122,22 @@ const Calendar = () => { // Load events when tab gains focus or month/year changes // NOTE: Wrapper needed because loadEvents is async (returns Promise) // and useFocusEffect expects a sync function (optionally returning cleanup) - // Also re-open overlay if selectedDate exists (for back navigation from editEvent) useFocusEffect( useCallback(() => { loadEvents(); - if (selectedDate) { - setOverlayVisible(true); - } const interval = setInterval(loadEvents, 10_000); return () => clearInterval(interval); - }, [loadEvents, selectedDate]), + }, [loadEvents]), + ); + + // Re-open overlay after back navigation from editEvent + useFocusEffect( + useCallback(() => { + if (selectedDate) { + setOverlayVisible(true); + } + }, [selectedDate]), ); // Group events by date (YYYY-MM-DD format) diff --git a/apps/client/src/app/editEvent.tsx b/apps/client/src/app/editEvent.tsx index 6aefe2c..8505ee4 100644 --- a/apps/client/src/app/editEvent.tsx +++ b/apps/client/src/app/editEvent.tsx @@ -19,7 +19,7 @@ import { Ionicons } from "@expo/vector-icons"; import { ScrollableDropdown } from "../components/ScrollableDropdown"; import { useDropdownPosition } from "../hooks/useDropdownPosition"; import { EventService, ChatService } from "../services"; -import { buildRRule, CreateEventDTO } from "@calchat/shared"; +import { buildRRule, CreateEventDTO, REPEAT_TYPE_LABELS, RepeatType } from "@calchat/shared"; import { useChatStore } from "../stores"; import CustomTextInput, { CustomTextInputProps, @@ -86,15 +86,6 @@ const PickerRow = ({ ); }; -type RepeatType = "Tag" | "Woche" | "Monat" | "Jahr"; - -const REPEAT_TYPE_LABELS: Record = { - Tag: "Tage", - Woche: "Wochen", - Monat: "Monate", - Jahr: "Jahre", -}; - type RepeatPressableProps = { focused: boolean; repeatType: RepeatType; diff --git a/apps/client/src/components/EventCard.tsx b/apps/client/src/components/EventCard.tsx index b252f7b..5005559 100644 --- a/apps/client/src/components/EventCard.tsx +++ b/apps/client/src/components/EventCard.tsx @@ -19,7 +19,7 @@ export const EventCard = ({ event, onEdit, onDelete }: EventCardProps) => { startTime={event.occurrenceStart} endTime={event.occurrenceEnd} description={event.description} - isRecurring={!!event.recurrenceRule} + recurrenceRule={event.recurrenceRule} > {/* Action buttons - TouchableOpacity with delayPressIn allows ScrollView to detect scroll gestures */} diff --git a/apps/client/src/components/EventCardBase.tsx b/apps/client/src/components/EventCardBase.tsx index 8f731de..6020512 100644 --- a/apps/client/src/components/EventCardBase.tsx +++ b/apps/client/src/components/EventCardBase.tsx @@ -8,6 +8,7 @@ import { formatDateWithWeekday, formatDateWithWeekdayShort, formatTime, + formatRecurrenceRule, } from "@calchat/shared"; type EventCardBaseProps = { @@ -16,7 +17,7 @@ type EventCardBaseProps = { startTime: Date; endTime: Date; description?: string; - isRecurring?: boolean; + recurrenceRule?: string; children?: ReactNode; }; @@ -46,7 +47,7 @@ export const EventCardBase = ({ startTime, endTime, description, - isRecurring, + recurrenceRule, children, }: EventCardBaseProps) => { const { theme } = useThemeStore(); @@ -95,7 +96,7 @@ export const EventCardBase = ({ {/* Recurring indicator */} - {isRecurring && ( + {recurrenceRule && ( - Wiederkehrend + + {formatRecurrenceRule(recurrenceRule)} + )} diff --git a/apps/client/src/components/ProposedEventCard.tsx b/apps/client/src/components/ProposedEventCard.tsx index 4d50f5f..d2f9694 100644 --- a/apps/client/src/components/ProposedEventCard.tsx +++ b/apps/client/src/components/ProposedEventCard.tsx @@ -113,7 +113,7 @@ export const ProposedEventCard = ({ startTime={event.startTime} endTime={event.endTime} description={event.description} - isRecurring={!!event.recurrenceRule} + recurrenceRule={event.recurrenceRule} > {/* Show new exception date for delete/single actions */} {newExceptionDate && ( diff --git a/apps/server/src/ai/utils/toolExecutor.ts b/apps/server/src/ai/utils/toolExecutor.ts index 7df219c..bea6383 100644 --- a/apps/server/src/ai/utils/toolExecutor.ts +++ b/apps/server/src/ai/utils/toolExecutor.ts @@ -234,7 +234,7 @@ export async function executeToolCall( const eventsText = events .map((e) => { - const start = new Date(e.startTime); + const start = new Date(e.occurrenceStart); const recurrenceInfo = e.recurrenceRule ? " (wiederkehrend)" : ""; return `- ${e.title} (ID: ${e.id}) am ${formatDate(start)} um ${formatTime(start)} Uhr${recurrenceInfo}`; }) diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 59df394..4d5c62e 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -62,12 +62,13 @@ const aiProvider = new GPTAdapter(); // Initialize services const authService = new AuthService(userRepo); const eventService = new EventService(eventRepo); +const caldavService = new CaldavService(caldavRepo, eventService); const chatService = new ChatService( chatRepo, eventService, aiProvider, + caldavService, ); -const caldavService = new CaldavService(caldavRepo, eventService); // Initialize controllers const authController = new AuthController(authService); diff --git a/apps/server/src/services/CaldavService.ts b/apps/server/src/services/CaldavService.ts index c5128a1..4e153cf 100644 --- a/apps/server/src/services/CaldavService.ts +++ b/apps/server/src/services/CaldavService.ts @@ -257,4 +257,14 @@ export class CaldavService { async deleteConfig(userId: string) { return await this.caldavRepo.deleteByUserId(userId); } + + /** + * Sync with CalDAV server if config exists. Silent no-op if no config. + */ + async sync(userId: string): Promise { + const config = await this.getConfig(userId); + if (!config) return; + await this.pushAll(userId); + await this.pullEvents(userId); + } } diff --git a/apps/server/src/services/ChatService.ts b/apps/server/src/services/ChatService.ts index 04991a4..63f135b 100644 --- a/apps/server/src/services/ChatService.ts +++ b/apps/server/src/services/ChatService.ts @@ -14,6 +14,7 @@ import { } from "@calchat/shared"; import { ChatRepository, AIProvider } from "./interfaces"; import { EventService } from "./EventService"; +import { CaldavService } from "./CaldavService"; import { getWeeksOverview, getMonthOverview } from "../utils/eventFormatters"; type TestResponse = { @@ -543,6 +544,7 @@ export class ChatService { private chatRepo: ChatRepository, private eventService: EventService, private aiProvider: AIProvider, + private caldavService: CaldavService, ) {} async processMessage( @@ -573,17 +575,32 @@ export class ChatService { limit: 20, }); + // Lazy CalDAV sync: only sync once when the AI first accesses event data + let hasSynced = false; + const syncOnce = async () => { + if (hasSynced) return; + hasSynced = true; + try { + await this.caldavService.sync(userId); + } catch { + // CalDAV sync is not critical for AI responses + } + }; + response = await this.aiProvider.processMessage(data.content, { userId, conversationHistory: history, currentDate: new Date(), fetchEventsInRange: async (start, end) => { + await syncOnce(); return this.eventService.getByDateRange(userId, start, end); }, searchEvents: async (query) => { + await syncOnce(); return this.eventService.searchByTitle(userId, query); }, fetchEventById: async (eventId) => { + await syncOnce(); return this.eventService.getById(eventId, userId); }, }); diff --git a/packages/shared/src/utils/rruleHelpers.ts b/packages/shared/src/utils/rruleHelpers.ts index 51717c3..0293ade 100644 --- a/packages/shared/src/utils/rruleHelpers.ts +++ b/packages/shared/src/utils/rruleHelpers.ts @@ -11,6 +11,24 @@ const REPEAT_TYPE_TO_FREQ: Record = { Jahr: "YEARLY", }; +const FREQ_TO_REPEAT_TYPE: Record = Object.fromEntries( + Object.entries(REPEAT_TYPE_TO_FREQ).map(([k, v]) => [v, k as RepeatType]), +); + +export const REPEAT_TYPE_LABELS: Record = { + Tag: "Tage", + Woche: "Wochen", + Monat: "Monate", + Jahr: "Jahre", +}; + +const REPEAT_TYPE_SINGULAR: Record = { + Tag: "Jeden Tag", + Woche: "Jede Woche", + Monat: "Jeden Monat", + Jahr: "Jedes Jahr", +}; + /** * Build an RRULE string from repeat count and type. * @@ -27,3 +45,30 @@ export function buildRRule(repeatType: RepeatType, interval: number = 1): string return `FREQ=${freq};INTERVAL=${interval}`; } + +/** + * Format an RRULE string into a human-readable German description. + * + * @param rrule - RRULE string like "FREQ=WEEKLY;INTERVAL=2" + * @returns German description like "Alle 2 Wochen" or "Wöchentlich" + */ +export function formatRecurrenceRule(rrule: string): string { + const rule = rrule.replace(/^RRULE:/, ""); + const parts = Object.fromEntries( + rule.split(";").map((p) => p.split("=") as [string, string]), + ); + + const freq = parts.FREQ; + const interval = parts.INTERVAL ? parseInt(parts.INTERVAL, 10) : 1; + const repeatType = FREQ_TO_REPEAT_TYPE[freq]; + + if (!repeatType) { + return "Wiederkehrend"; + } + + if (interval <= 1) { + return REPEAT_TYPE_SINGULAR[repeatType]; + } + + return `Alle ${interval} ${REPEAT_TYPE_LABELS[repeatType]}`; +}