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:
30
CLAUDE.md
30
CLAUDE.md
@@ -91,7 +91,8 @@ src/
|
||||
│ ├── EventCardBase.tsx # Shared event card layout with icons (used by EventCard & ProposedEventCard)
|
||||
│ ├── EventCard.tsx # Calendar event card (uses EventCardBase + edit/delete buttons)
|
||||
│ ├── EventConfirmDialog.tsx # AI-proposed event confirmation modal (skeleton)
|
||||
│ └── ProposedEventCard.tsx # Chat event proposal (uses EventCardBase + confirm/reject buttons)
|
||||
│ ├── ProposedEventCard.tsx # Chat event proposal (uses EventCardBase + confirm/reject buttons)
|
||||
│ └── DeleteEventModal.tsx # Unified delete confirmation modal (recurring: 3 options, non-recurring: simple confirm)
|
||||
├── Themes.tsx # Theme definitions: THEMES object with defaultLight/defaultDark, Theme type
|
||||
├── logging/
|
||||
│ ├── index.ts # Re-exports
|
||||
@@ -100,8 +101,8 @@ src/
|
||||
│ ├── index.ts # Re-exports all services
|
||||
│ ├── ApiClient.ts # HTTP client with X-User-Id header injection, request logging, handles empty responses (204)
|
||||
│ ├── AuthService.ts # login(), register(), logout() - calls API and updates AuthStore
|
||||
│ ├── EventService.ts # getAll(), getById(), getByDateRange(), create(), update(), delete()
|
||||
│ └── ChatService.ts # sendMessage(), confirmEvent(), rejectEvent(), getConversations(), getConversation()
|
||||
│ ├── EventService.ts # getAll(), getById(), getByDateRange(), create(), update(), delete(mode, occurrenceDate)
|
||||
│ └── ChatService.ts # sendMessage(), confirmEvent(deleteMode, occurrenceDate), rejectEvent(), getConversations(), getConversation()
|
||||
└── stores/ # Zustand state management
|
||||
├── index.ts # Re-exports all stores
|
||||
├── AuthStore.ts # user, isAuthenticated, isLoading, login(), logout(), loadStoredUser()
|
||||
@@ -224,7 +225,7 @@ src/
|
||||
- `GET /api/events/:id` - Get single event (protected)
|
||||
- `POST /api/events` - Create event (protected)
|
||||
- `PUT /api/events/:id` - Update event (protected)
|
||||
- `DELETE /api/events/:id` - Delete event (protected)
|
||||
- `DELETE /api/events/:id` - Delete event (protected, query params: mode, occurrenceDate for recurring)
|
||||
- `POST /api/chat/message` - Send message to AI (protected)
|
||||
- `POST /api/chat/confirm/:conversationId/:messageId` - Confirm proposed event (protected)
|
||||
- `POST /api/chat/reject/:conversationId/:messageId` - Reject proposed event (protected)
|
||||
@@ -254,12 +255,15 @@ src/
|
||||
|
||||
**Key Types:**
|
||||
- `User`: id, email, userName, passwordHash?, createdAt?, updatedAt?
|
||||
- `CalendarEvent`: id, userId, title, description?, startTime, endTime, note?, isRecurring?, recurrenceRule?
|
||||
- `CalendarEvent`: id, userId, title, description?, startTime, endTime, note?, isRecurring?, recurrenceRule?, exceptionDates?
|
||||
- `ExpandedEvent`: Extends CalendarEvent with occurrenceStart, occurrenceEnd (for recurring event instances)
|
||||
- `ChatMessage`: id, conversationId, sender ('user' | 'assistant'), content, proposedChanges?
|
||||
- `ProposedEventChange`: id, action ('create' | 'update' | 'delete'), eventId?, event?, updates?, respondedAction?
|
||||
- `ProposedEventChange`: id, action ('create' | 'update' | 'delete'), eventId?, event?, updates?, respondedAction?, deleteMode?, occurrenceDate?
|
||||
- Each proposal has unique `id` (e.g., "proposal-0") for individual confirm/reject
|
||||
- `respondedAction` tracks user response per proposal (not per message)
|
||||
- `deleteMode` ('single' | 'future' | 'all') and `occurrenceDate` for recurring event deletion
|
||||
- `RecurringDeleteMode`: 'single' | 'future' | 'all' - delete modes for recurring events
|
||||
- `DeleteRecurringEventDTO`: mode, occurrenceDate? - DTO for recurring event deletion
|
||||
- `Conversation`: id, userId, createdAt?, updatedAt? (messages loaded separately via lazy loading)
|
||||
- `CreateUserDTO`: email, userName, password (for registration)
|
||||
- `LoginDTO`: identifier (email OR userName), password
|
||||
@@ -393,9 +397,9 @@ NODE_ENV=development # development = pretty logs, production = JSON
|
||||
- `dotenv` integration for environment variables
|
||||
- `ChatController`: sendMessage(), confirmEvent(), rejectEvent()
|
||||
- `ChatService`: processMessage() with test responses (create, update, delete actions), confirmEvent() handles all CRUD actions
|
||||
- `MongoEventRepository`: Full CRUD implemented (findById, findByUserId, findByDateRange, create, update, delete)
|
||||
- `MongoEventRepository`: Full CRUD implemented (findById, findByUserId, findByDateRange, create, update, delete, addExceptionDate)
|
||||
- `EventController`: Full CRUD (create, getById, getAll, getByDateRange, update, delete)
|
||||
- `EventService`: Full CRUD with recurring event expansion via recurrenceExpander
|
||||
- `EventService`: Full CRUD with recurring event expansion via recurrenceExpander, deleteRecurring() with three modes (single/future/all)
|
||||
- `utils/eventFormatters`: getWeeksOverview(), getMonthOverview() with German localization
|
||||
- `utils/recurrenceExpander`: expandRecurringEvents() using rrule library for RRULE parsing
|
||||
- `ChatController`: getConversations(), getConversation() with cursor-based pagination support
|
||||
@@ -405,7 +409,7 @@ NODE_ENV=development # development = pretty logs, production = JSON
|
||||
- `GPTAdapter`: Full implementation with OpenAI GPT (gpt-4o-mini model), function calling for calendar operations, collects multiple proposals per response
|
||||
- `ai/utils/`: Provider-agnostic shared utilities (systemPrompt, toolDefinitions, toolExecutor, eventFormatter)
|
||||
- `ai/utils/systemPrompt`: Includes RRULE documentation - AI knows to create separate events when times differ by day
|
||||
- `utils/recurrenceExpander`: Handles RRULE parsing, strips `RRULE:` prefix if present (AI may include it)
|
||||
- `utils/recurrenceExpander`: Handles RRULE parsing, strips `RRULE:` prefix if present (AI may include it), filters out exceptionDates
|
||||
- `logging/`: Structured logging with pino, pino-http middleware, @Logged decorator
|
||||
- All repositories and GPTAdapter decorated with @Logged for automatic method logging
|
||||
- CORS configured to allow X-User-Id header
|
||||
@@ -443,6 +447,7 @@ NODE_ENV=development # development = pretty logs, production = JSON
|
||||
- Tap-to-open modal overlay showing EventCards for selected day
|
||||
- Supports events from adjacent months visible in grid
|
||||
- Uses `useFocusEffect` for automatic reload on tab focus
|
||||
- DeleteEventModal integration for recurring event deletion with three modes
|
||||
- Chat screen fully functional with FlashList, message sending, and event confirm/reject
|
||||
- **Multiple event proposals**: AI can propose multiple events in one response
|
||||
- Arrow navigation between proposals with "Event X von Y" counter
|
||||
@@ -454,11 +459,12 @@ NODE_ENV=development # development = pretty logs, production = JSON
|
||||
- KeyboardAvoidingView for proper keyboard handling (iOS padding, Android height)
|
||||
- Auto-scroll to end on new messages and keyboard show
|
||||
- keyboardDismissMode="interactive" and keyboardShouldPersistTaps="handled"
|
||||
- `EventService`: getAll(), getById(), getByDateRange(), create(), update(), delete() - fully implemented
|
||||
- `ChatService`: sendMessage(), confirmEvent(), rejectEvent(), getConversations(), getConversation() - fully implemented with cursor pagination
|
||||
- `EventService`: getAll(), getById(), getByDateRange(), create(), update(), delete(mode, occurrenceDate) - fully implemented with recurring delete modes
|
||||
- `ChatService`: sendMessage(), confirmEvent(deleteMode, occurrenceDate), rejectEvent(), getConversations(), getConversation() - fully implemented with cursor pagination and recurring delete support
|
||||
- `EventCardBase`: Shared base component with event layout (header, date/time/recurring icons, description) - used by both EventCard and ProposedEventCard
|
||||
- `EventCard`: Uses EventCardBase + edit/delete buttons for calendar display
|
||||
- `ProposedEventCard`: Uses EventCardBase + confirm/reject buttons for chat proposals (supports create/update/delete actions)
|
||||
- `ProposedEventCard`: Uses EventCardBase + confirm/reject buttons for chat proposals (supports create/update/delete actions with deleteMode display)
|
||||
- `DeleteEventModal`: Unified delete confirmation modal - shows three options for recurring events (single/future/all), simple confirm for non-recurring
|
||||
- `Themes.tsx`: Theme definitions with THEMES object (defaultLight, defaultDark) including all color tokens (textPrimary, borderPrimary, eventIndicator, secondaryBg, shadowColor, etc.)
|
||||
- `EventsStore`: Zustand store with setEvents(), addEvent(), updateEvent(), deleteEvent() - stores ExpandedEvent[]
|
||||
- `ChatStore`: Zustand store with addMessage(), addMessages(), updateMessage(), clearMessages(), isWaitingForResponse/setWaitingForResponse() for typing indicator - loads from server on mount and persists across tab switches
|
||||
|
||||
@@ -56,5 +56,5 @@ export const THEMES = {
|
||||
eventIndicator: "#DE6C20",
|
||||
borderPrimary: "#FFFFFF",
|
||||
shadowColor: "#FFFFFF",
|
||||
}
|
||||
},
|
||||
} as const satisfies Record<string, Theme>;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
162
apps/client/src/components/DeleteEventModal.tsx
Normal file
162
apps/client/src/components/DeleteEventModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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] }),
|
||||
}));
|
||||
|
||||
@@ -140,7 +140,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
||||
{
|
||||
name: "proposeDeleteEvent",
|
||||
description:
|
||||
"Propose deleting an event. The user must confirm. Use this when the user wants to remove an appointment.",
|
||||
"Propose deleting an event. The user must confirm. Use this when the user wants to remove an appointment. For recurring events, specify deleteMode to control which occurrences to delete.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -148,6 +148,17 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
||||
type: "string",
|
||||
description: "ID of the event to delete",
|
||||
},
|
||||
deleteMode: {
|
||||
type: "string",
|
||||
enum: ["single", "future", "all"],
|
||||
description:
|
||||
"For recurring events: 'single' = only this occurrence, 'future' = this and all future, 'all' = entire recurring event. Defaults to 'all' for non-recurring events.",
|
||||
},
|
||||
occurrenceDate: {
|
||||
type: "string",
|
||||
description:
|
||||
"ISO date string (YYYY-MM-DD) of the specific occurrence to delete. Required for 'single' and 'future' modes.",
|
||||
},
|
||||
},
|
||||
required: ["eventId"],
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
getDay,
|
||||
Day,
|
||||
DAY_TO_GERMAN,
|
||||
RecurringDeleteMode,
|
||||
} from "@calchat/shared";
|
||||
import { AIContext } from "../../services/interfaces";
|
||||
import { formatDate, formatTime, formatDateTime } from "./eventFormatter";
|
||||
@@ -111,6 +112,8 @@ export function executeToolCall(
|
||||
|
||||
case "proposeDeleteEvent": {
|
||||
const eventId = args.eventId as string;
|
||||
const deleteMode = (args.deleteMode as RecurringDeleteMode) || "all";
|
||||
const occurrenceDate = args.occurrenceDate as string | undefined;
|
||||
const existingEvent = context.existingEvents.find(
|
||||
(e) => e.id === eventId,
|
||||
);
|
||||
@@ -119,8 +122,24 @@ export function executeToolCall(
|
||||
return { content: `Event mit ID ${eventId} nicht gefunden.` };
|
||||
}
|
||||
|
||||
// Build descriptive content based on delete mode
|
||||
let modeDescription = "";
|
||||
if (existingEvent.isRecurring) {
|
||||
switch (deleteMode) {
|
||||
case "single":
|
||||
modeDescription = " (nur dieses Vorkommen)";
|
||||
break;
|
||||
case "future":
|
||||
modeDescription = " (dieses und alle zukünftigen Vorkommen)";
|
||||
break;
|
||||
case "all":
|
||||
modeDescription = " (alle Vorkommen)";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: `Lösch-Vorschlag für "${existingEvent.title}" erstellt.`,
|
||||
content: `Lösch-Vorschlag für "${existingEvent.title}"${modeDescription} erstellt.`,
|
||||
proposedChange: {
|
||||
action: "delete",
|
||||
eventId,
|
||||
@@ -131,6 +150,10 @@ export function executeToolCall(
|
||||
description: existingEvent.description,
|
||||
isRecurring: existingEvent.isRecurring,
|
||||
},
|
||||
deleteMode: existingEvent.isRecurring ? deleteMode : undefined,
|
||||
occurrenceDate: existingEvent.isRecurring
|
||||
? occurrenceDate
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -35,7 +35,10 @@ if (process.env.NODE_ENV !== "production") {
|
||||
"Access-Control-Allow-Methods",
|
||||
"GET, POST, PUT, DELETE, OPTIONS",
|
||||
);
|
||||
res.header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-User-Id");
|
||||
res.header(
|
||||
"Access-Control-Allow-Headers",
|
||||
"Content-Type, Authorization, X-User-Id",
|
||||
);
|
||||
if (req.method === "OPTIONS") {
|
||||
res.sendStatus(200);
|
||||
return;
|
||||
@@ -54,8 +57,13 @@ const aiProvider = new GPTAdapter();
|
||||
|
||||
// Initialize services
|
||||
const authService = new AuthService(userRepo);
|
||||
const chatService = new ChatService(chatRepo, eventRepo, aiProvider);
|
||||
const eventService = new EventService(eventRepo);
|
||||
const chatService = new ChatService(
|
||||
chatRepo,
|
||||
eventRepo,
|
||||
eventService,
|
||||
aiProvider,
|
||||
);
|
||||
|
||||
// Initialize controllers
|
||||
const authController = new AuthController(authService);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
UpdateEventDTO,
|
||||
EventAction,
|
||||
GetMessagesOptions,
|
||||
RecurringDeleteMode,
|
||||
} from "@calchat/shared";
|
||||
import { ChatService } from "../services";
|
||||
import { createLogger } from "../logging";
|
||||
@@ -22,7 +23,10 @@ export class ChatController {
|
||||
const response = await this.chatService.processMessage(userId, data);
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
log.error({ error, userId: req.user?.userId }, "Error processing message");
|
||||
log.error(
|
||||
{ error, userId: req.user?.userId },
|
||||
"Error processing message",
|
||||
);
|
||||
res.status(500).json({ error: "Failed to process message" });
|
||||
}
|
||||
}
|
||||
@@ -31,12 +35,22 @@ export class ChatController {
|
||||
try {
|
||||
const userId = req.user!.userId;
|
||||
const { conversationId, messageId } = req.params;
|
||||
const { proposalId, action, event, eventId, updates } = req.body as {
|
||||
const {
|
||||
proposalId,
|
||||
action,
|
||||
event,
|
||||
eventId,
|
||||
updates,
|
||||
deleteMode,
|
||||
occurrenceDate,
|
||||
} = req.body as {
|
||||
proposalId: string;
|
||||
action: EventAction;
|
||||
event?: CreateEventDTO;
|
||||
eventId?: string;
|
||||
updates?: UpdateEventDTO;
|
||||
deleteMode?: RecurringDeleteMode;
|
||||
occurrenceDate?: string;
|
||||
};
|
||||
const response = await this.chatService.confirmEvent(
|
||||
userId,
|
||||
@@ -47,10 +61,15 @@ export class ChatController {
|
||||
event,
|
||||
eventId,
|
||||
updates,
|
||||
deleteMode,
|
||||
occurrenceDate,
|
||||
);
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
log.error({ error, conversationId: req.params.conversationId }, "Error confirming event");
|
||||
log.error(
|
||||
{ error, conversationId: req.params.conversationId },
|
||||
"Error confirming event",
|
||||
);
|
||||
res.status(500).json({ error: "Failed to confirm event" });
|
||||
}
|
||||
}
|
||||
@@ -68,7 +87,10 @@ export class ChatController {
|
||||
);
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
log.error({ error, conversationId: req.params.conversationId }, "Error rejecting event");
|
||||
log.error(
|
||||
{ error, conversationId: req.params.conversationId },
|
||||
"Error rejecting event",
|
||||
);
|
||||
res.status(500).json({ error: "Failed to reject event" });
|
||||
}
|
||||
}
|
||||
@@ -82,7 +104,10 @@ export class ChatController {
|
||||
const conversations = await this.chatService.getConversations(userId);
|
||||
res.json(conversations);
|
||||
} catch (error) {
|
||||
log.error({ error, userId: req.user?.userId }, "Error getting conversations");
|
||||
log.error(
|
||||
{ error, userId: req.user?.userId },
|
||||
"Error getting conversations",
|
||||
);
|
||||
res.status(500).json({ error: "Failed to get conversations" });
|
||||
}
|
||||
}
|
||||
@@ -113,7 +138,10 @@ export class ChatController {
|
||||
if ((error as Error).message === "Conversation not found") {
|
||||
res.status(404).json({ error: "Conversation not found" });
|
||||
} else {
|
||||
log.error({ error, conversationId: req.params.id }, "Error getting conversation");
|
||||
log.error(
|
||||
{ error, conversationId: req.params.id },
|
||||
"Error getting conversation",
|
||||
);
|
||||
res.status(500).json({ error: "Failed to get conversation" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Response } from "express";
|
||||
import { RecurringDeleteMode } from "@calchat/shared";
|
||||
import { EventService } from "../services";
|
||||
import { createLogger } from "../logging";
|
||||
import { AuthenticatedRequest } from "./AuthMiddleware";
|
||||
@@ -72,7 +73,10 @@ export class EventController {
|
||||
);
|
||||
res.json(events);
|
||||
} catch (error) {
|
||||
log.error({ error, start: req.query.start, end: req.query.end }, "Error getting events by range");
|
||||
log.error(
|
||||
{ error, start: req.query.start, end: req.query.end },
|
||||
"Error getting events by range",
|
||||
);
|
||||
res.status(500).json({ error: "Failed to get events" });
|
||||
}
|
||||
}
|
||||
@@ -97,6 +101,38 @@ export class EventController {
|
||||
|
||||
async delete(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const { mode, occurrenceDate } = req.query as {
|
||||
mode?: RecurringDeleteMode;
|
||||
occurrenceDate?: string;
|
||||
};
|
||||
|
||||
// If mode is specified, use deleteRecurring
|
||||
if (mode) {
|
||||
const result = await this.eventService.deleteRecurring(
|
||||
req.params.id,
|
||||
req.user!.userId,
|
||||
mode,
|
||||
occurrenceDate,
|
||||
);
|
||||
|
||||
// For 'all' mode or when event was completely deleted, return 204
|
||||
if (result === null && mode === "all") {
|
||||
res.status(204).send();
|
||||
return;
|
||||
}
|
||||
|
||||
// For 'single' or 'future' modes, return updated event
|
||||
if (result) {
|
||||
res.json(result);
|
||||
return;
|
||||
}
|
||||
|
||||
// result is null but mode wasn't 'all' - event not found or was deleted
|
||||
res.status(204).send();
|
||||
return;
|
||||
}
|
||||
|
||||
// Default behavior: delete completely
|
||||
const deleted = await this.eventService.delete(
|
||||
req.params.id,
|
||||
req.user!.userId,
|
||||
|
||||
@@ -80,7 +80,10 @@ export function Logged(name: string) {
|
||||
const method = String(propKey);
|
||||
|
||||
// Summarize args to avoid huge log entries
|
||||
log.debug({ method, args: summarizeArgs(methodArgs) }, `${method} started`);
|
||||
log.debug(
|
||||
{ method, args: summarizeArgs(methodArgs) },
|
||||
`${method} started`,
|
||||
);
|
||||
|
||||
const logCompletion = (err?: unknown) => {
|
||||
const duration = Math.round(performance.now() - start);
|
||||
|
||||
@@ -47,4 +47,17 @@ export class MongoEventRepository implements EventRepository {
|
||||
const result = await EventModel.findByIdAndDelete(id);
|
||||
return result !== null;
|
||||
}
|
||||
|
||||
async addExceptionDate(
|
||||
id: string,
|
||||
date: string,
|
||||
): Promise<CalendarEvent | null> {
|
||||
const event = await EventModel.findByIdAndUpdate(
|
||||
id,
|
||||
{ $addToSet: { exceptionDates: date } },
|
||||
{ new: true },
|
||||
);
|
||||
if (!event) return null;
|
||||
return event.toJSON() as unknown as CalendarEvent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,10 @@ const EventSchema = new Schema<
|
||||
recurrenceRule: {
|
||||
type: String,
|
||||
},
|
||||
exceptionDates: {
|
||||
type: [String],
|
||||
default: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
|
||||
@@ -10,11 +10,16 @@ import {
|
||||
UpdateEventDTO,
|
||||
EventAction,
|
||||
CreateMessageDTO,
|
||||
RecurringDeleteMode,
|
||||
} from "@calchat/shared";
|
||||
import { ChatRepository, EventRepository, AIProvider } from "./interfaces";
|
||||
import { EventService } from "./EventService";
|
||||
import { getWeeksOverview, getMonthOverview } from "../utils/eventFormatters";
|
||||
|
||||
type TestResponse = { content: string; proposedChanges?: ProposedEventChange[] };
|
||||
type TestResponse = {
|
||||
content: string;
|
||||
proposedChanges?: ProposedEventChange[];
|
||||
};
|
||||
|
||||
// Test response index (cycles through responses)
|
||||
let responseIndex = 0;
|
||||
@@ -25,8 +30,7 @@ const staticResponses: TestResponse[] = [
|
||||
// === MULTI-EVENT TEST RESPONSES ===
|
||||
// Response 0: 3 Meetings an verschiedenen Tagen
|
||||
{
|
||||
content:
|
||||
"Alles klar! Ich erstelle dir 3 Team-Meetings für diese Woche:",
|
||||
content: "Alles klar! Ich erstelle dir 3 Team-Meetings für diese Woche:",
|
||||
proposedChanges: [
|
||||
{
|
||||
id: "multi-1-a",
|
||||
@@ -62,8 +66,7 @@ const staticResponses: TestResponse[] = [
|
||||
},
|
||||
// Response 1: 5 Termine für einen Projekttag
|
||||
{
|
||||
content:
|
||||
"Ich habe deinen kompletten Projekttag am Dienstag geplant:",
|
||||
content: "Ich habe deinen kompletten Projekttag am Dienstag geplant:",
|
||||
proposedChanges: [
|
||||
{
|
||||
id: "multi-2-a",
|
||||
@@ -119,8 +122,7 @@ const staticResponses: TestResponse[] = [
|
||||
},
|
||||
// Response 2: 2 wiederkehrende Termine
|
||||
{
|
||||
content:
|
||||
"Ich erstelle dir zwei wiederkehrende Fitness-Termine:",
|
||||
content: "Ich erstelle dir zwei wiederkehrende Fitness-Termine:",
|
||||
proposedChanges: [
|
||||
{
|
||||
id: "multi-3-a",
|
||||
@@ -209,7 +211,8 @@ const staticResponses: TestResponse[] = [
|
||||
title: "Arzttermin Dr. Müller",
|
||||
startTime: getDay("Wednesday", 1, 9, 30),
|
||||
endTime: getDay("Wednesday", 1, 10, 30),
|
||||
description: "Routineuntersuchung - Versichertenkarte nicht vergessen",
|
||||
description:
|
||||
"Routineuntersuchung - Versichertenkarte nicht vergessen",
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -403,6 +406,7 @@ export class ChatService {
|
||||
constructor(
|
||||
private chatRepo: ChatRepository,
|
||||
private eventRepo: EventRepository,
|
||||
private eventService: EventService,
|
||||
private aiProvider: AIProvider,
|
||||
) {}
|
||||
|
||||
@@ -462,9 +466,15 @@ export class ChatService {
|
||||
event?: CreateEventDTO,
|
||||
eventId?: string,
|
||||
updates?: UpdateEventDTO,
|
||||
deleteMode?: RecurringDeleteMode,
|
||||
occurrenceDate?: string,
|
||||
): Promise<ChatResponse> {
|
||||
// Update specific proposal with respondedAction
|
||||
await this.chatRepo.updateProposalResponse(messageId, proposalId, "confirm");
|
||||
await this.chatRepo.updateProposalResponse(
|
||||
messageId,
|
||||
proposalId,
|
||||
"confirm",
|
||||
);
|
||||
|
||||
// Perform the actual event operation
|
||||
let content: string;
|
||||
@@ -478,10 +488,25 @@ export class ChatService {
|
||||
? `Der Termin "${updatedEvent.title}" wurde aktualisiert.`
|
||||
: "Termin nicht gefunden.";
|
||||
} else if (action === "delete" && eventId) {
|
||||
await this.eventRepo.delete(eventId);
|
||||
// Use deleteRecurring for proper handling of recurring events
|
||||
const mode = deleteMode || "all";
|
||||
await this.eventService.deleteRecurring(
|
||||
eventId,
|
||||
userId,
|
||||
mode,
|
||||
occurrenceDate,
|
||||
);
|
||||
|
||||
// Build appropriate response message
|
||||
let deleteDescription = "";
|
||||
if (deleteMode === "single") {
|
||||
deleteDescription = " (dieses Vorkommen)";
|
||||
} else if (deleteMode === "future") {
|
||||
deleteDescription = " (dieses und zukünftige Vorkommen)";
|
||||
}
|
||||
content = event?.title
|
||||
? `Der Termin "${event.title}" wurde gelöscht.`
|
||||
: "Der Termin wurde gelöscht.";
|
||||
? `Der Termin "${event.title}"${deleteDescription} wurde gelöscht.`
|
||||
: `Der Termin${deleteDescription} wurde gelöscht.`;
|
||||
} else {
|
||||
content = "Ungültige Aktion.";
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ import {
|
||||
CreateEventDTO,
|
||||
UpdateEventDTO,
|
||||
ExpandedEvent,
|
||||
RecurringDeleteMode,
|
||||
} from "@calchat/shared";
|
||||
import { RRule, rrulestr } from "rrule";
|
||||
import { EventRepository } from "./interfaces";
|
||||
import { expandRecurringEvents } from "../utils/recurrenceExpander";
|
||||
|
||||
@@ -67,4 +69,96 @@ export class EventService {
|
||||
}
|
||||
return this.eventRepo.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a recurring event with different modes:
|
||||
* - 'all': Delete the entire event (all occurrences)
|
||||
* - 'single': Add the occurrence date to exception list (EXDATE)
|
||||
* - 'future': Set UNTIL in RRULE to stop future occurrences
|
||||
*
|
||||
* @returns Updated event for 'single'/'future' modes, null for 'all' mode or if not found
|
||||
*/
|
||||
async deleteRecurring(
|
||||
id: string,
|
||||
userId: string,
|
||||
mode: RecurringDeleteMode,
|
||||
occurrenceDate?: string,
|
||||
): Promise<CalendarEvent | null> {
|
||||
const event = await this.eventRepo.findById(id);
|
||||
if (!event || event.userId !== userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// For non-recurring events, always delete completely
|
||||
if (!event.isRecurring || !event.recurrenceRule) {
|
||||
await this.eventRepo.delete(id);
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (mode) {
|
||||
case "all":
|
||||
await this.eventRepo.delete(id);
|
||||
return null;
|
||||
|
||||
case "single":
|
||||
if (!occurrenceDate) {
|
||||
throw new Error("occurrenceDate required for single delete mode");
|
||||
}
|
||||
// Add to exception dates
|
||||
return this.eventRepo.addExceptionDate(id, occurrenceDate);
|
||||
|
||||
case "future":
|
||||
if (!occurrenceDate) {
|
||||
throw new Error("occurrenceDate required for future delete mode");
|
||||
}
|
||||
// Check if this is the first occurrence
|
||||
const startDateKey = this.formatDateKey(new Date(event.startTime));
|
||||
if (occurrenceDate <= startDateKey) {
|
||||
// Deleting from first occurrence = delete all
|
||||
await this.eventRepo.delete(id);
|
||||
return null;
|
||||
}
|
||||
// Set UNTIL to the day before the occurrence
|
||||
const updatedRule = this.addUntilToRRule(
|
||||
event.recurrenceRule,
|
||||
occurrenceDate,
|
||||
);
|
||||
return this.eventRepo.update(id, { recurrenceRule: updatedRule });
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown delete mode: ${mode}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or replace UNTIL clause in an RRULE string.
|
||||
* The UNTIL is set to 23:59:59 of the day before the occurrence date.
|
||||
*/
|
||||
private addUntilToRRule(ruleString: string, occurrenceDate: string): string {
|
||||
// Normalize: ensure we have RRULE: prefix for parsing
|
||||
const normalizedRule = ruleString.replace(/^RRULE:/i, "");
|
||||
const parsedRule = rrulestr(`RRULE:${normalizedRule}`);
|
||||
|
||||
// Calculate the day before the occurrence at 23:59:59
|
||||
const untilDate = new Date(occurrenceDate);
|
||||
untilDate.setDate(untilDate.getDate() - 1);
|
||||
untilDate.setHours(23, 59, 59, 0);
|
||||
|
||||
// Create new rule with UNTIL, removing COUNT (they're mutually exclusive)
|
||||
const newRule = new RRule({
|
||||
...parsedRule.options,
|
||||
count: undefined,
|
||||
until: untilDate,
|
||||
});
|
||||
|
||||
// toString() returns "RRULE:...", we store without prefix
|
||||
return newRule.toString().replace(/^RRULE:/, "");
|
||||
}
|
||||
|
||||
private formatDateKey(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,4 +11,5 @@ export interface EventRepository {
|
||||
create(userId: string, data: CreateEventDTO): Promise<CalendarEvent>;
|
||||
update(id: string, data: UpdateEventDTO): Promise<CalendarEvent | null>;
|
||||
delete(id: string): Promise<boolean>;
|
||||
addExceptionDate(id: string, date: string): Promise<CalendarEvent | null>;
|
||||
}
|
||||
|
||||
@@ -71,10 +71,19 @@ export function expandRecurringEvents(
|
||||
true, // inclusive
|
||||
);
|
||||
|
||||
// Build set of exception dates for fast lookup
|
||||
const exceptionSet = new Set(event.exceptionDates || []);
|
||||
|
||||
for (const occurrence of occurrences) {
|
||||
const occurrenceStart = fromRRuleDate(occurrence);
|
||||
const occurrenceEnd = new Date(occurrenceStart.getTime() + duration);
|
||||
|
||||
// Skip if this occurrence is in the exception dates
|
||||
const dateKey = formatDateKey(occurrenceStart);
|
||||
if (exceptionSet.has(dateKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
expanded.push({
|
||||
...event,
|
||||
occurrenceStart,
|
||||
@@ -113,3 +122,11 @@ function formatRRuleDateString(date: Date): string {
|
||||
const seconds = String(date.getSeconds()).padStart(2, "0");
|
||||
return `${year}${month}${day}T${hours}${minutes}${seconds}`;
|
||||
}
|
||||
|
||||
// Format date as YYYY-MM-DD for exception date comparison
|
||||
function formatDateKey(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
@@ -8,10 +8,18 @@ export interface CalendarEvent {
|
||||
note?: string;
|
||||
isRecurring?: boolean;
|
||||
recurrenceRule?: string;
|
||||
exceptionDates?: string[]; // ISO date strings (YYYY-MM-DD) for excluded occurrences
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
export type RecurringDeleteMode = "single" | "future" | "all";
|
||||
|
||||
export interface DeleteRecurringEventDTO {
|
||||
mode: RecurringDeleteMode;
|
||||
occurrenceDate?: string; // ISO date string of the occurrence to delete
|
||||
}
|
||||
|
||||
export interface CreateEventDTO {
|
||||
title: string;
|
||||
description?: string;
|
||||
@@ -30,6 +38,7 @@ export interface UpdateEventDTO {
|
||||
note?: string;
|
||||
isRecurring?: boolean;
|
||||
recurrenceRule?: string;
|
||||
exceptionDates?: string[];
|
||||
}
|
||||
|
||||
export interface ExpandedEvent extends CalendarEvent {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { CreateEventDTO, UpdateEventDTO } from "./CalendarEvent";
|
||||
import {
|
||||
CreateEventDTO,
|
||||
UpdateEventDTO,
|
||||
RecurringDeleteMode,
|
||||
} from "./CalendarEvent";
|
||||
|
||||
export type MessageSender = "user" | "assistant";
|
||||
|
||||
@@ -13,6 +17,8 @@ export interface ProposedEventChange {
|
||||
event?: CreateEventDTO; // Required for create
|
||||
updates?: UpdateEventDTO; // Required for update
|
||||
respondedAction?: RespondedAction; // User's response to this specific proposal
|
||||
deleteMode?: RecurringDeleteMode; // For recurring event deletion
|
||||
occurrenceDate?: string; // ISO date string of specific occurrence for single/future delete
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
|
||||
Reference in New Issue
Block a user