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

@@ -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>
)}