diff --git a/CLAUDE.md b/CLAUDE.md index d8ce1b0..42090c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -167,7 +167,7 @@ src/ ├── models/ │ ├── index.ts │ ├── User.ts # User, CreateUserDTO, LoginDTO, AuthResponse -│ ├── CalendarEvent.ts # CalendarEvent, CreateEventDTO, UpdateEventDTO +│ ├── CalendarEvent.ts # CalendarEvent, CreateEventDTO, UpdateEventDTO, ExpandedEvent │ ├── ChatMessage.ts # ChatMessage, Conversation, SendMessageDTO, CreateMessageDTO, │ │ # GetMessagesOptions, ChatResponse, ConversationSummary, │ │ # ProposedEventChange, EventAction @@ -181,6 +181,7 @@ src/ **Key Types:** - `User`: id, email, displayName, passwordHash?, createdAt?, updatedAt? - `CalendarEvent`: id, userId, title, description?, startTime, endTime, note?, isRecurring?, recurrenceRule? +- `ExpandedEvent`: Extends CalendarEvent with occurrenceStart, occurrenceEnd (for recurring event instances) - `ChatMessage`: id, conversationId, sender ('user' | 'assistant'), content, proposedChange? - `ProposedEventChange`: action ('create' | 'update' | 'delete'), eventId?, event?, updates? - `Conversation`: id, userId, createdAt?, updatedAt? (messages loaded separately via lazy loading) @@ -274,6 +275,8 @@ MONGODB_URI=mongodb://root:mongoose@localhost:27017/calchat?authSource=admin - `ChatController`: sendMessage(), confirmEvent(), 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) + - `EventController`: Full CRUD (create, getById, getAll, getByDateRange, update, delete) + - `EventService`: Full CRUD with recurring event expansion via recurrenceExpander - `utils/eventFormatters`: getWeeksOverview(), getMonthOverview() with German localization - `utils/recurrenceExpander`: expandRecurringEvents() using rrule library for RRULE parsing - **Stubbed (TODO):** @@ -283,22 +286,28 @@ MONGODB_URI=mongodb://root:mongoose@localhost:27017/calchat?authSource=admin - `ChatController`: getConversations(), getConversation() - `MongoChatRepository`: Database persistence for chat - **Not started:** - - `EventController`, `EventService` - `ClaudeAdapter` (AI integration - currently using test responses) -**Shared:** Types, DTOs, constants (Day, Month with German translations), and date utilities defined and exported. +**Shared:** Types, DTOs, constants (Day, Month with German translations), ExpandedEvent type, 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) +- Calendar screen fully functional: + - Month navigation with grid display + - Events loaded from API via EventService.getByDateRange() + - Orange dot indicator for days with events + - Tap-to-open modal overlay showing EventCards for selected day + - Supports events from adjacent months visible in grid - Chat screen functional with FlashList, message sending, and event confirm/reject -- `ApiClient`: get(), post() implemented +- `ApiClient`: get(), post(), put(), delete() implemented +- `EventService`: getAll(), getById(), getByDateRange(), create(), update(), delete() - fully implemented - `ChatService`: sendMessage(), confirmEvent(convId, msgId, action, event?, eventId?, updates?), rejectEvent() - supports create/update/delete actions +- `EventCard`: Displays event details (title, date, time, duration, recurring indicator) with Feather icons and edit/delete 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 textPrimary, borderPrimary, eventIndicator +- `EventsStore`: Zustand store with setEvents(), addEvent(), updateEvent(), deleteEvent() - stores ExpandedEvent[] - Auth screens (Login, Register), Event Detail, and Note screens exist as skeletons -- Zustand stores (AuthStore, EventsStore) defined with `throw new Error('Not implemented')` -- Components (EventCard, EventConfirmDialog) exist as skeletons +- AuthStore defined with `throw new Error('Not implemented')` ## Documentation diff --git a/apps/client/src/Themes.tsx b/apps/client/src/Themes.tsx index 3fc16d4..36497a8 100644 --- a/apps/client/src/Themes.tsx +++ b/apps/client/src/Themes.tsx @@ -9,8 +9,11 @@ type Theme = { rejectButton: string; disabledButton: string; buttonText: string; + textPrimary: string; textSecondary: string; textMuted: string; + eventIndicator: string; + borderPrimary: string; }; const defaultLight: Theme = { @@ -24,8 +27,11 @@ const defaultLight: Theme = { rejectButton: "#ef4444", disabledButton: "#ccc", buttonText: "#fff", + textPrimary: "#000000", textSecondary: "#666", textMuted: "#888", + eventIndicator: "#DE6C20", + borderPrimary: "#000000", }; let currentTheme: Theme = defaultLight; diff --git a/apps/client/src/app/(tabs)/calendar.tsx b/apps/client/src/app/(tabs)/calendar.tsx index 45d9574..e84465d 100644 --- a/apps/client/src/app/(tabs)/calendar.tsx +++ b/apps/client/src/app/(tabs)/calendar.tsx @@ -1,16 +1,74 @@ -import { Animated, Modal, Pressable, Text, View } from "react-native"; -import { DAYS, MONTHS, Month } from "@caldav/shared"; +import { + Animated, + Modal, + Pressable, + Text, + View, + ScrollView, + Alert, +} from "react-native"; +import { DAYS, MONTHS, Month, ExpandedEvent } from "@caldav/shared"; import Header from "../../components/Header"; +import { EventCard } from "../../components/EventCard"; import React, { useEffect, useMemo, useRef, useState } from "react"; import currentTheme from "../../Themes"; import BaseBackground from "../../components/BaseBackground"; import { FlashList } from "@shopify/flash-list"; +import { EventService } from "../../services"; +import { useEventsStore } from "../../stores"; // TODO: month selection dropdown menu const Calendar = () => { - const [monthIndex, setMonthIndex] = useState(0); + const [monthIndex, setMonthIndex] = useState(new Date().getMonth()); const [currentYear, setCurrentYear] = useState(new Date().getFullYear()); + const [selectedDate, setSelectedDate] = useState(null); + + const { events, setEvents, deleteEvent } = useEventsStore(); + + // Load events when month/year changes + // Include days from prev/next month that are visible in the grid + useEffect(() => { + const loadEvents = async () => { + try { + // Calculate first visible day (up to 6 days before month start) + const firstOfMonth = new Date(currentYear, monthIndex, 1); + const dayOfWeek = firstOfMonth.getDay(); + const daysFromPrevMonth = dayOfWeek === 0 ? 6 : dayOfWeek - 1; + const startDate = new Date( + currentYear, + monthIndex, + 1 - daysFromPrevMonth, + ); + + // Calculate last visible day (6 weeks * 7 days = 42 days total) + const endDate = new Date(startDate); + endDate.setDate(startDate.getDate() + 41); + endDate.setHours(23, 59, 59); + + const loadedEvents = await EventService.getByDateRange( + startDate, + endDate, + ); + setEvents(loadedEvents); + } catch (error) { + console.error("Failed to load events:", error); + } + }; + loadEvents(); + }, [monthIndex, currentYear, setEvents]); + + // Group events by date (YYYY-MM-DD format) + const eventsByDate = useMemo(() => { + const map = new Map(); + events.forEach((e) => { + const date = new Date(e.occurrenceStart); + const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; + if (!map.has(key)) map.set(key, []); + map.get(key)!.push(e); + }); + return map; + }, [events]); const changeMonth = (delta: number) => { setMonthIndex((prev) => { @@ -27,6 +85,55 @@ const Calendar = () => { }); }; + const handleDayPress = (date: Date, hasEvents: boolean) => { + if (hasEvents) { + setSelectedDate(date); + } + }; + + const handleCloseOverlay = () => { + setSelectedDate(null); + }; + + const handleEditEvent = (event: ExpandedEvent) => { + console.log("Edit event:", event.id); + // TODO: Navigate to event edit screen + }; + + const handleDeleteEvent = async (event: ExpandedEvent) => { + Alert.alert("Event löschen", `"${event.title}" wirklich löschen?`, [ + { text: "Abbrechen", style: "cancel" }, + { + text: "Löschen", + style: "destructive", + onPress: async () => { + try { + await EventService.delete(event.id); + deleteEvent(event.id); + // Close overlay if no more events for this date + if (selectedDate) { + const dateKey = `${selectedDate.getFullYear()}-${String(selectedDate.getMonth() + 1).padStart(2, "0")}-${String(selectedDate.getDate()).padStart(2, "0")}`; + const remainingEvents = eventsByDate.get(dateKey) || []; + if (remainingEvents.length <= 1) { + setSelectedDate(null); + } + } + } catch (error) { + console.error("Failed to delete event:", error); + Alert.alert("Fehler", "Event konnte nicht gelöscht werden"); + } + }, + }, + ]); + }; + + // Get events for selected date + const selectedDateEvents = useMemo(() => { + if (!selectedDate) return []; + const key = `${selectedDate.getFullYear()}-${String(selectedDate.getMonth() + 1).padStart(2, "0")}-${String(selectedDate.getDate()).padStart(2, "0")}`; + return eventsByDate.get(key) || []; + }, [selectedDate, eventsByDate]); + return ( { currentYear={currentYear} /> - + + + {/* Event Overlay Modal */} + ); }; +type EventOverlayProps = { + visible: boolean; + date: Date | null; + events: ExpandedEvent[]; + onClose: () => void; + onEditEvent: (event: ExpandedEvent) => void; + onDeleteEvent: (event: ExpandedEvent) => void; +}; + +const EventOverlay = ({ + visible, + date, + events, + onClose, + onEditEvent, + onDeleteEvent, +}: EventOverlayProps) => { + if (!date) return null; + + const dateString = date.toLocaleDateString("de-DE", { + weekday: "long", + day: "2-digit", + month: "long", + year: "numeric", + }); + + return ( + + + e.stopPropagation()} + > + {/* Header */} + + {dateString} + + {events.length} {events.length === 1 ? "Termin" : "Termine"} + + + + {/* Events List */} + + {events.map((event, index) => ( + onEditEvent(event)} + onDelete={() => onDeleteEvent(event)} + /> + ))} + + + {/* Close button */} + + + Schließen + + + + + + ); +}; + type MonthSelectorProps = { modalVisible: boolean; onClose: () => void; @@ -239,6 +454,8 @@ const WeekDaysLine = () => ( type CalendarGridProps = { month: Month; year: number; + eventsByDate: Map; + onDayPress: (date: Date, hasEvents: boolean) => void; }; const CalendarGrid = (props: CalendarGridProps) => { @@ -256,6 +473,10 @@ const CalendarGrid = (props: CalendarGridProps) => { return date; }; + const getDateKey = (date: Date): string => { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; + }; + return ( { key={i} className="w-full flex-1 flex-row justify-around items-center gap-2" > - {Array.from({ length: 7 }).map((_, j) => ( - - ))} + {Array.from({ length: 7 }).map((_, j) => { + const date = createDateFromOffset(i * 7 + j - dateOffset); + const dateKey = getDateKey(date); + const hasEvents = props.eventsByDate.has(dateKey); + return ( + props.onDayPress(date, hasEvents)} + /> + ); + })} ))} @@ -284,14 +512,17 @@ const CalendarGrid = (props: CalendarGridProps) => { type SingleDayProps = { date: Date; month: Month; + hasEvents: boolean; + onPress: () => void; }; const SingleDay = (props: SingleDayProps) => { const isSameMonth = MONTHS[props.date.getMonth()] === props.month; return ( - { > {props.date.getDate()} - + + {/* Event indicator dot */} + {props.hasEvents && ( + + )} + ); }; diff --git a/apps/client/src/components/EventCard.tsx b/apps/client/src/components/EventCard.tsx index 4e5682f..b6b3683 100644 --- a/apps/client/src/components/EventCard.tsx +++ b/apps/client/src/components/EventCard.tsx @@ -1,23 +1,154 @@ import { View, Text, Pressable } from "react-native"; -import { CalendarEvent } from "@caldav/shared"; +import { ExpandedEvent } from "@caldav/shared"; +import { Feather } from "@expo/vector-icons"; +import currentTheme from "../Themes"; type EventCardProps = { - event: CalendarEvent; - onPress?: (event: CalendarEvent) => void; + event: ExpandedEvent; + onEdit: () => void; + onDelete: () => void; }; -const EventCard = ({ event: _event, onPress: _onPress }: EventCardProps) => { - // TODO: Display event title, time, and description preview - // TODO: Handle onPress to navigate to EventDetailScreen - // TODO: Style based on event type or time of day - throw new Error("Not implemented"); +function formatDate(date: Date): string { + const d = new Date(date); + return d.toLocaleDateString("de-DE", { + weekday: "short", + day: "2-digit", + month: "2-digit", + year: "numeric", + }); +} +function formatTime(date: Date): string { + const d = new Date(date); + return d.toLocaleTimeString("de-DE", { + hour: "2-digit", + minute: "2-digit", + }); +} + +function formatDuration(start: Date, end: Date): string { + const startDate = new Date(start); + const endDate = new Date(end); + const diffMs = endDate.getTime() - startDate.getTime(); + const diffMins = Math.round(diffMs / 60000); + + if (diffMins < 60) { + return `${diffMins} min`; + } + + const hours = Math.floor(diffMins / 60); + const mins = diffMins % 60; + + if (mins === 0) { + return hours === 1 ? "1 Std" : `${hours} Std`; + } + + return `${hours} Std ${mins} min`; +} + +export const EventCard = ({ event, onEdit, onDelete }: EventCardProps) => { return ( - - - EventCard - Not Implemented + + {/* Header with title */} + + {event.title} - + + {/* Content */} + + {/* Date */} + + + + {formatDate(event.occurrenceStart)} + + + + {/* Time with duration */} + + + + {formatTime(event.occurrenceStart)} -{" "} + {formatTime(event.occurrenceEnd)} ( + {formatDuration(event.occurrenceStart, event.occurrenceEnd)}) + + + + {/* Recurring indicator */} + {event.isRecurring && ( + + + + Wiederkehrend + + + )} + + {/* Description */} + {event.description && ( + + {event.description} + + )} + + {/* Action buttons */} + + + + + + + + + + ); }; diff --git a/apps/client/src/services/EventService.ts b/apps/client/src/services/EventService.ts index 3b14717..359eab2 100644 --- a/apps/client/src/services/EventService.ts +++ b/apps/client/src/services/EventService.ts @@ -1,33 +1,35 @@ -import { CalendarEvent, CreateEventDTO, UpdateEventDTO } from "@caldav/shared"; +import { + CalendarEvent, + CreateEventDTO, + UpdateEventDTO, + ExpandedEvent, +} from "@caldav/shared"; +import { ApiClient } from "./ApiClient"; export const EventService = { getAll: async (): Promise => { - throw new Error("Not implemented"); + return ApiClient.get("/events"); }, - getById: async (_id: string): Promise => { - throw new Error("Not implemented"); + getById: async (id: string): Promise => { + return ApiClient.get(`/events/${id}`); }, - getByDateRange: async ( - _start: Date, - _end: Date, - ): Promise => { - throw new Error("Not implemented"); + getByDateRange: async (start: Date, end: Date): Promise => { + return ApiClient.get( + `/events/range?start=${start.toISOString()}&end=${end.toISOString()}`, + ); }, - create: async (_data: CreateEventDTO): Promise => { - throw new Error("Not implemented"); + create: async (data: CreateEventDTO): Promise => { + return ApiClient.post("/events", data); }, - update: async ( - _id: string, - _data: UpdateEventDTO, - ): Promise => { - throw new Error("Not implemented"); + update: async (id: string, data: UpdateEventDTO): Promise => { + return ApiClient.put(`/events/${id}`, data); }, - delete: async (_id: string): Promise => { - throw new Error("Not implemented"); + delete: async (id: string): Promise => { + return ApiClient.delete(`/events/${id}`); }, }; diff --git a/apps/client/src/stores/EventsStore.ts b/apps/client/src/stores/EventsStore.ts index b14f5b8..82a8c60 100644 --- a/apps/client/src/stores/EventsStore.ts +++ b/apps/client/src/stores/EventsStore.ts @@ -1,26 +1,30 @@ import { create } from "zustand"; -import { CalendarEvent } from "@caldav/shared"; +import { ExpandedEvent } from "@caldav/shared"; interface EventsState { - events: CalendarEvent[]; - setEvents: (events: CalendarEvent[]) => void; - addEvent: (event: CalendarEvent) => void; - updateEvent: (id: string, event: Partial) => void; + events: ExpandedEvent[]; + setEvents: (events: ExpandedEvent[]) => void; + addEvent: (event: ExpandedEvent) => void; + updateEvent: (id: string, event: Partial) => void; deleteEvent: (id: string) => void; } export const useEventsStore = create((set) => ({ events: [], - setEvents: (_events: CalendarEvent[]) => { - throw new Error("Not implemented"); + setEvents: (events: ExpandedEvent[]) => { + set({ events }); }, - addEvent: (_event: CalendarEvent) => { - throw new Error("Not implemented"); + addEvent: (event: ExpandedEvent) => { + set((state) => ({ events: [...state.events, event] })); }, - updateEvent: (_id: string, _event: Partial) => { - throw new Error("Not implemented"); + updateEvent: (id: string, updates: Partial) => { + set((state) => ({ + events: state.events.map((e) => (e.id === id ? { ...e, ...updates } : e)), + })); }, - deleteEvent: (_id: string) => { - throw new Error("Not implemented"); + deleteEvent: (id: string) => { + set((state) => ({ + events: state.events.filter((e) => e.id !== id), + })); }, })); diff --git a/apps/server/src/controllers/EventController.ts b/apps/server/src/controllers/EventController.ts index 0297d40..7e231f0 100644 --- a/apps/server/src/controllers/EventController.ts +++ b/apps/server/src/controllers/EventController.ts @@ -6,29 +6,106 @@ export class EventController { constructor(private eventService: EventService) {} async create(req: AuthenticatedRequest, res: Response): Promise { - throw new Error("Not implemented"); + try { + const event = await this.eventService.create(req.user!.userId, req.body); + res.status(201).json(event); + } catch (error) { + console.error("Error creating event:", error); + res.status(500).json({ error: "Failed to create event" }); + } } async getById(req: AuthenticatedRequest, res: Response): Promise { - throw new Error("Not implemented"); + try { + const event = await this.eventService.getById( + req.params.id, + req.user!.userId, + ); + if (!event) { + res.status(404).json({ error: "Event not found" }); + return; + } + res.json(event); + } catch (error) { + console.error("Error getting event:", error); + res.status(500).json({ error: "Failed to get event" }); + } } async getAll(req: AuthenticatedRequest, res: Response): Promise { - throw new Error("Not implemented"); + try { + const events = await this.eventService.getAll(req.user!.userId); + res.json(events); + } catch (error) { + console.error("Error getting events:", error); + res.status(500).json({ error: "Failed to get events" }); + } } async getByDateRange( req: AuthenticatedRequest, res: Response, ): Promise { - throw new Error("Not implemented"); + try { + const { start, end } = req.query; + + if (!start || !end) { + res.status(400).json({ error: "start and end query params required" }); + return; + } + + const startDate = new Date(start as string); + const endDate = new Date(end as string); + + if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { + res.status(400).json({ error: "Invalid date format" }); + return; + } + + const events = await this.eventService.getByDateRange( + req.user!.userId, + startDate, + endDate, + ); + res.json(events); + } catch (error) { + console.error("Error getting events by range:", error); + res.status(500).json({ error: "Failed to get events" }); + } } async update(req: AuthenticatedRequest, res: Response): Promise { - throw new Error("Not implemented"); + try { + const event = await this.eventService.update( + req.params.id, + req.user!.userId, + req.body, + ); + if (!event) { + res.status(404).json({ error: "Event not found" }); + return; + } + res.json(event); + } catch (error) { + console.error("Error updating event:", error); + res.status(500).json({ error: "Failed to update event" }); + } } async delete(req: AuthenticatedRequest, res: Response): Promise { - throw new Error("Not implemented"); + try { + const deleted = await this.eventService.delete( + req.params.id, + req.user!.userId, + ); + if (!deleted) { + res.status(404).json({ error: "Event not found" }); + return; + } + res.status(204).send(); + } catch (error) { + console.error("Error deleting event:", error); + res.status(500).json({ error: "Failed to delete event" }); + } } } diff --git a/apps/server/src/services/ChatService.ts b/apps/server/src/services/ChatService.ts index 9b77a4d..772e6b9 100644 --- a/apps/server/src/services/ChatService.ts +++ b/apps/server/src/services/ChatService.ts @@ -16,7 +16,7 @@ import { getWeeksOverview, getMonthOverview } from "../utils/eventFormatters"; type TestResponse = { content: string; proposedChange?: ProposedEventChange }; // Test response index (cycles through responses) -let responseIndex = 8; +let responseIndex = 0; // Static test responses (event proposals) const staticResponses: TestResponse[] = [ @@ -109,7 +109,7 @@ const staticResponses: TestResponse[] = [ '• "Verschiebe das Meeting auf Donnerstag"\n\n' + "Wie kann ich dir helfen?", }, - // Response 9: Phone call - short appointment + // Response 9: Phone call - short appointment (Wednesday, so +2 days = Friday) { content: "Alles klar! Ich habe das Telefonat mit deiner Mutter eingetragen:", @@ -117,12 +117,12 @@ const staticResponses: TestResponse[] = [ action: "create", event: { title: "Telefonat mit Mama", - startTime: getDay("Sunday", 0, 11, 0), - endTime: getDay("Sunday", 0, 11, 30), + startTime: getDay("Wednesday", 1, 11, 0), + endTime: getDay("Wednesday", 1, 11, 30), }, }, }, - // Response 10: Update "Telefonat mit Mama" +2 days (DYNAMIC - placeholder) + // Response 10: Update "Telefonat mit Mama" +3 days (DYNAMIC - placeholder) { content: "" }, // Response 11: Birthday party - evening event { @@ -137,10 +137,10 @@ const staticResponses: TestResponse[] = [ }, }, }, - // Response 12: Language course - limited to 8 weeks + // Response 12: Language course - limited to 8 weeks (Thu + Sat) { content: - "Dein Spanischkurs ist eingetragen! Er läuft jeden Donnerstag für die nächsten 8 Wochen:", + "Dein Spanischkurs ist eingetragen! Er läuft jeden Donnerstag und Samstag für die nächsten 8 Wochen:", proposedChange: { action: "create", event: { @@ -148,7 +148,7 @@ const staticResponses: TestResponse[] = [ startTime: getDay("Thursday", 1, 19, 0), endTime: getDay("Thursday", 1, 20, 30), isRecurring: true, - recurrenceRule: "FREQ=WEEKLY;BYDAY=TH;COUNT=8", + recurrenceRule: "FREQ=WEEKLY;BYDAY=TH,SA;COUNT=8", }, }, }, @@ -191,17 +191,18 @@ async function getTestResponse( } if (responseIdx === 10) { - // Update "Telefonat mit Mama" +2 days + // 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"); if (mamaEvent) { const newStart = new Date(mamaEvent.startTime); - newStart.setDate(newStart.getDate() + 2); - const newEnd = new Date(mamaEvent.endTime); - newEnd.setDate(newEnd.getDate() + 2); + newStart.setDate(newStart.getDate() + 3); + newStart.setHours(13, 0, 0, 0); + const newEnd = new Date(newStart); + newEnd.setMinutes(30); return { content: - "Alles klar, ich verschiebe das Telefonat mit Mama um 2 Tage nach hinten:", + "Alles klar, ich verschiebe das Telefonat mit Mama auf Samstag um 13:00 Uhr:", proposedChange: { action: "update", eventId: mamaEvent.id, diff --git a/apps/server/src/services/EventService.ts b/apps/server/src/services/EventService.ts index 90466b8..6c808ed 100644 --- a/apps/server/src/services/EventService.ts +++ b/apps/server/src/services/EventService.ts @@ -1,27 +1,51 @@ -import { CalendarEvent, CreateEventDTO, UpdateEventDTO } from "@caldav/shared"; +import { + CalendarEvent, + CreateEventDTO, + UpdateEventDTO, + ExpandedEvent, +} from "@caldav/shared"; import { EventRepository } from "./interfaces"; +import { expandRecurringEvents } from "../utils/recurrenceExpander"; export class EventService { constructor(private eventRepo: EventRepository) {} async create(userId: string, data: CreateEventDTO): Promise { - throw new Error("Not implemented"); + return this.eventRepo.create(userId, data); } async getById(id: string, userId: string): Promise { - throw new Error("Not implemented"); + const event = await this.eventRepo.findById(id); + if (!event || event.userId !== userId) { + return null; + } + return event; } async getAll(userId: string): Promise { - throw new Error("Not implemented"); + return this.eventRepo.findByUserId(userId); } async getByDateRange( userId: string, startDate: Date, endDate: Date, - ): Promise { - throw new Error("Not implemented"); + ): Promise { + // Get all events for the user + const allEvents = await this.eventRepo.findByUserId(userId); + + // Separate recurring and non-recurring events + const recurringEvents = allEvents.filter((e) => e.isRecurring); + const nonRecurringEvents = allEvents.filter((e) => !e.isRecurring); + + // Expand all events (recurring get multiple instances, non-recurring stay as-is) + const expanded = expandRecurringEvents( + [...nonRecurringEvents, ...recurringEvents], + startDate, + endDate, + ); + + return expanded; } async update( @@ -29,10 +53,18 @@ export class EventService { userId: string, data: UpdateEventDTO, ): Promise { - throw new Error("Not implemented"); + const event = await this.eventRepo.findById(id); + if (!event || event.userId !== userId) { + return null; + } + return this.eventRepo.update(id, data); } async delete(id: string, userId: string): Promise { - throw new Error("Not implemented"); + const event = await this.eventRepo.findById(id); + if (!event || event.userId !== userId) { + return false; + } + return this.eventRepo.delete(id); } } diff --git a/apps/server/src/utils/eventFormatters.ts b/apps/server/src/utils/eventFormatters.ts index 105070a..a2a022d 100644 --- a/apps/server/src/utils/eventFormatters.ts +++ b/apps/server/src/utils/eventFormatters.ts @@ -4,9 +4,10 @@ import { DAY_TO_GERMAN, DAY_TO_GERMAN_SHORT, MONTH_TO_GERMAN, + ExpandedEvent, } from "@caldav/shared"; import { EventRepository } from "../services/interfaces"; -import { expandRecurringEvents, ExpandedEvent } from "./recurrenceExpander"; +import { expandRecurringEvents } from "./recurrenceExpander"; // Private formatting helpers diff --git a/apps/server/src/utils/recurrenceExpander.ts b/apps/server/src/utils/recurrenceExpander.ts index a5e9cdd..aa4f312 100644 --- a/apps/server/src/utils/recurrenceExpander.ts +++ b/apps/server/src/utils/recurrenceExpander.ts @@ -1,10 +1,5 @@ import { RRule, rrulestr } from "rrule"; -import { CalendarEvent } from "@caldav/shared"; - -export interface ExpandedEvent extends CalendarEvent { - occurrenceStart: Date; - occurrenceEnd: Date; -} +import { CalendarEvent, ExpandedEvent } from "@caldav/shared"; // Convert local time to "fake UTC" for rrule // rrule interprets all dates as UTC internally, so we need to trick it diff --git a/packages/shared/src/models/CalendarEvent.ts b/packages/shared/src/models/CalendarEvent.ts index 4e59e3d..269e5a1 100644 --- a/packages/shared/src/models/CalendarEvent.ts +++ b/packages/shared/src/models/CalendarEvent.ts @@ -31,3 +31,8 @@ export interface UpdateEventDTO { isRecurring?: boolean; recurrenceRule?: string; } + +export interface ExpandedEvent extends CalendarEvent { + occurrenceStart: Date; + occurrenceEnd: Date; +}