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

@@ -56,5 +56,5 @@ export const THEMES = {
eventIndicator: "#DE6C20",
borderPrimary: "#FFFFFF",
shadowColor: "#FFFFFF",
}
},
} as const satisfies Record<string, Theme>;

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

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}

View File

@@ -50,11 +50,15 @@ async function request<T>(
const duration = Math.round(performance.now() - start);
if (!response.ok) {
apiLogger.error(`${method} ${endpoint} - ${response.status} (${duration}ms)`);
apiLogger.error(
`${method} ${endpoint} - ${response.status} (${duration}ms)`,
);
throw new Error(`HTTP ${response.status}`);
}
apiLogger.debug(`${method} ${endpoint} - ${response.status} (${duration}ms)`);
apiLogger.debug(
`${method} ${endpoint} - ${response.status} (${duration}ms)`,
);
const text = await response.text();
if (!text) {

View File

@@ -7,6 +7,7 @@ import {
CreateEventDTO,
UpdateEventDTO,
EventAction,
RecurringDeleteMode,
} from "@calchat/shared";
import { ApiClient } from "./ApiClient";
@@ -16,6 +17,8 @@ interface ConfirmEventRequest {
event?: CreateEventDTO;
eventId?: string;
updates?: UpdateEventDTO;
deleteMode?: RecurringDeleteMode;
occurrenceDate?: string;
}
interface RejectEventRequest {
@@ -35,6 +38,8 @@ export const ChatService = {
event?: CreateEventDTO,
eventId?: string,
updates?: UpdateEventDTO,
deleteMode?: RecurringDeleteMode,
occurrenceDate?: string,
): Promise<ChatResponse> => {
const body: ConfirmEventRequest = {
proposalId,
@@ -42,6 +47,8 @@ export const ChatService = {
event,
eventId,
updates,
deleteMode,
occurrenceDate,
};
return ApiClient.post<ChatResponse>(
`/chat/confirm/${conversationId}/${messageId}`,

View File

@@ -3,6 +3,7 @@ import {
CreateEventDTO,
UpdateEventDTO,
ExpandedEvent,
RecurringDeleteMode,
} from "@calchat/shared";
import { ApiClient } from "./ApiClient";
@@ -29,7 +30,18 @@ export const EventService = {
return ApiClient.put<CalendarEvent>(`/events/${id}`, data);
},
delete: async (id: string): Promise<void> => {
return ApiClient.delete(`/events/${id}`);
delete: async (
id: string,
mode?: RecurringDeleteMode,
occurrenceDate?: string,
): Promise<CalendarEvent | void> => {
const params = new URLSearchParams();
if (mode) params.append("mode", mode);
if (occurrenceDate) params.append("occurrenceDate", occurrenceDate);
const queryString = params.toString();
const url = `/events/${id}${queryString ? `?${queryString}` : ""}`;
return ApiClient.delete(url);
},
};

View File

@@ -8,5 +8,5 @@ interface ThemeState {
export const useThemeStore = create<ThemeState>((set) => ({
theme: THEMES.defaultLight,
setTheme: (themeName) => set({theme: THEMES[themeName]})
}))
setTheme: (themeName) => set({ theme: THEMES[themeName] }),
}));