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

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