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

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