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:
25
CLAUDE.md
25
CLAUDE.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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}`);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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),
|
||||||
|
}));
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -31,3 +31,8 @@ export interface UpdateEventDTO {
|
|||||||
isRecurring?: boolean;
|
isRecurring?: boolean;
|
||||||
recurrenceRule?: string;
|
recurrenceRule?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ExpandedEvent extends CalendarEvent {
|
||||||
|
occurrenceStart: Date;
|
||||||
|
occurrenceEnd: Date;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user