implement calendar event display with day indicators and overlay

- Add ExpandedEvent type to shared package for recurring event instances
- Implement EventController and EventService with full CRUD operations
- Server-side recurring event expansion via recurrenceExpander
- Calendar grid shows orange dot indicator for days with events
- Tap on day opens modal overlay with EventCards
- EventCard component with Feather icons (calendar, clock, repeat, edit, trash)
- EventsStore with Zustand for client-side event state management
- Load events for visible grid range including adjacent month days
- Add textPrimary, borderPrimary, eventIndicator to theme
- Update test responses for multiple events on Saturdays
This commit is contained in:
2026-01-04 17:19:58 +01:00
parent e3f7a778c7
commit 1532acab78
12 changed files with 601 additions and 99 deletions

View File

@@ -167,7 +167,7 @@ src/
├── models/ ├── models/
│ ├── index.ts │ ├── index.ts
│ ├── User.ts # User, CreateUserDTO, LoginDTO, AuthResponse │ ├── User.ts # User, CreateUserDTO, LoginDTO, AuthResponse
│ ├── CalendarEvent.ts # CalendarEvent, CreateEventDTO, UpdateEventDTO │ ├── CalendarEvent.ts # CalendarEvent, CreateEventDTO, UpdateEventDTO, ExpandedEvent
│ ├── ChatMessage.ts # ChatMessage, Conversation, SendMessageDTO, CreateMessageDTO, │ ├── ChatMessage.ts # ChatMessage, Conversation, SendMessageDTO, CreateMessageDTO,
│ │ # GetMessagesOptions, ChatResponse, ConversationSummary, │ │ # GetMessagesOptions, ChatResponse, ConversationSummary,
│ │ # ProposedEventChange, EventAction │ │ # ProposedEventChange, EventAction
@@ -181,6 +181,7 @@ src/
**Key Types:** **Key Types:**
- `User`: id, email, displayName, passwordHash?, createdAt?, updatedAt? - `User`: id, email, displayName, passwordHash?, createdAt?, updatedAt?
- `CalendarEvent`: id, userId, title, description?, startTime, endTime, note?, isRecurring?, recurrenceRule? - `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? - `ChatMessage`: id, conversationId, sender ('user' | 'assistant'), content, proposedChange?
- `ProposedEventChange`: action ('create' | 'update' | 'delete'), eventId?, event?, updates? - `ProposedEventChange`: action ('create' | 'update' | 'delete'), eventId?, event?, updates?
- `Conversation`: id, userId, createdAt?, updatedAt? (messages loaded separately via lazy loading) - `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() - `ChatController`: sendMessage(), confirmEvent(), rejectEvent()
- `ChatService`: processMessage() with test responses (create, update, delete actions), confirmEvent() handles all CRUD actions - `ChatService`: processMessage() with test responses (create, update, delete actions), confirmEvent() handles all CRUD actions
- `MongoEventRepository`: Full CRUD implemented (findById, findByUserId, findByDateRange, create, update, delete) - `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/eventFormatters`: getWeeksOverview(), getMonthOverview() with German localization
- `utils/recurrenceExpander`: expandRecurringEvents() using rrule library for RRULE parsing - `utils/recurrenceExpander`: expandRecurringEvents() using rrule library for RRULE parsing
- **Stubbed (TODO):** - **Stubbed (TODO):**
@@ -283,22 +286,28 @@ MONGODB_URI=mongodb://root:mongoose@localhost:27017/calchat?authSource=admin
- `ChatController`: getConversations(), getConversation() - `ChatController`: getConversations(), getConversation()
- `MongoChatRepository`: Database persistence for chat - `MongoChatRepository`: Database persistence for chat
- **Not started:** - **Not started:**
- `EventController`, `EventService`
- `ClaudeAdapter` (AI integration - currently using test responses) - `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:** **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 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 - 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 - `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 - `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 - Auth screens (Login, Register), Event Detail, and Note screens exist as skeletons
- Zustand stores (AuthStore, EventsStore) defined with `throw new Error('Not implemented')` - AuthStore defined with `throw new Error('Not implemented')`
- Components (EventCard, EventConfirmDialog) exist as skeletons
## Documentation ## Documentation

View File

@@ -9,8 +9,11 @@ type Theme = {
rejectButton: string; rejectButton: string;
disabledButton: string; disabledButton: string;
buttonText: string; buttonText: string;
textPrimary: string;
textSecondary: string; textSecondary: string;
textMuted: string; textMuted: string;
eventIndicator: string;
borderPrimary: string;
}; };
const defaultLight: Theme = { const defaultLight: Theme = {
@@ -24,8 +27,11 @@ const defaultLight: Theme = {
rejectButton: "#ef4444", rejectButton: "#ef4444",
disabledButton: "#ccc", disabledButton: "#ccc",
buttonText: "#fff", buttonText: "#fff",
textPrimary: "#000000",
textSecondary: "#666", textSecondary: "#666",
textMuted: "#888", textMuted: "#888",
eventIndicator: "#DE6C20",
borderPrimary: "#000000",
}; };
let currentTheme: Theme = defaultLight; let currentTheme: Theme = defaultLight;

View File

@@ -1,16 +1,74 @@
import { Animated, Modal, Pressable, Text, View } from "react-native"; import {
import { DAYS, MONTHS, Month } from "@caldav/shared"; Animated,
Modal,
Pressable,
Text,
View,
ScrollView,
Alert,
} from "react-native";
import { DAYS, MONTHS, Month, ExpandedEvent } from "@caldav/shared";
import Header from "../../components/Header"; import Header from "../../components/Header";
import { EventCard } from "../../components/EventCard";
import React, { useEffect, useMemo, useRef, useState } from "react"; import React, { useEffect, useMemo, useRef, useState } from "react";
import currentTheme from "../../Themes"; import currentTheme from "../../Themes";
import BaseBackground from "../../components/BaseBackground"; import BaseBackground from "../../components/BaseBackground";
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import { EventService } from "../../services";
import { useEventsStore } from "../../stores";
// TODO: month selection dropdown menu // TODO: month selection dropdown menu
const Calendar = () => { const Calendar = () => {
const [monthIndex, setMonthIndex] = useState(0); const [monthIndex, setMonthIndex] = useState(new Date().getMonth());
const [currentYear, setCurrentYear] = useState(new Date().getFullYear()); const [currentYear, setCurrentYear] = useState(new Date().getFullYear());
const [selectedDate, setSelectedDate] = useState<Date | null>(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<string, ExpandedEvent[]>();
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) => { const changeMonth = (delta: number) => {
setMonthIndex((prev) => { 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 ( return (
<BaseBackground> <BaseBackground>
<CalendarHeader <CalendarHeader
@@ -35,11 +142,119 @@ const Calendar = () => {
currentYear={currentYear} currentYear={currentYear}
/> />
<WeekDaysLine /> <WeekDaysLine />
<CalendarGrid month={MONTHS[monthIndex]} year={currentYear} /> <CalendarGrid
month={MONTHS[monthIndex]}
year={currentYear}
eventsByDate={eventsByDate}
onDayPress={handleDayPress}
/>
{/* Event Overlay Modal */}
<EventOverlay
visible={selectedDate !== null}
date={selectedDate}
events={selectedDateEvents}
onClose={handleCloseOverlay}
onEditEvent={handleEditEvent}
onDeleteEvent={handleDeleteEvent}
/>
</BaseBackground> </BaseBackground>
); );
}; };
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 (
<Modal
visible={visible}
transparent={true}
animationType="fade"
onRequestClose={onClose}
>
<Pressable
className="flex-1 justify-center items-center"
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
onPress={onClose}
>
<Pressable
className="w-11/12 max-h-3/4 rounded-2xl overflow-hidden"
style={{
backgroundColor: currentTheme.primeBg,
borderWidth: 4,
borderColor: currentTheme.borderPrimary,
}}
onPress={(e) => e.stopPropagation()}
>
{/* Header */}
<View
className="px-4 py-3"
style={{
backgroundColor: currentTheme.chatBot,
borderBottomWidth: 3,
borderBottomColor: currentTheme.borderPrimary,
}}
>
<Text className="font-bold text-lg">{dateString}</Text>
<Text>
{events.length} {events.length === 1 ? "Termin" : "Termine"}
</Text>
</View>
{/* Events List */}
<ScrollView className="p-4" style={{ maxHeight: 400 }}>
{events.map((event, index) => (
<EventCard
key={`${event.id}-${index}`}
event={event}
onEdit={() => onEditEvent(event)}
onDelete={() => onDeleteEvent(event)}
/>
))}
</ScrollView>
{/* Close button */}
<Pressable
onPress={onClose}
className="py-3 items-center"
style={{
borderTopWidth: 1,
borderTopColor: currentTheme.placeholderBg,
}}
>
<Text style={{ color: currentTheme.primeFg }} className="font-bold">
Schließen
</Text>
</Pressable>
</Pressable>
</Pressable>
</Modal>
);
};
type MonthSelectorProps = { type MonthSelectorProps = {
modalVisible: boolean; modalVisible: boolean;
onClose: () => void; onClose: () => void;
@@ -239,6 +454,8 @@ const WeekDaysLine = () => (
type CalendarGridProps = { type CalendarGridProps = {
month: Month; month: Month;
year: number; year: number;
eventsByDate: Map<string, ExpandedEvent[]>;
onDayPress: (date: Date, hasEvents: boolean) => void;
}; };
const CalendarGrid = (props: CalendarGridProps) => { const CalendarGrid = (props: CalendarGridProps) => {
@@ -256,6 +473,10 @@ const CalendarGrid = (props: CalendarGridProps) => {
return date; return date;
}; };
const getDateKey = (date: Date): string => {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
};
return ( return (
<View <View
className="h-full flex-1 flex-col flex-wrap gap-2 p-2" className="h-full flex-1 flex-col flex-wrap gap-2 p-2"
@@ -268,13 +489,20 @@ const CalendarGrid = (props: CalendarGridProps) => {
key={i} key={i}
className="w-full flex-1 flex-row justify-around items-center gap-2" 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 (
<SingleDay <SingleDay
key={j} key={j}
date={createDateFromOffset(i * 7 + j - dateOffset)} date={date}
month={props.month} month={props.month}
hasEvents={hasEvents}
onPress={() => props.onDayPress(date, hasEvents)}
/> />
))} );
})}
</View> </View>
))} ))}
</View> </View>
@@ -284,14 +512,17 @@ const CalendarGrid = (props: CalendarGridProps) => {
type SingleDayProps = { type SingleDayProps = {
date: Date; date: Date;
month: Month; month: Month;
hasEvents: boolean;
onPress: () => void;
}; };
const SingleDay = (props: SingleDayProps) => { const SingleDay = (props: SingleDayProps) => {
const isSameMonth = MONTHS[props.date.getMonth()] === props.month; const isSameMonth = MONTHS[props.date.getMonth()] === props.month;
return ( return (
<View <Pressable
className="h-full flex-1 aspect-auto rounded-xl items-center" onPress={props.onPress}
className="h-full flex-1 aspect-auto rounded-xl items-center justify-between py-1"
style={{ style={{
backgroundColor: currentTheme.primeBg, backgroundColor: currentTheme.primeBg,
}} }}
@@ -301,7 +532,15 @@ const SingleDay = (props: SingleDayProps) => {
> >
{props.date.getDate()} {props.date.getDate()}
</Text> </Text>
</View>
{/* Event indicator dot */}
{props.hasEvents && (
<View
className="w-2 h-2 rounded-full"
style={{ backgroundColor: currentTheme.eventIndicator }}
/>
)}
</Pressable>
); );
}; };

View File

@@ -1,23 +1,154 @@
import { View, Text, Pressable } from "react-native"; 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 = { type EventCardProps = {
event: CalendarEvent; event: ExpandedEvent;
onPress?: (event: CalendarEvent) => void; onEdit: () => void;
onDelete: () => void;
}; };
const EventCard = ({ event: _event, onPress: _onPress }: EventCardProps) => { function formatDate(date: Date): string {
// TODO: Display event title, time, and description preview const d = new Date(date);
// TODO: Handle onPress to navigate to EventDetailScreen return d.toLocaleDateString("de-DE", {
// TODO: Style based on event type or time of day weekday: "short",
throw new Error("Not implemented"); 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 ( return (
<Pressable> <View
<View> className="rounded-xl overflow-hidden mb-3"
<Text>EventCard - Not Implemented</Text> style={{ borderWidth: 2, borderColor: currentTheme.borderPrimary }}
>
{/* Header with title */}
<View
className="px-3 py-2"
style={{
backgroundColor: currentTheme.chatBot,
borderBottomWidth: 2,
borderBottomColor: currentTheme.borderPrimary,
}}
>
<Text className="font-bold text-base">{event.title}</Text>
</View> </View>
{/* Content */}
<View className="px-3 py-2 bg-white">
{/* Date */}
<View className="flex-row items-center mb-1">
<Feather
name="calendar"
size={16}
color={currentTheme.textPrimary}
style={{ marginRight: 8 }}
/>
<Text style={{ color: currentTheme.textPrimary }}>
{formatDate(event.occurrenceStart)}
</Text>
</View>
{/* Time with duration */}
<View className="flex-row items-center mb-1">
<Feather
name="clock"
size={16}
color={currentTheme.textPrimary}
style={{ marginRight: 8 }}
/>
<Text style={{ color: currentTheme.textPrimary }}>
{formatTime(event.occurrenceStart)} -{" "}
{formatTime(event.occurrenceEnd)} (
{formatDuration(event.occurrenceStart, event.occurrenceEnd)})
</Text>
</View>
{/* Recurring indicator */}
{event.isRecurring && (
<View className="flex-row items-center mb-1">
<Feather
name="repeat"
size={16}
color={currentTheme.textPrimary}
style={{ marginRight: 8 }}
/>
<Text style={{ color: currentTheme.textPrimary }}>
Wiederkehrend
</Text>
</View>
)}
{/* Description */}
{event.description && (
<Text
style={{ color: currentTheme.textPrimary }}
className="text-sm mt-1"
>
{event.description}
</Text>
)}
{/* Action buttons */}
<View className="flex-row justify-end mt-3 gap-3">
<Pressable
onPress={onEdit}
className="w-10 h-10 rounded-full items-center justify-center"
style={{
borderWidth: 1,
borderColor: currentTheme.borderPrimary,
}}
>
<Feather name="edit-2" size={18} color={currentTheme.textPrimary} />
</Pressable> </Pressable>
<Pressable
onPress={onDelete}
className="w-10 h-10 rounded-full items-center justify-center"
style={{
borderWidth: 1,
borderColor: currentTheme.borderPrimary,
}}
>
<Feather
name="trash-2"
size={18}
color={currentTheme.textPrimary}
/>
</Pressable>
</View>
</View>
</View>
); );
}; };

View File

@@ -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 = { export const EventService = {
getAll: async (): Promise<CalendarEvent[]> => { getAll: async (): Promise<CalendarEvent[]> => {
throw new Error("Not implemented"); return ApiClient.get<CalendarEvent[]>("/events");
}, },
getById: async (_id: string): Promise<CalendarEvent> => { getById: async (id: string): Promise<CalendarEvent> => {
throw new Error("Not implemented"); return ApiClient.get<CalendarEvent>(`/events/${id}`);
}, },
getByDateRange: async ( getByDateRange: async (start: Date, end: Date): Promise<ExpandedEvent[]> => {
_start: Date, return ApiClient.get<ExpandedEvent[]>(
_end: Date, `/events/range?start=${start.toISOString()}&end=${end.toISOString()}`,
): Promise<CalendarEvent[]> => { );
throw new Error("Not implemented");
}, },
create: async (_data: CreateEventDTO): Promise<CalendarEvent> => { create: async (data: CreateEventDTO): Promise<CalendarEvent> => {
throw new Error("Not implemented"); return ApiClient.post<CalendarEvent>("/events", data);
}, },
update: async ( update: async (id: string, data: UpdateEventDTO): Promise<CalendarEvent> => {
_id: string, return ApiClient.put<CalendarEvent>(`/events/${id}`, data);
_data: UpdateEventDTO,
): Promise<CalendarEvent> => {
throw new Error("Not implemented");
}, },
delete: async (_id: string): Promise<void> => { delete: async (id: string): Promise<void> => {
throw new Error("Not implemented"); return ApiClient.delete(`/events/${id}`);
}, },
}; };

View File

@@ -1,26 +1,30 @@
import { create } from "zustand"; import { create } from "zustand";
import { CalendarEvent } from "@caldav/shared"; import { ExpandedEvent } from "@caldav/shared";
interface EventsState { interface EventsState {
events: CalendarEvent[]; events: ExpandedEvent[];
setEvents: (events: CalendarEvent[]) => void; setEvents: (events: ExpandedEvent[]) => void;
addEvent: (event: CalendarEvent) => void; addEvent: (event: ExpandedEvent) => void;
updateEvent: (id: string, event: Partial<CalendarEvent>) => void; updateEvent: (id: string, event: Partial<ExpandedEvent>) => void;
deleteEvent: (id: string) => void; deleteEvent: (id: string) => void;
} }
export const useEventsStore = create<EventsState>((set) => ({ export const useEventsStore = create<EventsState>((set) => ({
events: [], events: [],
setEvents: (_events: CalendarEvent[]) => { setEvents: (events: ExpandedEvent[]) => {
throw new Error("Not implemented"); set({ events });
}, },
addEvent: (_event: CalendarEvent) => { addEvent: (event: ExpandedEvent) => {
throw new Error("Not implemented"); set((state) => ({ events: [...state.events, event] }));
}, },
updateEvent: (_id: string, _event: Partial<CalendarEvent>) => { updateEvent: (id: string, updates: Partial<ExpandedEvent>) => {
throw new Error("Not implemented"); set((state) => ({
events: state.events.map((e) => (e.id === id ? { ...e, ...updates } : e)),
}));
}, },
deleteEvent: (_id: string) => { deleteEvent: (id: string) => {
throw new Error("Not implemented"); set((state) => ({
events: state.events.filter((e) => e.id !== id),
}));
}, },
})); }));

View File

@@ -6,29 +6,106 @@ export class EventController {
constructor(private eventService: EventService) {} constructor(private eventService: EventService) {}
async create(req: AuthenticatedRequest, res: Response): Promise<void> { async create(req: AuthenticatedRequest, res: Response): Promise<void> {
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<void> { async getById(req: AuthenticatedRequest, res: Response): Promise<void> {
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<void> { async getAll(req: AuthenticatedRequest, res: Response): Promise<void> {
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( async getByDateRange(
req: AuthenticatedRequest, req: AuthenticatedRequest,
res: Response, res: Response,
): Promise<void> { ): Promise<void> {
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<void> { async update(req: AuthenticatedRequest, res: Response): Promise<void> {
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<void> { async delete(req: AuthenticatedRequest, res: Response): Promise<void> {
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" });
}
} }
} }

View File

@@ -16,7 +16,7 @@ import { getWeeksOverview, getMonthOverview } from "../utils/eventFormatters";
type TestResponse = { content: string; proposedChange?: ProposedEventChange }; type TestResponse = { content: string; proposedChange?: ProposedEventChange };
// Test response index (cycles through responses) // Test response index (cycles through responses)
let responseIndex = 8; let responseIndex = 0;
// Static test responses (event proposals) // Static test responses (event proposals)
const staticResponses: TestResponse[] = [ const staticResponses: TestResponse[] = [
@@ -109,7 +109,7 @@ const staticResponses: TestResponse[] = [
'• "Verschiebe das Meeting auf Donnerstag"\n\n' + '• "Verschiebe das Meeting auf Donnerstag"\n\n' +
"Wie kann ich dir helfen?", "Wie kann ich dir helfen?",
}, },
// Response 9: Phone call - short appointment // Response 9: Phone call - short appointment (Wednesday, so +2 days = Friday)
{ {
content: content:
"Alles klar! Ich habe das Telefonat mit deiner Mutter eingetragen:", "Alles klar! Ich habe das Telefonat mit deiner Mutter eingetragen:",
@@ -117,12 +117,12 @@ const staticResponses: TestResponse[] = [
action: "create", action: "create",
event: { event: {
title: "Telefonat mit Mama", title: "Telefonat mit Mama",
startTime: getDay("Sunday", 0, 11, 0), startTime: getDay("Wednesday", 1, 11, 0),
endTime: getDay("Sunday", 0, 11, 30), 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: "" }, { content: "" },
// Response 11: Birthday party - evening event // 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: 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: { proposedChange: {
action: "create", action: "create",
event: { event: {
@@ -148,7 +148,7 @@ const staticResponses: TestResponse[] = [
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,SA;COUNT=8",
}, },
}, },
}, },
@@ -191,17 +191,18 @@ async function getTestResponse(
} }
if (responseIdx === 10) { 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 events = await eventRepo.findByUserId(userId);
const mamaEvent = events.find((e) => e.title === "Telefonat mit Mama"); const mamaEvent = events.find((e) => e.title === "Telefonat mit Mama");
if (mamaEvent) { if (mamaEvent) {
const newStart = new Date(mamaEvent.startTime); const newStart = new Date(mamaEvent.startTime);
newStart.setDate(newStart.getDate() + 2); newStart.setDate(newStart.getDate() + 3);
const newEnd = new Date(mamaEvent.endTime); newStart.setHours(13, 0, 0, 0);
newEnd.setDate(newEnd.getDate() + 2); const newEnd = new Date(newStart);
newEnd.setMinutes(30);
return { return {
content: 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: { proposedChange: {
action: "update", action: "update",
eventId: mamaEvent.id, eventId: mamaEvent.id,

View File

@@ -1,27 +1,51 @@
import { CalendarEvent, CreateEventDTO, UpdateEventDTO } from "@caldav/shared"; import {
CalendarEvent,
CreateEventDTO,
UpdateEventDTO,
ExpandedEvent,
} from "@caldav/shared";
import { EventRepository } from "./interfaces"; import { EventRepository } from "./interfaces";
import { expandRecurringEvents } from "../utils/recurrenceExpander";
export class EventService { export class EventService {
constructor(private eventRepo: EventRepository) {} constructor(private eventRepo: EventRepository) {}
async create(userId: string, data: CreateEventDTO): Promise<CalendarEvent> { async create(userId: string, data: CreateEventDTO): Promise<CalendarEvent> {
throw new Error("Not implemented"); return this.eventRepo.create(userId, data);
} }
async getById(id: string, userId: string): Promise<CalendarEvent | null> { async getById(id: string, userId: string): Promise<CalendarEvent | null> {
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<CalendarEvent[]> { async getAll(userId: string): Promise<CalendarEvent[]> {
throw new Error("Not implemented"); return this.eventRepo.findByUserId(userId);
} }
async getByDateRange( async getByDateRange(
userId: string, userId: string,
startDate: Date, startDate: Date,
endDate: Date, endDate: Date,
): Promise<CalendarEvent[]> { ): Promise<ExpandedEvent[]> {
throw new Error("Not implemented"); // 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( async update(
@@ -29,10 +53,18 @@ export class EventService {
userId: string, userId: string,
data: UpdateEventDTO, data: UpdateEventDTO,
): Promise<CalendarEvent | null> { ): Promise<CalendarEvent | null> {
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<boolean> { async delete(id: string, userId: string): Promise<boolean> {
throw new Error("Not implemented"); const event = await this.eventRepo.findById(id);
if (!event || event.userId !== userId) {
return false;
}
return this.eventRepo.delete(id);
} }
} }

View File

@@ -4,9 +4,10 @@ import {
DAY_TO_GERMAN, DAY_TO_GERMAN,
DAY_TO_GERMAN_SHORT, DAY_TO_GERMAN_SHORT,
MONTH_TO_GERMAN, MONTH_TO_GERMAN,
ExpandedEvent,
} from "@caldav/shared"; } from "@caldav/shared";
import { EventRepository } from "../services/interfaces"; import { EventRepository } from "../services/interfaces";
import { expandRecurringEvents, ExpandedEvent } from "./recurrenceExpander"; import { expandRecurringEvents } from "./recurrenceExpander";
// Private formatting helpers // Private formatting helpers

View File

@@ -1,10 +1,5 @@
import { RRule, rrulestr } from "rrule"; import { RRule, rrulestr } from "rrule";
import { CalendarEvent } from "@caldav/shared"; import { CalendarEvent, ExpandedEvent } from "@caldav/shared";
export interface ExpandedEvent extends CalendarEvent {
occurrenceStart: Date;
occurrenceEnd: Date;
}
// Convert local time to "fake UTC" for rrule // Convert local time to "fake UTC" for rrule
// rrule interprets all dates as UTC internally, so we need to trick it // rrule interprets all dates as UTC internally, so we need to trick it

View File

@@ -31,3 +31,8 @@ export interface UpdateEventDTO {
isRecurring?: boolean; isRecurring?: boolean;
recurrenceRule?: string; recurrenceRule?: string;
} }
export interface ExpandedEvent extends CalendarEvent {
occurrenceStart: Date;
occurrenceEnd: Date;
}