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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user