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

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