feat: add recurring event deletion with three modes

Implement three deletion modes for recurring events:
- single: exclude specific occurrence via EXDATE mechanism
- future: set RRULE UNTIL to stop future occurrences
- all: delete entire event series

Changes include:
- Add exceptionDates field to CalendarEvent model
- Add RecurringDeleteMode type and DeleteRecurringEventDTO
- EventService.deleteRecurring() with mode-based logic using rrule library
- EventController DELETE endpoint accepts mode/occurrenceDate query params
- recurrenceExpander filters out exception dates during expansion
- AI tools support deleteMode and occurrenceDate for proposed deletions
- ChatService.confirmEvent() handles recurring delete modes
- New DeleteEventModal component for unified delete confirmation UI
- Calendar screen integrates modal for both recurring and non-recurring events
This commit is contained in:
2026-01-25 15:19:31 +01:00
parent a42e2a7c1c
commit 2b999d9b0f
35 changed files with 787 additions and 200 deletions

View File

@@ -91,7 +91,8 @@ src/
│ ├── EventCardBase.tsx # Shared event card layout with icons (used by EventCard & ProposedEventCard)
│ ├── EventCard.tsx # Calendar event card (uses EventCardBase + edit/delete buttons)
│ ├── EventConfirmDialog.tsx # AI-proposed event confirmation modal (skeleton)
── ProposedEventCard.tsx # Chat event proposal (uses EventCardBase + confirm/reject buttons)
── ProposedEventCard.tsx # Chat event proposal (uses EventCardBase + confirm/reject buttons)
│ └── DeleteEventModal.tsx # Unified delete confirmation modal (recurring: 3 options, non-recurring: simple confirm)
├── Themes.tsx # Theme definitions: THEMES object with defaultLight/defaultDark, Theme type
├── logging/
│ ├── index.ts # Re-exports
@@ -100,8 +101,8 @@ src/
│ ├── index.ts # Re-exports all services
│ ├── ApiClient.ts # HTTP client with X-User-Id header injection, request logging, handles empty responses (204)
│ ├── AuthService.ts # login(), register(), logout() - calls API and updates AuthStore
│ ├── EventService.ts # getAll(), getById(), getByDateRange(), create(), update(), delete()
│ └── ChatService.ts # sendMessage(), confirmEvent(), rejectEvent(), getConversations(), getConversation()
│ ├── EventService.ts # getAll(), getById(), getByDateRange(), create(), update(), delete(mode, occurrenceDate)
│ └── ChatService.ts # sendMessage(), confirmEvent(deleteMode, occurrenceDate), rejectEvent(), getConversations(), getConversation()
└── stores/ # Zustand state management
├── index.ts # Re-exports all stores
├── AuthStore.ts # user, isAuthenticated, isLoading, login(), logout(), loadStoredUser()
@@ -224,7 +225,7 @@ src/
- `GET /api/events/:id` - Get single event (protected)
- `POST /api/events` - Create event (protected)
- `PUT /api/events/:id` - Update event (protected)
- `DELETE /api/events/:id` - Delete event (protected)
- `DELETE /api/events/:id` - Delete event (protected, query params: mode, occurrenceDate for recurring)
- `POST /api/chat/message` - Send message to AI (protected)
- `POST /api/chat/confirm/:conversationId/:messageId` - Confirm proposed event (protected)
- `POST /api/chat/reject/:conversationId/:messageId` - Reject proposed event (protected)
@@ -254,12 +255,15 @@ src/
**Key Types:**
- `User`: id, email, userName, passwordHash?, createdAt?, updatedAt?
- `CalendarEvent`: id, userId, title, description?, startTime, endTime, note?, isRecurring?, recurrenceRule?
- `CalendarEvent`: id, userId, title, description?, startTime, endTime, note?, isRecurring?, recurrenceRule?, exceptionDates?
- `ExpandedEvent`: Extends CalendarEvent with occurrenceStart, occurrenceEnd (for recurring event instances)
- `ChatMessage`: id, conversationId, sender ('user' | 'assistant'), content, proposedChanges?
- `ProposedEventChange`: id, action ('create' | 'update' | 'delete'), eventId?, event?, updates?, respondedAction?
- `ProposedEventChange`: id, action ('create' | 'update' | 'delete'), eventId?, event?, updates?, respondedAction?, deleteMode?, occurrenceDate?
- Each proposal has unique `id` (e.g., "proposal-0") for individual confirm/reject
- `respondedAction` tracks user response per proposal (not per message)
- `deleteMode` ('single' | 'future' | 'all') and `occurrenceDate` for recurring event deletion
- `RecurringDeleteMode`: 'single' | 'future' | 'all' - delete modes for recurring events
- `DeleteRecurringEventDTO`: mode, occurrenceDate? - DTO for recurring event deletion
- `Conversation`: id, userId, createdAt?, updatedAt? (messages loaded separately via lazy loading)
- `CreateUserDTO`: email, userName, password (for registration)
- `LoginDTO`: identifier (email OR userName), password
@@ -393,9 +397,9 @@ NODE_ENV=development # development = pretty logs, production = JSON
- `dotenv` integration for environment variables
- `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)
- `MongoEventRepository`: Full CRUD implemented (findById, findByUserId, findByDateRange, create, update, delete, addExceptionDate)
- `EventController`: Full CRUD (create, getById, getAll, getByDateRange, update, delete)
- `EventService`: Full CRUD with recurring event expansion via recurrenceExpander
- `EventService`: Full CRUD with recurring event expansion via recurrenceExpander, deleteRecurring() with three modes (single/future/all)
- `utils/eventFormatters`: getWeeksOverview(), getMonthOverview() with German localization
- `utils/recurrenceExpander`: expandRecurringEvents() using rrule library for RRULE parsing
- `ChatController`: getConversations(), getConversation() with cursor-based pagination support
@@ -405,7 +409,7 @@ NODE_ENV=development # development = pretty logs, production = JSON
- `GPTAdapter`: Full implementation with OpenAI GPT (gpt-4o-mini model), function calling for calendar operations, collects multiple proposals per response
- `ai/utils/`: Provider-agnostic shared utilities (systemPrompt, toolDefinitions, toolExecutor, eventFormatter)
- `ai/utils/systemPrompt`: Includes RRULE documentation - AI knows to create separate events when times differ by day
- `utils/recurrenceExpander`: Handles RRULE parsing, strips `RRULE:` prefix if present (AI may include it)
- `utils/recurrenceExpander`: Handles RRULE parsing, strips `RRULE:` prefix if present (AI may include it), filters out exceptionDates
- `logging/`: Structured logging with pino, pino-http middleware, @Logged decorator
- All repositories and GPTAdapter decorated with @Logged for automatic method logging
- CORS configured to allow X-User-Id header
@@ -443,6 +447,7 @@ NODE_ENV=development # development = pretty logs, production = JSON
- Tap-to-open modal overlay showing EventCards for selected day
- Supports events from adjacent months visible in grid
- Uses `useFocusEffect` for automatic reload on tab focus
- DeleteEventModal integration for recurring event deletion with three modes
- Chat screen fully functional with FlashList, message sending, and event confirm/reject
- **Multiple event proposals**: AI can propose multiple events in one response
- Arrow navigation between proposals with "Event X von Y" counter
@@ -454,11 +459,12 @@ NODE_ENV=development # development = pretty logs, production = JSON
- KeyboardAvoidingView for proper keyboard handling (iOS padding, Android height)
- Auto-scroll to end on new messages and keyboard show
- keyboardDismissMode="interactive" and keyboardShouldPersistTaps="handled"
- `EventService`: getAll(), getById(), getByDateRange(), create(), update(), delete() - fully implemented
- `ChatService`: sendMessage(), confirmEvent(), rejectEvent(), getConversations(), getConversation() - fully implemented with cursor pagination
- `EventService`: getAll(), getById(), getByDateRange(), create(), update(), delete(mode, occurrenceDate) - fully implemented with recurring delete modes
- `ChatService`: sendMessage(), confirmEvent(deleteMode, occurrenceDate), rejectEvent(), getConversations(), getConversation() - fully implemented with cursor pagination and recurring delete support
- `EventCardBase`: Shared base component with event layout (header, date/time/recurring icons, description) - used by both EventCard and ProposedEventCard
- `EventCard`: Uses EventCardBase + edit/delete buttons for calendar display
- `ProposedEventCard`: Uses EventCardBase + confirm/reject buttons for chat proposals (supports create/update/delete actions)
- `ProposedEventCard`: Uses EventCardBase + confirm/reject buttons for chat proposals (supports create/update/delete actions with deleteMode display)
- `DeleteEventModal`: Unified delete confirmation modal - shows three options for recurring events (single/future/all), simple confirm for non-recurring
- `Themes.tsx`: Theme definitions with THEMES object (defaultLight, defaultDark) including all color tokens (textPrimary, borderPrimary, eventIndicator, secondaryBg, shadowColor, etc.)
- `EventsStore`: Zustand store with setEvents(), addEvent(), updateEvent(), deleteEvent() - stores ExpandedEvent[]
- `ChatStore`: Zustand store with addMessage(), addMessages(), updateMessage(), clearMessages(), isWaitingForResponse/setWaitingForResponse() for typing indicator - loads from server on mount and persists across tab switches

View File

@@ -56,5 +56,5 @@ export const THEMES = {
eventIndicator: "#DE6C20",
borderPrimary: "#FFFFFF",
shadowColor: "#FFFFFF",
}
},
} as const satisfies Record<string, Theme>;

View File

@@ -8,40 +8,40 @@ export default function TabLayout() {
return (
<AuthGuard>
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: theme.chatBot,
tabBarInactiveTintColor: theme.primeFg,
tabBarStyle: { backgroundColor: theme.primeBg },
}}
>
<Tabs.Screen
name="chat"
options={{
title: "Chat",
tabBarIcon: ({ color }) => (
<Ionicons size={28} name="chatbubble" color={color} />
),
screenOptions={{
headerShown: false,
tabBarActiveTintColor: theme.chatBot,
tabBarInactiveTintColor: theme.primeFg,
tabBarStyle: { backgroundColor: theme.primeBg },
}}
/>
<Tabs.Screen
name="calendar"
options={{
title: "Calendar",
tabBarIcon: ({ color }) => (
<Ionicons size={28} name="calendar" color={color} />
),
}}
/>
<Tabs.Screen
name="settings"
options={{
title: "Settings",
tabBarIcon: ({ color }) => (
<Ionicons size={28} name="settings" color={color} />
),
}}
/>
>
<Tabs.Screen
name="chat"
options={{
title: "Chat",
tabBarIcon: ({ color }) => (
<Ionicons size={28} name="chatbubble" color={color} />
),
}}
/>
<Tabs.Screen
name="calendar"
options={{
title: "Calendar",
tabBarIcon: ({ color }) => (
<Ionicons size={28} name="calendar" color={color} />
),
}}
/>
<Tabs.Screen
name="settings"
options={{
title: "Settings",
tabBarIcon: ({ color }) => (
<Ionicons size={28} name="settings" color={color} />
),
}}
/>
</Tabs>
</AuthGuard>
);

View File

@@ -5,11 +5,17 @@ import {
Text,
View,
ScrollView,
Alert,
} from "react-native";
import { DAYS, MONTHS, Month, ExpandedEvent } from "@calchat/shared";
import {
DAYS,
MONTHS,
Month,
ExpandedEvent,
RecurringDeleteMode,
} from "@calchat/shared";
import Header from "../../components/Header";
import { EventCard } from "../../components/EventCard";
import { DeleteEventModal } from "../../components/DeleteEventModal";
import React, {
useCallback,
useEffect,
@@ -75,40 +81,49 @@ const Calendar = () => {
const [currentYear, setCurrentYear] = useState(new Date().getFullYear());
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
// State for delete modal
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const [eventToDelete, setEventToDelete] = useState<ExpandedEvent | null>(
null,
);
const { events, setEvents, deleteEvent } = useEventsStore();
// Function to load events for current view
const loadEvents = useCallback(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);
}
}, [monthIndex, currentYear, setEvents]);
// Load events when tab gains focus or month/year changes
// Include days from prev/next month that are visible in the grid
// NOTE: Wrapper needed because loadEvents is async (returns Promise)
// and useFocusEffect expects a sync function (optionally returning cleanup)
useFocusEffect(
useCallback(() => {
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]),
}, [loadEvents]),
);
// Group events by date (YYYY-MM-DD format)
@@ -153,31 +168,40 @@ const Calendar = () => {
// 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 = getDateKey(selectedDate);
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");
}
},
},
]);
const handleDeleteEvent = (event: ExpandedEvent) => {
// Show delete modal for both recurring and non-recurring events
setEventToDelete(event);
setDeleteModalVisible(true);
};
const handleDeleteConfirm = async (mode: RecurringDeleteMode) => {
if (!eventToDelete) return;
setDeleteModalVisible(false);
const event = eventToDelete;
const occurrenceDate = getDateKey(new Date(event.occurrenceStart));
try {
if (event.isRecurring) {
// Recurring event: use mode and occurrenceDate
await EventService.delete(event.id, mode, occurrenceDate);
// Reload events to reflect changes
await loadEvents();
} else {
// Non-recurring event: simple delete
await EventService.delete(event.id);
deleteEvent(event.id);
}
} catch (error) {
console.error("Failed to delete event:", error);
} finally {
setEventToDelete(null);
}
};
const handleDeleteCancel = () => {
setDeleteModalVisible(false);
setEventToDelete(null);
};
// Get events for selected date
@@ -203,8 +227,6 @@ const Calendar = () => {
eventsByDate={eventsByDate}
onDayPress={handleDayPress}
/>
{/* Event Overlay Modal */}
<EventOverlay
visible={selectedDate !== null}
date={selectedDate}
@@ -213,6 +235,13 @@ const Calendar = () => {
onEditEvent={handleEditEvent}
onDeleteEvent={handleDeleteEvent}
/>
<DeleteEventModal
visible={deleteModalVisible}
eventTitle={eventToDelete?.title || ""}
isRecurring={eventToDelete?.isRecurring || false}
onConfirm={handleDeleteConfirm}
onCancel={handleDeleteCancel}
/>
</BaseBackground>
);
};
@@ -274,7 +303,12 @@ const EventOverlay = ({
borderBottomColor: theme.borderPrimary,
}}
>
<Text className="font-bold text-lg" style={{ color: theme.textPrimary }}>{dateString}</Text>
<Text
className="font-bold text-lg"
style={{ color: theme.textPrimary }}
>
{dateString}
</Text>
<Text style={{ color: theme.textPrimary }}>
{events.length} {events.length === 1 ? "Termin" : "Termine"}
</Text>
@@ -406,9 +440,7 @@ const MonthSelector = ({
className="w-full flex justify-center items-center py-2"
style={{
backgroundColor:
item.monthIndex % 2 === 0
? theme.primeBg
: theme.secondaryBg,
item.monthIndex % 2 === 0 ? theme.primeBg : theme.secondaryBg,
}}
>
<Text className="text-xl" style={{ color: theme.primeFg }}>
@@ -511,11 +543,7 @@ const CalendarHeader = (props: CalendarHeaderProps) => {
}}
onPress={measureAndOpen}
>
<Ionicons
name="chevron-down"
size={28}
color={theme.primeFg}
/>
<Ionicons name="chevron-down" size={28} color={theme.primeFg} />
</Pressable>
</View>
<MonthSelector
@@ -576,7 +604,9 @@ const WeekDaysLine = () => {
<View className="flex flex-row items-center justify-around px-2 gap-2">
{/* TODO: px and gap need fine tuning to perfectly align with the grid */}
{DAYS.map((day, i) => (
<Text key={i} style={{ color: theme.textPrimary }}>{day.substring(0, 2).toUpperCase()}</Text>
<Text key={i} style={{ color: theme.textPrimary }}>
{day.substring(0, 2).toUpperCase()}
</Text>
))}
</View>
);

View File

@@ -20,7 +20,7 @@ import {
chatMessageToMessageData,
MessageData,
} from "../../stores";
import { ProposedEventChange } from "@calchat/shared";
import { ProposedEventChange, RespondedAction } from "@calchat/shared";
import { ProposedEventCard } from "../../components/ProposedEventCard";
import { Ionicons } from "@expo/vector-icons";
import TypingIndicator from "../../components/TypingIndicator";
@@ -104,7 +104,7 @@ const Chat = () => {
};
const handleEventResponse = async (
action: "confirm" | "reject",
action: RespondedAction,
messageId: string,
conversationId: string,
proposalId: string,
@@ -114,7 +114,9 @@ const Chat = () => {
const message = messages.find((m) => m.id === messageId);
if (message?.proposedChanges) {
const updatedProposals = message.proposedChanges.map((p) =>
p.id === proposalId ? { ...p, respondedAction: action as "confirm" | "reject" } : p,
p.id === proposalId
? { ...p, respondedAction: action }
: p,
);
updateMessage(messageId, { proposedChanges: updatedProposals });
}
@@ -130,8 +132,14 @@ const Chat = () => {
proposedChange.event,
proposedChange.eventId,
proposedChange.updates,
proposedChange.deleteMode,
proposedChange.occurrenceDate,
)
: await ChatService.rejectEvent(conversationId, messageId, proposalId);
: await ChatService.rejectEvent(
conversationId,
messageId,
proposalId,
);
const botMessage: MessageData = {
id: response.message.id,
@@ -225,14 +233,21 @@ const Chat = () => {
)
}
onReject={(proposalId) =>
handleEventResponse("reject", item.id, item.conversationId!, proposalId)
handleEventResponse(
"reject",
item.id,
item.conversationId!,
proposalId,
)
}
/>
)}
keyExtractor={(item) => item.id}
keyboardDismissMode="interactive"
keyboardShouldPersistTaps="handled"
ListFooterComponent={isWaitingForResponse ? <TypingIndicator /> : null}
ListFooterComponent={
isWaitingForResponse ? <TypingIndicator /> : null
}
/>
<ChatInput onSend={handleSend} />
</KeyboardAvoidingView>
@@ -251,7 +266,9 @@ const ChatHeader = () => {
borderColor: theme.primeFg,
}}
></View>
<Text className="text-lg pl-3" style={{ color: theme.textPrimary }}>CalChat</Text>
<Text className="text-lg pl-3" style={{ color: theme.textPrimary }}>
CalChat
</Text>
<View
className="h-2 bg-black"
style={{
@@ -329,9 +346,7 @@ const ChatMessage = ({
const goToPrev = () => setCurrentIndex((i) => Math.max(0, i - 1));
const goToNext = () =>
setCurrentIndex((i) =>
Math.min((proposedChanges?.length || 1) - 1, i + 1),
);
setCurrentIndex((i) => Math.min((proposedChanges?.length || 1) - 1, i + 1));
const canGoPrev = currentIndex > 0;
const canGoNext = currentIndex < (proposedChanges?.length || 1) - 1;
@@ -344,7 +359,9 @@ const ChatMessage = ({
minWidth: hasProposals ? "75%" : undefined,
}}
>
<Text className="p-2" style={{ color: theme.textPrimary }}>{content}</Text>
<Text className="p-2" style={{ color: theme.textPrimary }}>
{content}
</Text>
{hasProposals && currentProposal && onConfirm && onReject && (
<View>
@@ -358,11 +375,7 @@ const ChatMessage = ({
className="p-1"
style={{ opacity: canGoPrev ? 1 : 0.3 }}
>
<Ionicons
name="chevron-back"
size={24}
color={theme.primeFg}
/>
<Ionicons name="chevron-back" size={24} color={theme.primeFg} />
</Pressable>
)}

View File

@@ -19,27 +19,49 @@ const EventDetailScreen = () => {
return (
<BaseBackground>
<View className="flex-1 p-4">
<Text className="text-2xl mb-4" style={{ color: theme.textPrimary }}>Event Detail</Text>
<Text className="mb-4" style={{ color: theme.textSecondary }}>ID: {id}</Text>
<Text className="text-2xl mb-4" style={{ color: theme.textPrimary }}>
Event Detail
</Text>
<Text className="mb-4" style={{ color: theme.textSecondary }}>
ID: {id}
</Text>
<TextInput
placeholder="Title"
placeholderTextColor={theme.textMuted}
className="w-full border rounded p-2 mb-4"
style={{ color: theme.textPrimary, borderColor: theme.borderPrimary, backgroundColor: theme.secondaryBg }}
style={{
color: theme.textPrimary,
borderColor: theme.borderPrimary,
backgroundColor: theme.secondaryBg,
}}
/>
<TextInput
placeholder="Description"
placeholderTextColor={theme.textMuted}
multiline
className="w-full border rounded p-2 mb-4 h-24"
style={{ color: theme.textPrimary, borderColor: theme.borderPrimary, backgroundColor: theme.secondaryBg }}
style={{
color: theme.textPrimary,
borderColor: theme.borderPrimary,
backgroundColor: theme.secondaryBg,
}}
/>
<View className="flex-row gap-2">
<Pressable className="p-3 rounded flex-1" style={{ backgroundColor: theme.confirmButton }}>
<Text className="text-center" style={{ color: theme.buttonText }}>Save</Text>
<Pressable
className="p-3 rounded flex-1"
style={{ backgroundColor: theme.confirmButton }}
>
<Text className="text-center" style={{ color: theme.buttonText }}>
Save
</Text>
</Pressable>
<Pressable className="p-3 rounded flex-1" style={{ backgroundColor: theme.rejectButton }}>
<Text className="text-center" style={{ color: theme.buttonText }}>Delete</Text>
<Pressable
className="p-3 rounded flex-1"
style={{ backgroundColor: theme.rejectButton }}
>
<Text className="text-center" style={{ color: theme.buttonText }}>
Delete
</Text>
</Pressable>
</View>
</View>

View File

@@ -43,7 +43,10 @@ const LoginScreen = () => {
</Text>
{error && (
<Text className="mb-4 text-center" style={{ color: theme.rejectButton }}>
<Text
className="mb-4 text-center"
style={{ color: theme.rejectButton }}
>
{error}
</Text>
)}

View File

@@ -17,18 +17,31 @@ const NoteScreen = () => {
return (
<BaseBackground>
<View className="flex-1 p-4">
<Text className="text-2xl mb-4" style={{ color: theme.textPrimary }}>Note</Text>
<Text className="mb-4" style={{ color: theme.textSecondary }}>Event ID: {id}</Text>
<Text className="text-2xl mb-4" style={{ color: theme.textPrimary }}>
Note
</Text>
<Text className="mb-4" style={{ color: theme.textSecondary }}>
Event ID: {id}
</Text>
<TextInput
placeholder="Write your note here..."
placeholderTextColor={theme.textMuted}
multiline
className="w-full border rounded p-2 flex-1 mb-4"
textAlignVertical="top"
style={{ color: theme.textPrimary, borderColor: theme.borderPrimary, backgroundColor: theme.secondaryBg }}
style={{
color: theme.textPrimary,
borderColor: theme.borderPrimary,
backgroundColor: theme.secondaryBg,
}}
/>
<Pressable className="p-3 rounded" style={{ backgroundColor: theme.confirmButton }}>
<Text className="text-center" style={{ color: theme.buttonText }}>Save Note</Text>
<Pressable
className="p-3 rounded"
style={{ backgroundColor: theme.confirmButton }}
>
<Text className="text-center" style={{ color: theme.buttonText }}>
Save Note
</Text>
</Pressable>
</View>
</BaseBackground>

View File

@@ -51,7 +51,10 @@ const RegisterScreen = () => {
</Text>
{error && (
<Text className="mb-4 text-center" style={{ color: theme.rejectButton }}>
<Text
className="mb-4 text-center"
style={{ color: theme.rejectButton }}
>
{error}
</Text>
)}

View File

@@ -15,9 +15,7 @@ const AuthButton = ({ title, onPress, isLoading = false }: AuthButtonProps) => {
disabled={isLoading}
className="w-full rounded-lg p-4 mb-4 border-4"
style={{
backgroundColor: isLoading
? theme.disabledButton
: theme.chatBot,
backgroundColor: isLoading ? theme.disabledButton : theme.chatBot,
shadowColor: theme.shadowColor,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,

View File

@@ -8,7 +8,7 @@ type BaseButtonProps = {
solid?: boolean;
};
const BaseButton = ({children, onPress, solid = false}: BaseButtonProps) => {
const BaseButton = ({ children, onPress, solid = false }: BaseButtonProps) => {
const { theme } = useThemeStore();
return (
<Pressable
@@ -16,9 +16,7 @@ const BaseButton = ({children, onPress, solid = false}: BaseButtonProps) => {
onPress={onPress}
style={{
borderColor: theme.borderPrimary,
backgroundColor: solid
? theme.chatBot
: theme.primeBg,
backgroundColor: solid ? theme.chatBot : theme.primeBg,
shadowColor: theme.shadowColor,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,

View File

@@ -10,7 +10,12 @@ type ChatBubbleProps = {
style?: ViewStyle;
};
export function ChatBubble({ side, children, className = "", style }: ChatBubbleProps) {
export function ChatBubble({
side,
children,
className = "",
style,
}: ChatBubbleProps) {
const { theme } = useThemeStore();
const borderColor = side === "left" ? theme.chatBot : theme.primeFg;
const sideClass =
@@ -21,7 +26,10 @@ export function ChatBubble({ side, children, className = "", style }: ChatBubble
return (
<View
className={`border-2 border-solid rounded-xl my-2 ${sideClass} ${className}`}
style={[{ borderColor, elevation: 8, backgroundColor: theme.secondaryBg }, style]}
style={[
{ borderColor, elevation: 8, backgroundColor: theme.secondaryBg },
style,
]}
>
{children}
</View>

View File

@@ -0,0 +1,162 @@
import { Modal, Pressable, Text, View } from "react-native";
import { RecurringDeleteMode } from "@calchat/shared";
import { useThemeStore } from "../stores/ThemeStore";
type DeleteEventModalProps = {
visible: boolean;
eventTitle: string;
isRecurring: boolean;
onConfirm: (mode: RecurringDeleteMode) => void;
onCancel: () => void;
};
type DeleteOption = {
mode: RecurringDeleteMode;
label: string;
description: string;
};
const RECURRING_DELETE_OPTIONS: DeleteOption[] = [
{
mode: "single",
label: "Nur dieses Vorkommen",
description: "Nur der ausgewaehlte Termin wird entfernt",
},
{
mode: "future",
label: "Dieses und zukuenftige",
description: "Dieser und alle folgenden Termine werden entfernt",
},
{
mode: "all",
label: "Alle Vorkommen",
description: "Die gesamte Terminserie wird geloescht",
},
];
export const DeleteEventModal = ({
visible,
eventTitle,
isRecurring,
onConfirm,
onCancel,
}: DeleteEventModalProps) => {
const { theme } = useThemeStore();
return (
<Modal
visible={visible}
transparent={true}
animationType="fade"
onRequestClose={onCancel}
>
<Pressable
className="flex-1 justify-center items-center"
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
onPress={onCancel}
>
<Pressable
className="w-11/12 rounded-2xl overflow-hidden"
style={{
backgroundColor: theme.primeBg,
borderWidth: 4,
borderColor: theme.borderPrimary,
}}
onPress={(e) => e.stopPropagation()}
>
{/* Header */}
<View
className="px-4 py-3"
style={{
backgroundColor: theme.chatBot,
borderBottomWidth: 3,
borderBottomColor: theme.borderPrimary,
}}
>
<Text
className="font-bold text-lg"
style={{ color: theme.textPrimary }}
>
{isRecurring
? "Wiederkehrenden Termin loeschen"
: "Termin loeschen"}
</Text>
<Text style={{ color: theme.textSecondary }} numberOfLines={1}>
{eventTitle}
</Text>
</View>
{/* Content */}
<View className="p-4">
{isRecurring ? (
// Recurring event: show three options
RECURRING_DELETE_OPTIONS.map((option) => (
<Pressable
key={option.mode}
onPress={() => onConfirm(option.mode)}
className="py-3 px-4 rounded-lg mb-2"
style={{
backgroundColor: theme.secondaryBg,
borderWidth: 1,
borderColor: theme.borderPrimary,
}}
>
<Text
className="font-medium text-base"
style={{ color: theme.textPrimary }}
>
{option.label}
</Text>
<Text
className="text-sm mt-1"
style={{ color: theme.textMuted }}
>
{option.description}
</Text>
</Pressable>
))
) : (
// Non-recurring event: simple confirmation
<View>
<Text
className="text-base mb-4"
style={{ color: theme.textPrimary }}
>
Möchtest du diesen Termin wirklich löschen?
</Text>
<Pressable
onPress={() => onConfirm("all")}
className="py-3 px-4 rounded-lg"
style={{
backgroundColor: theme.rejectButton,
}}
>
<Text
className="font-medium text-base text-center"
style={{ color: theme.buttonText }}
>
Loeschen
</Text>
</Pressable>
</View>
)}
</View>
{/* Cancel button */}
<Pressable
onPress={onCancel}
className="py-3 items-center"
style={{
borderTopWidth: 1,
borderTopColor: theme.placeholderBg,
}}
>
<Text style={{ color: theme.primeFg }} className="font-bold">
Abbrechen
</Text>
</Pressable>
</Pressable>
</Pressable>
</Modal>
);
};

View File

@@ -41,11 +41,7 @@ export const EventCard = ({ event, onEdit, onDelete }: EventCardProps) => {
borderColor: theme.borderPrimary,
}}
>
<Feather
name="trash-2"
size={18}
color={theme.textPrimary}
/>
<Feather name="trash-2" size={18} color={theme.textPrimary} />
</Pressable>
</View>
</EventCardBase>

View File

@@ -75,11 +75,19 @@ export const EventCardBase = ({
borderBottomColor: theme.borderPrimary,
}}
>
<Text className="font-bold text-base" style={{ color: theme.textPrimary }}>{title}</Text>
<Text
className="font-bold text-base"
style={{ color: theme.textPrimary }}
>
{title}
</Text>
</View>
{/* Content */}
<View className="px-3 py-2" style={{ backgroundColor: theme.secondaryBg }}>
<View
className="px-3 py-2"
style={{ backgroundColor: theme.secondaryBg }}
>
{/* Date */}
<View className="flex-row items-center mb-1">
<Feather
@@ -116,18 +124,13 @@ export const EventCardBase = ({
color={theme.textPrimary}
style={{ marginRight: 8 }}
/>
<Text style={{ color: theme.textPrimary }}>
Wiederkehrend
</Text>
<Text style={{ color: theme.textPrimary }}>Wiederkehrend</Text>
</View>
)}
{/* Description */}
{description && (
<Text
style={{ color: theme.textPrimary }}
className="text-sm mt-1"
>
<Text style={{ color: theme.textPrimary }} className="text-sm mt-1">
{description}
</Text>
)}

View File

@@ -29,7 +29,9 @@ const EventConfirmDialog = ({
<Modal visible={false} transparent animationType="fade">
<View>
<Pressable>
<Text style={{ color: theme.textPrimary }}>EventConfirmDialog - Not Implemented</Text>
<Text style={{ color: theme.textPrimary }}>
EventConfirmDialog - Not Implemented
</Text>
</Pressable>
</View>
</Modal>

View File

@@ -1,14 +1,34 @@
import { View, Text, Pressable } from "react-native";
import { ProposedEventChange } from "@calchat/shared";
import { ProposedEventChange, RecurringDeleteMode } from "@calchat/shared";
import { useThemeStore } from "../stores/ThemeStore";
import { EventCardBase } from "./EventCardBase";
const DELETE_MODE_LABELS: Record<RecurringDeleteMode, string> = {
single: "Nur dieses Vorkommen",
future: "Dieses & zukuenftige",
all: "Alle Vorkommen",
};
type ProposedEventCardProps = {
proposedChange: ProposedEventChange;
onConfirm: () => void;
onReject: () => void;
};
const DeleteModeBadge = ({ mode }: { mode: RecurringDeleteMode }) => {
const { theme } = useThemeStore();
return (
<View
className="self-start px-2 py-1 rounded-md mb-2"
style={{ backgroundColor: theme.rejectButton }}
>
<Text style={{ color: theme.buttonText }} className="text-xs font-medium">
{DELETE_MODE_LABELS[mode]}
</Text>
</View>
);
};
const ConfirmRejectButtons = ({
isDisabled,
respondedAction,
@@ -68,6 +88,12 @@ export const ProposedEventCard = ({
// respondedAction is now part of the proposedChange
const isDisabled = !!proposedChange.respondedAction;
// Show delete mode badge for delete actions on recurring events
const showDeleteModeBadge =
proposedChange.action === "delete" &&
event?.isRecurring &&
proposedChange.deleteMode;
if (!event) {
return null;
}
@@ -82,6 +108,9 @@ export const ProposedEventCard = ({
description={event.description}
isRecurring={event.isRecurring}
>
{showDeleteModeBadge && (
<DeleteModeBadge mode={proposedChange.deleteMode!} />
)}
<ConfirmRejectButtons
isDisabled={isDisabled}
respondedAction={proposedChange.respondedAction}

View File

@@ -50,11 +50,15 @@ async function request<T>(
const duration = Math.round(performance.now() - start);
if (!response.ok) {
apiLogger.error(`${method} ${endpoint} - ${response.status} (${duration}ms)`);
apiLogger.error(
`${method} ${endpoint} - ${response.status} (${duration}ms)`,
);
throw new Error(`HTTP ${response.status}`);
}
apiLogger.debug(`${method} ${endpoint} - ${response.status} (${duration}ms)`);
apiLogger.debug(
`${method} ${endpoint} - ${response.status} (${duration}ms)`,
);
const text = await response.text();
if (!text) {

View File

@@ -7,6 +7,7 @@ import {
CreateEventDTO,
UpdateEventDTO,
EventAction,
RecurringDeleteMode,
} from "@calchat/shared";
import { ApiClient } from "./ApiClient";
@@ -16,6 +17,8 @@ interface ConfirmEventRequest {
event?: CreateEventDTO;
eventId?: string;
updates?: UpdateEventDTO;
deleteMode?: RecurringDeleteMode;
occurrenceDate?: string;
}
interface RejectEventRequest {
@@ -35,6 +38,8 @@ export const ChatService = {
event?: CreateEventDTO,
eventId?: string,
updates?: UpdateEventDTO,
deleteMode?: RecurringDeleteMode,
occurrenceDate?: string,
): Promise<ChatResponse> => {
const body: ConfirmEventRequest = {
proposalId,
@@ -42,6 +47,8 @@ export const ChatService = {
event,
eventId,
updates,
deleteMode,
occurrenceDate,
};
return ApiClient.post<ChatResponse>(
`/chat/confirm/${conversationId}/${messageId}`,

View File

@@ -3,6 +3,7 @@ import {
CreateEventDTO,
UpdateEventDTO,
ExpandedEvent,
RecurringDeleteMode,
} from "@calchat/shared";
import { ApiClient } from "./ApiClient";
@@ -29,7 +30,18 @@ export const EventService = {
return ApiClient.put<CalendarEvent>(`/events/${id}`, data);
},
delete: async (id: string): Promise<void> => {
return ApiClient.delete(`/events/${id}`);
delete: async (
id: string,
mode?: RecurringDeleteMode,
occurrenceDate?: string,
): Promise<CalendarEvent | void> => {
const params = new URLSearchParams();
if (mode) params.append("mode", mode);
if (occurrenceDate) params.append("occurrenceDate", occurrenceDate);
const queryString = params.toString();
const url = `/events/${id}${queryString ? `?${queryString}` : ""}`;
return ApiClient.delete(url);
},
};

View File

@@ -8,5 +8,5 @@ interface ThemeState {
export const useThemeStore = create<ThemeState>((set) => ({
theme: THEMES.defaultLight,
setTheme: (themeName) => set({theme: THEMES[themeName]})
}))
setTheme: (themeName) => set({ theme: THEMES[themeName] }),
}));

View File

@@ -140,7 +140,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
{
name: "proposeDeleteEvent",
description:
"Propose deleting an event. The user must confirm. Use this when the user wants to remove an appointment.",
"Propose deleting an event. The user must confirm. Use this when the user wants to remove an appointment. For recurring events, specify deleteMode to control which occurrences to delete.",
parameters: {
type: "object",
properties: {
@@ -148,6 +148,17 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
type: "string",
description: "ID of the event to delete",
},
deleteMode: {
type: "string",
enum: ["single", "future", "all"],
description:
"For recurring events: 'single' = only this occurrence, 'future' = this and all future, 'all' = entire recurring event. Defaults to 'all' for non-recurring events.",
},
occurrenceDate: {
type: "string",
description:
"ISO date string (YYYY-MM-DD) of the specific occurrence to delete. Required for 'single' and 'future' modes.",
},
},
required: ["eventId"],
},

View File

@@ -3,6 +3,7 @@ import {
getDay,
Day,
DAY_TO_GERMAN,
RecurringDeleteMode,
} from "@calchat/shared";
import { AIContext } from "../../services/interfaces";
import { formatDate, formatTime, formatDateTime } from "./eventFormatter";
@@ -111,6 +112,8 @@ export function executeToolCall(
case "proposeDeleteEvent": {
const eventId = args.eventId as string;
const deleteMode = (args.deleteMode as RecurringDeleteMode) || "all";
const occurrenceDate = args.occurrenceDate as string | undefined;
const existingEvent = context.existingEvents.find(
(e) => e.id === eventId,
);
@@ -119,8 +122,24 @@ export function executeToolCall(
return { content: `Event mit ID ${eventId} nicht gefunden.` };
}
// Build descriptive content based on delete mode
let modeDescription = "";
if (existingEvent.isRecurring) {
switch (deleteMode) {
case "single":
modeDescription = " (nur dieses Vorkommen)";
break;
case "future":
modeDescription = " (dieses und alle zukünftigen Vorkommen)";
break;
case "all":
modeDescription = " (alle Vorkommen)";
break;
}
}
return {
content: `Lösch-Vorschlag für "${existingEvent.title}" erstellt.`,
content: `Lösch-Vorschlag für "${existingEvent.title}"${modeDescription} erstellt.`,
proposedChange: {
action: "delete",
eventId,
@@ -131,6 +150,10 @@ export function executeToolCall(
description: existingEvent.description,
isRecurring: existingEvent.isRecurring,
},
deleteMode: existingEvent.isRecurring ? deleteMode : undefined,
occurrenceDate: existingEvent.isRecurring
? occurrenceDate
: undefined,
},
};
}

View File

@@ -35,7 +35,10 @@ if (process.env.NODE_ENV !== "production") {
"Access-Control-Allow-Methods",
"GET, POST, PUT, DELETE, OPTIONS",
);
res.header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-User-Id");
res.header(
"Access-Control-Allow-Headers",
"Content-Type, Authorization, X-User-Id",
);
if (req.method === "OPTIONS") {
res.sendStatus(200);
return;
@@ -54,8 +57,13 @@ const aiProvider = new GPTAdapter();
// Initialize services
const authService = new AuthService(userRepo);
const chatService = new ChatService(chatRepo, eventRepo, aiProvider);
const eventService = new EventService(eventRepo);
const chatService = new ChatService(
chatRepo,
eventRepo,
eventService,
aiProvider,
);
// Initialize controllers
const authController = new AuthController(authService);

View File

@@ -5,6 +5,7 @@ import {
UpdateEventDTO,
EventAction,
GetMessagesOptions,
RecurringDeleteMode,
} from "@calchat/shared";
import { ChatService } from "../services";
import { createLogger } from "../logging";
@@ -22,7 +23,10 @@ export class ChatController {
const response = await this.chatService.processMessage(userId, data);
res.json(response);
} catch (error) {
log.error({ error, userId: req.user?.userId }, "Error processing message");
log.error(
{ error, userId: req.user?.userId },
"Error processing message",
);
res.status(500).json({ error: "Failed to process message" });
}
}
@@ -31,12 +35,22 @@ export class ChatController {
try {
const userId = req.user!.userId;
const { conversationId, messageId } = req.params;
const { proposalId, action, event, eventId, updates } = req.body as {
const {
proposalId,
action,
event,
eventId,
updates,
deleteMode,
occurrenceDate,
} = req.body as {
proposalId: string;
action: EventAction;
event?: CreateEventDTO;
eventId?: string;
updates?: UpdateEventDTO;
deleteMode?: RecurringDeleteMode;
occurrenceDate?: string;
};
const response = await this.chatService.confirmEvent(
userId,
@@ -47,10 +61,15 @@ export class ChatController {
event,
eventId,
updates,
deleteMode,
occurrenceDate,
);
res.json(response);
} catch (error) {
log.error({ error, conversationId: req.params.conversationId }, "Error confirming event");
log.error(
{ error, conversationId: req.params.conversationId },
"Error confirming event",
);
res.status(500).json({ error: "Failed to confirm event" });
}
}
@@ -68,7 +87,10 @@ export class ChatController {
);
res.json(response);
} catch (error) {
log.error({ error, conversationId: req.params.conversationId }, "Error rejecting event");
log.error(
{ error, conversationId: req.params.conversationId },
"Error rejecting event",
);
res.status(500).json({ error: "Failed to reject event" });
}
}
@@ -82,7 +104,10 @@ export class ChatController {
const conversations = await this.chatService.getConversations(userId);
res.json(conversations);
} catch (error) {
log.error({ error, userId: req.user?.userId }, "Error getting conversations");
log.error(
{ error, userId: req.user?.userId },
"Error getting conversations",
);
res.status(500).json({ error: "Failed to get conversations" });
}
}
@@ -113,7 +138,10 @@ export class ChatController {
if ((error as Error).message === "Conversation not found") {
res.status(404).json({ error: "Conversation not found" });
} else {
log.error({ error, conversationId: req.params.id }, "Error getting conversation");
log.error(
{ error, conversationId: req.params.id },
"Error getting conversation",
);
res.status(500).json({ error: "Failed to get conversation" });
}
}

View File

@@ -1,4 +1,5 @@
import { Response } from "express";
import { RecurringDeleteMode } from "@calchat/shared";
import { EventService } from "../services";
import { createLogger } from "../logging";
import { AuthenticatedRequest } from "./AuthMiddleware";
@@ -72,7 +73,10 @@ export class EventController {
);
res.json(events);
} catch (error) {
log.error({ error, start: req.query.start, end: req.query.end }, "Error getting events by range");
log.error(
{ error, start: req.query.start, end: req.query.end },
"Error getting events by range",
);
res.status(500).json({ error: "Failed to get events" });
}
}
@@ -97,6 +101,38 @@ export class EventController {
async delete(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { mode, occurrenceDate } = req.query as {
mode?: RecurringDeleteMode;
occurrenceDate?: string;
};
// If mode is specified, use deleteRecurring
if (mode) {
const result = await this.eventService.deleteRecurring(
req.params.id,
req.user!.userId,
mode,
occurrenceDate,
);
// For 'all' mode or when event was completely deleted, return 204
if (result === null && mode === "all") {
res.status(204).send();
return;
}
// For 'single' or 'future' modes, return updated event
if (result) {
res.json(result);
return;
}
// result is null but mode wasn't 'all' - event not found or was deleted
res.status(204).send();
return;
}
// Default behavior: delete completely
const deleted = await this.eventService.delete(
req.params.id,
req.user!.userId,

View File

@@ -80,7 +80,10 @@ export function Logged(name: string) {
const method = String(propKey);
// Summarize args to avoid huge log entries
log.debug({ method, args: summarizeArgs(methodArgs) }, `${method} started`);
log.debug(
{ method, args: summarizeArgs(methodArgs) },
`${method} started`,
);
const logCompletion = (err?: unknown) => {
const duration = Math.round(performance.now() - start);

View File

@@ -47,4 +47,17 @@ export class MongoEventRepository implements EventRepository {
const result = await EventModel.findByIdAndDelete(id);
return result !== null;
}
async addExceptionDate(
id: string,
date: string,
): Promise<CalendarEvent | null> {
const event = await EventModel.findByIdAndUpdate(
id,
{ $addToSet: { exceptionDates: date } },
{ new: true },
);
if (!event) return null;
return event.toJSON() as unknown as CalendarEvent;
}
}

View File

@@ -46,6 +46,10 @@ const EventSchema = new Schema<
recurrenceRule: {
type: String,
},
exceptionDates: {
type: [String],
default: [],
},
},
{
timestamps: true,

View File

@@ -10,11 +10,16 @@ import {
UpdateEventDTO,
EventAction,
CreateMessageDTO,
RecurringDeleteMode,
} from "@calchat/shared";
import { ChatRepository, EventRepository, AIProvider } from "./interfaces";
import { EventService } from "./EventService";
import { getWeeksOverview, getMonthOverview } from "../utils/eventFormatters";
type TestResponse = { content: string; proposedChanges?: ProposedEventChange[] };
type TestResponse = {
content: string;
proposedChanges?: ProposedEventChange[];
};
// Test response index (cycles through responses)
let responseIndex = 0;
@@ -25,8 +30,7 @@ const staticResponses: TestResponse[] = [
// === MULTI-EVENT TEST RESPONSES ===
// Response 0: 3 Meetings an verschiedenen Tagen
{
content:
"Alles klar! Ich erstelle dir 3 Team-Meetings für diese Woche:",
content: "Alles klar! Ich erstelle dir 3 Team-Meetings für diese Woche:",
proposedChanges: [
{
id: "multi-1-a",
@@ -62,8 +66,7 @@ const staticResponses: TestResponse[] = [
},
// Response 1: 5 Termine für einen Projekttag
{
content:
"Ich habe deinen kompletten Projekttag am Dienstag geplant:",
content: "Ich habe deinen kompletten Projekttag am Dienstag geplant:",
proposedChanges: [
{
id: "multi-2-a",
@@ -119,8 +122,7 @@ const staticResponses: TestResponse[] = [
},
// Response 2: 2 wiederkehrende Termine
{
content:
"Ich erstelle dir zwei wiederkehrende Fitness-Termine:",
content: "Ich erstelle dir zwei wiederkehrende Fitness-Termine:",
proposedChanges: [
{
id: "multi-3-a",
@@ -209,7 +211,8 @@ const staticResponses: TestResponse[] = [
title: "Arzttermin Dr. Müller",
startTime: getDay("Wednesday", 1, 9, 30),
endTime: getDay("Wednesday", 1, 10, 30),
description: "Routineuntersuchung - Versichertenkarte nicht vergessen",
description:
"Routineuntersuchung - Versichertenkarte nicht vergessen",
},
},
],
@@ -403,6 +406,7 @@ export class ChatService {
constructor(
private chatRepo: ChatRepository,
private eventRepo: EventRepository,
private eventService: EventService,
private aiProvider: AIProvider,
) {}
@@ -462,9 +466,15 @@ export class ChatService {
event?: CreateEventDTO,
eventId?: string,
updates?: UpdateEventDTO,
deleteMode?: RecurringDeleteMode,
occurrenceDate?: string,
): Promise<ChatResponse> {
// Update specific proposal with respondedAction
await this.chatRepo.updateProposalResponse(messageId, proposalId, "confirm");
await this.chatRepo.updateProposalResponse(
messageId,
proposalId,
"confirm",
);
// Perform the actual event operation
let content: string;
@@ -478,10 +488,25 @@ export class ChatService {
? `Der Termin "${updatedEvent.title}" wurde aktualisiert.`
: "Termin nicht gefunden.";
} else if (action === "delete" && eventId) {
await this.eventRepo.delete(eventId);
// Use deleteRecurring for proper handling of recurring events
const mode = deleteMode || "all";
await this.eventService.deleteRecurring(
eventId,
userId,
mode,
occurrenceDate,
);
// Build appropriate response message
let deleteDescription = "";
if (deleteMode === "single") {
deleteDescription = " (dieses Vorkommen)";
} else if (deleteMode === "future") {
deleteDescription = " (dieses und zukünftige Vorkommen)";
}
content = event?.title
? `Der Termin "${event.title}" wurde gelöscht.`
: "Der Termin wurde gelöscht.";
? `Der Termin "${event.title}"${deleteDescription} wurde gelöscht.`
: `Der Termin${deleteDescription} wurde gelöscht.`;
} else {
content = "Ungültige Aktion.";
}

View File

@@ -3,7 +3,9 @@ import {
CreateEventDTO,
UpdateEventDTO,
ExpandedEvent,
RecurringDeleteMode,
} from "@calchat/shared";
import { RRule, rrulestr } from "rrule";
import { EventRepository } from "./interfaces";
import { expandRecurringEvents } from "../utils/recurrenceExpander";
@@ -67,4 +69,96 @@ export class EventService {
}
return this.eventRepo.delete(id);
}
/**
* Delete a recurring event with different modes:
* - 'all': Delete the entire event (all occurrences)
* - 'single': Add the occurrence date to exception list (EXDATE)
* - 'future': Set UNTIL in RRULE to stop future occurrences
*
* @returns Updated event for 'single'/'future' modes, null for 'all' mode or if not found
*/
async deleteRecurring(
id: string,
userId: string,
mode: RecurringDeleteMode,
occurrenceDate?: string,
): Promise<CalendarEvent | null> {
const event = await this.eventRepo.findById(id);
if (!event || event.userId !== userId) {
return null;
}
// For non-recurring events, always delete completely
if (!event.isRecurring || !event.recurrenceRule) {
await this.eventRepo.delete(id);
return null;
}
switch (mode) {
case "all":
await this.eventRepo.delete(id);
return null;
case "single":
if (!occurrenceDate) {
throw new Error("occurrenceDate required for single delete mode");
}
// Add to exception dates
return this.eventRepo.addExceptionDate(id, occurrenceDate);
case "future":
if (!occurrenceDate) {
throw new Error("occurrenceDate required for future delete mode");
}
// Check if this is the first occurrence
const startDateKey = this.formatDateKey(new Date(event.startTime));
if (occurrenceDate <= startDateKey) {
// Deleting from first occurrence = delete all
await this.eventRepo.delete(id);
return null;
}
// Set UNTIL to the day before the occurrence
const updatedRule = this.addUntilToRRule(
event.recurrenceRule,
occurrenceDate,
);
return this.eventRepo.update(id, { recurrenceRule: updatedRule });
default:
throw new Error(`Unknown delete mode: ${mode}`);
}
}
/**
* Add or replace UNTIL clause in an RRULE string.
* The UNTIL is set to 23:59:59 of the day before the occurrence date.
*/
private addUntilToRRule(ruleString: string, occurrenceDate: string): string {
// Normalize: ensure we have RRULE: prefix for parsing
const normalizedRule = ruleString.replace(/^RRULE:/i, "");
const parsedRule = rrulestr(`RRULE:${normalizedRule}`);
// Calculate the day before the occurrence at 23:59:59
const untilDate = new Date(occurrenceDate);
untilDate.setDate(untilDate.getDate() - 1);
untilDate.setHours(23, 59, 59, 0);
// Create new rule with UNTIL, removing COUNT (they're mutually exclusive)
const newRule = new RRule({
...parsedRule.options,
count: undefined,
until: untilDate,
});
// toString() returns "RRULE:...", we store without prefix
return newRule.toString().replace(/^RRULE:/, "");
}
private formatDateKey(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
}

View File

@@ -11,4 +11,5 @@ export interface EventRepository {
create(userId: string, data: CreateEventDTO): Promise<CalendarEvent>;
update(id: string, data: UpdateEventDTO): Promise<CalendarEvent | null>;
delete(id: string): Promise<boolean>;
addExceptionDate(id: string, date: string): Promise<CalendarEvent | null>;
}

View File

@@ -71,10 +71,19 @@ export function expandRecurringEvents(
true, // inclusive
);
// Build set of exception dates for fast lookup
const exceptionSet = new Set(event.exceptionDates || []);
for (const occurrence of occurrences) {
const occurrenceStart = fromRRuleDate(occurrence);
const occurrenceEnd = new Date(occurrenceStart.getTime() + duration);
// Skip if this occurrence is in the exception dates
const dateKey = formatDateKey(occurrenceStart);
if (exceptionSet.has(dateKey)) {
continue;
}
expanded.push({
...event,
occurrenceStart,
@@ -113,3 +122,11 @@ function formatRRuleDateString(date: Date): string {
const seconds = String(date.getSeconds()).padStart(2, "0");
return `${year}${month}${day}T${hours}${minutes}${seconds}`;
}
// Format date as YYYY-MM-DD for exception date comparison
function formatDateKey(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}

View File

@@ -8,10 +8,18 @@ export interface CalendarEvent {
note?: string;
isRecurring?: boolean;
recurrenceRule?: string;
exceptionDates?: string[]; // ISO date strings (YYYY-MM-DD) for excluded occurrences
createdAt?: Date;
updatedAt?: Date;
}
export type RecurringDeleteMode = "single" | "future" | "all";
export interface DeleteRecurringEventDTO {
mode: RecurringDeleteMode;
occurrenceDate?: string; // ISO date string of the occurrence to delete
}
export interface CreateEventDTO {
title: string;
description?: string;
@@ -30,6 +38,7 @@ export interface UpdateEventDTO {
note?: string;
isRecurring?: boolean;
recurrenceRule?: string;
exceptionDates?: string[];
}
export interface ExpandedEvent extends CalendarEvent {

View File

@@ -1,4 +1,8 @@
import { CreateEventDTO, UpdateEventDTO } from "./CalendarEvent";
import {
CreateEventDTO,
UpdateEventDTO,
RecurringDeleteMode,
} from "./CalendarEvent";
export type MessageSender = "user" | "assistant";
@@ -13,6 +17,8 @@ export interface ProposedEventChange {
event?: CreateEventDTO; // Required for create
updates?: UpdateEventDTO; // Required for update
respondedAction?: RespondedAction; // User's response to this specific proposal
deleteMode?: RecurringDeleteMode; // For recurring event deletion
occurrenceDate?: string; // ISO date string of specific occurrence for single/future delete
}
export interface ChatMessage {