From 6f0d172bf2d418309c55ba60cd712d4e0d8296f8 Mon Sep 17 00:00:00 2001 From: Linus Waldowsky Date: Sat, 31 Jan 2026 18:46:31 +0100 Subject: [PATCH] feat: add EditEventScreen with calendar and chat mode support Add a unified event editor that works in two modes: - Calendar mode: Create/edit events directly via EventService API - Chat mode: Edit AI-proposed events before confirming them The chat mode allows users to modify proposed events (title, time, recurrence) and persists changes both locally and to the server. New components: DateTimePicker, ScrollableDropdown, useDropdownPosition New API: PUT /api/chat/messages/:messageId/proposal --- CLAUDE.md | 41 +- apps/client/package.json | 2 + apps/client/src/Themes.tsx | 4 +- apps/client/src/app/(tabs)/calendar.tsx | 282 +++++---- apps/client/src/app/(tabs)/chat.tsx | 43 +- apps/client/src/app/(tabs)/settings.tsx | 8 +- apps/client/src/app/_layout.tsx | 5 +- apps/client/src/app/editEvent.tsx | 597 ++++++++++++++++++ apps/client/src/components/CardBase.tsx | 4 + apps/client/src/components/DateTimePicker.tsx | 136 ++++ apps/client/src/components/EventCardBase.tsx | 52 +- apps/client/src/components/Header.tsx | 55 +- apps/client/src/components/ModalBase.tsx | 3 + .../src/components/ProposedEventCard.tsx | 36 +- .../src/components/ScrollableDropdown.tsx | 107 ++++ apps/client/src/hooks/useDropdownPosition.ts | 37 ++ apps/client/src/services/ChatService.ts | 11 + apps/server/src/ai/utils/toolDefinitions.ts | 8 - apps/server/src/ai/utils/toolExecutor.ts | 12 +- apps/server/src/controllers/ChatController.ts | 29 + .../repositories/mongo/MongoChatRepository.ts | 14 + .../repositories/mongo/models/ChatModel.ts | 2 - .../repositories/mongo/models/EventModel.ts | 21 +- apps/server/src/routes/chat.routes.ts | 3 + apps/server/src/services/ChatService.ts | 19 +- .../src/services/interfaces/ChatRepository.ts | 7 + apps/server/src/utils/recurrenceExpander.ts | 19 +- package-lock.json | 27 + packages/shared/src/models/CalendarEvent.ts | 2 - packages/shared/src/utils/dateHelpers.ts | 12 + packages/shared/src/utils/formatters.ts | 23 + packages/shared/src/utils/index.ts | 2 +- packages/shared/src/utils/rruleHelpers.ts | 60 +- 33 files changed, 1394 insertions(+), 289 deletions(-) create mode 100644 apps/client/src/app/editEvent.tsx create mode 100644 apps/client/src/components/DateTimePicker.tsx create mode 100644 apps/client/src/components/ScrollableDropdown.tsx create mode 100644 apps/client/src/hooks/useDropdownPosition.ts diff --git a/CLAUDE.md b/CLAUDE.md index 0c58e24..676f306 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,6 +76,7 @@ src/ │ │ ├── chat.tsx # Chat screen (AI conversation) │ │ ├── calendar.tsx # Calendar overview │ │ └── settings.tsx # Settings screen (theme switcher, logout) +│ ├── editEvent.tsx # Event edit screen (dual-mode: calendar/chat) │ ├── event/ │ │ └── [id].tsx # Event detail screen (dynamic route) │ └── note/ @@ -93,8 +94,10 @@ src/ │ ├── EventCardBase.tsx # Event card layout with icons (uses CardBase) │ ├── 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) -│ └── DeleteEventModal.tsx # Delete confirmation modal (uses ModalBase) +│ ├── ProposedEventCard.tsx # Chat event proposal (uses EventCardBase + confirm/reject/edit buttons) +│ ├── DeleteEventModal.tsx # Delete confirmation modal (uses ModalBase) +│ ├── DateTimePicker.tsx # Date and time picker components +│ └── ScrollableDropdown.tsx # Scrollable dropdown component ├── Themes.tsx # Theme definitions: THEMES object with defaultLight/defaultDark, Theme type ├── logging/ │ ├── index.ts # Re-exports @@ -104,14 +107,16 @@ src/ │ ├── 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(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() - │ # Uses expo-secure-store (native) / localStorage (web) - ├── ChatStore.ts # messages[], isWaitingForResponse, addMessage(), addMessages(), updateMessage(), clearMessages(), setWaitingForResponse(), chatMessageToMessageData() - ├── EventsStore.ts # events[], setEvents(), addEvent(), updateEvent(), deleteEvent() - └── ThemeStore.ts # theme, setTheme() - reactive theme switching with Zustand +│ └── ChatService.ts # sendMessage(), confirmEvent(deleteMode, occurrenceDate), rejectEvent(), getConversations(), getConversation(), updateProposalEvent() +├── stores/ # Zustand state management +│ ├── index.ts # Re-exports all stores +│ ├── AuthStore.ts # user, isAuthenticated, isLoading, login(), logout(), loadStoredUser() +│ │ # Uses expo-secure-store (native) / localStorage (web) +│ ├── ChatStore.ts # messages[], isWaitingForResponse, addMessage(), addMessages(), updateMessage(), clearMessages(), setWaitingForResponse(), chatMessageToMessageData() +│ ├── EventsStore.ts # events[], setEvents(), addEvent(), updateEvent(), deleteEvent() +│ └── ThemeStore.ts # theme, setTheme() - reactive theme switching with Zustand +└── hooks/ + └── useDropdownPosition.ts # Hook for positioning dropdowns relative to trigger element ``` **Routing:** Tab-based navigation with Chat, Calendar, and Settings as main screens. Auth screens (login, register) outside tabs. Dynamic routes for event detail and note editing. @@ -223,7 +228,7 @@ src/ ├── app.ts # Entry point, DI setup, Express config ├── controllers/ # Request handlers + middleware (per architecture diagram) │ ├── AuthController.ts # login(), register(), refresh(), logout() -│ ├── ChatController.ts # sendMessage(), confirmEvent(), rejectEvent(), getConversations(), getConversation() +│ ├── ChatController.ts # sendMessage(), confirmEvent(), rejectEvent(), getConversations(), getConversation(), updateProposalEvent() │ ├── EventController.ts # create(), getById(), getAll(), getByDateRange(), update(), delete() │ ├── AuthMiddleware.ts # authenticate() - X-User-Id header validation │ └── LoggingMiddleware.ts # httpLogger - pino-http request logging @@ -290,6 +295,7 @@ src/ - `POST /api/chat/reject/:conversationId/:messageId` - Reject proposed event (protected) - `GET /api/chat/conversations` - Get all conversations (protected) - `GET /api/chat/conversations/:id` - Get messages of a conversation with cursor-based pagination (protected) +- `PUT /api/chat/messages/:messageId/proposal` - Update proposal event data before confirming (protected) - `GET /health` - Health check - `POST /api/ai/test` - AI test endpoint (development only) @@ -465,8 +471,8 @@ NODE_ENV=development # development = pretty logs, production = JSON - `utils/recurrenceExpander`: expandRecurringEvents() using rrule library for RRULE parsing - `ChatController`: getConversations(), getConversation() with cursor-based pagination support - `ChatService`: getConversations(), getConversation(), processMessage() uses real AI or test responses (via USE_TEST_RESPONSES), confirmEvent()/rejectEvent() update respondedAction and persist response messages - - `MongoChatRepository`: Full CRUD implemented (getConversationsByUser, createConversation, getMessages with cursor pagination, createMessage, updateMessage, updateProposalResponse) - - `ChatRepository` interface: updateMessage() and updateProposalResponse() for per-proposal respondedAction tracking + - `MongoChatRepository`: Full CRUD implemented (getConversationsByUser, createConversation, getMessages with cursor pagination, createMessage, updateMessage, updateProposalResponse, updateProposalEvent) + - `ChatRepository` interface: updateMessage(), updateProposalResponse(), updateProposalEvent() for per-proposal tracking - `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, warns AI not to put RRULE in description field @@ -527,12 +533,12 @@ NODE_ENV=development # development = pretty logs, production = JSON - Auto-scroll to end on new messages and keyboard show - keyboardDismissMode="interactive" and keyboardShouldPersistTaps="handled" - `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 +- `ChatService`: sendMessage(), confirmEvent(deleteMode, occurrenceDate), rejectEvent(), getConversations(), getConversation(), updateProposalEvent() - fully implemented with cursor pagination, recurring delete support, and proposal editing - `CardBase`: Reusable card component with header (title/subtitle), content area, and optional footer button - configurable padding, border, text size via props, ScrollView uses `nestedScrollEnabled` for Android - `ModalBase`: Reusable modal wrapper with backdrop (absolute-positioned behind card), uses CardBase internally - provides click-outside-to-close, Android back button support, and proper scrolling on Android - `EventCardBase`: Event card with date/time/recurring icons - uses CardBase for structure - `EventCard`: Uses EventCardBase + edit/delete buttons (TouchableOpacity with delayPressIn for scroll-friendly touch handling) -- `ProposedEventCard`: Uses EventCardBase + confirm/reject buttons for chat proposals, displays green highlighted text for new changes ("Neue Ausnahme: [date]" for single delete, "Neues Ende: [date]" for UNTIL updates) +- `ProposedEventCard`: Uses EventCardBase + confirm/reject/edit buttons for chat proposals, displays green highlighted text for new changes ("Neue Ausnahme: [date]" for single delete, "Neues Ende: [date]" for UNTIL updates). Edit button allows modifying proposals before confirming. - `DeleteEventModal`: Delete confirmation modal using ModalBase - shows three options for recurring events (single/future/all), simple confirm for non-recurring - `EventOverlay` (in calendar.tsx): Day events overlay using ModalBase - shows EventCards for selected day - `Themes.tsx`: Theme definitions with THEMES object (defaultLight, defaultDark) including all color tokens (textPrimary, borderPrimary, eventIndicator, secondaryBg, shadowColor, etc.) @@ -542,6 +548,11 @@ NODE_ENV=development # development = pretty logs, production = JSON - `ChatBubble`: Reusable chat bubble component with Tailwind styling, used by ChatMessage and TypingIndicator - `TypingIndicator`: Animated typing indicator component showing `. → .. → ...` loop while waiting for AI response - Event Detail and Note screens exist as skeletons +- `editEvent.tsx`: Dual-mode event editor screen + - **Calendar mode**: Edit existing events, create new events - calls EventService API + - **Chat mode**: Edit AI-proposed events before confirming - updates ChatStore locally and persists to server via ChatService.updateProposalEvent() + - Route params: `mode` ('calendar' | 'chat'), `id?`, `date?`, `eventData?` (JSON), `proposalContext?` (JSON with messageId, proposalId, conversationId) + - Supports recurring events with RRULE configuration (daily/weekly/monthly/yearly) ## Building diff --git a/apps/client/package.json b/apps/client/package.json index 4bc9148..91c3aec 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -14,6 +14,7 @@ "dependencies": { "@calchat/shared": "*", "@expo/vector-icons": "^15.0.3", + "@react-native-community/datetimepicker": "8.4.4", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", @@ -43,6 +44,7 @@ "react-native-screens": "~4.16.0", "react-native-web": "~0.21.0", "react-native-worklets": "0.5.1", + "rrule": "^2.8.1", "zustand": "^5.0.9" }, "devDependencies": { diff --git a/apps/client/src/Themes.tsx b/apps/client/src/Themes.tsx index dfb2a59..91007ca 100644 --- a/apps/client/src/Themes.tsx +++ b/apps/client/src/Themes.tsx @@ -46,8 +46,8 @@ export const THEMES = { messageBorderBg: "#3A3430", placeholderBg: "#4A4440", calenderBg: "#3D2A1A", - confirmButton: "#22c55e", - rejectButton: "#ef4444", + confirmButton: "#136e34", + rejectButton: "#bd1010", disabledButton: "#555", buttonText: "#FFFFFF", textPrimary: "#FFFFFF", diff --git a/apps/client/src/app/(tabs)/calendar.tsx b/apps/client/src/app/(tabs)/calendar.tsx index 092d64b..554c2d9 100644 --- a/apps/client/src/app/(tabs)/calendar.tsx +++ b/apps/client/src/app/(tabs)/calendar.tsx @@ -1,4 +1,4 @@ -import { Animated, Modal, Pressable, Text, View } from "react-native"; +import { Pressable, Text, View } from "react-native"; import { DAYS, MONTHS, @@ -10,20 +10,20 @@ import Header from "../../components/Header"; import { EventCard } from "../../components/EventCard"; import { DeleteEventModal } from "../../components/DeleteEventModal"; import { ModalBase } from "../../components/ModalBase"; +import { ScrollableDropdown } from "../../components/ScrollableDropdown"; import React, { useCallback, useEffect, useMemo, - useRef, useState, } from "react"; -import { useFocusEffect } from "expo-router"; +import { router, useFocusEffect } from "expo-router"; import { Ionicons } from "@expo/vector-icons"; import { useThemeStore } from "../../stores/ThemeStore"; import BaseBackground from "../../components/BaseBackground"; -import { FlashList } from "@shopify/flash-list"; import { EventService } from "../../services"; import { useEventsStore } from "../../stores"; +import { useDropdownPosition } from "../../hooks/useDropdownPosition"; // MonthSelector types and helpers type MonthItem = { @@ -74,6 +74,7 @@ const Calendar = () => { const [monthIndex, setMonthIndex] = useState(new Date().getMonth()); const [currentYear, setCurrentYear] = useState(new Date().getFullYear()); const [selectedDate, setSelectedDate] = useState(null); + const [overlayVisible, setOverlayVisible] = useState(false); // State for delete modal const [deleteModalVisible, setDeleteModalVisible] = useState(false); @@ -114,20 +115,33 @@ const Calendar = () => { // Load events when tab gains focus or month/year changes // NOTE: Wrapper needed because loadEvents is async (returns Promise) // and useFocusEffect expects a sync function (optionally returning cleanup) + // Also re-open overlay if selectedDate exists (for back navigation from editEvent) useFocusEffect( useCallback(() => { loadEvents(); - }, [loadEvents]), + if (selectedDate) { + setOverlayVisible(true); + } + }, [loadEvents, selectedDate]), ); // Group events by date (YYYY-MM-DD format) + // Multi-day events are added to all days they span const eventsByDate = useMemo(() => { const map = new Map(); events.forEach((e) => { - const date = new Date(e.occurrenceStart); - const key = getDateKey(date); - if (!map.has(key)) map.set(key, []); - map.get(key)!.push(e); + const start = new Date(e.occurrenceStart); + const end = new Date(e.occurrenceEnd); + + // Iterate through each day the event spans + const current = new Date(start); + current.setHours(0, 0, 0, 0); + while (current <= end) { + const key = getDateKey(current); + if (!map.has(key)) map.set(key, []); + map.get(key)!.push(e); + current.setDate(current.getDate() + 1); + } }); return map; }, [events]); @@ -147,19 +161,32 @@ const Calendar = () => { }); }; - const handleDayPress = (date: Date, hasEvents: boolean) => { - if (hasEvents) { - setSelectedDate(date); - } + const handleDayPress = (date: Date) => { + setSelectedDate(date); + setOverlayVisible(true); }; const handleCloseOverlay = () => { setSelectedDate(null); + setOverlayVisible(false); }; - const handleEditEvent = (event: ExpandedEvent) => { - console.log("Edit event:", event.id); - // TODO: Navigate to event edit screen + const handleCreateEvent = () => { + setOverlayVisible(false); + router.push({ + pathname: "/editEvent", + params: { date: selectedDate?.toISOString() }, + }); + }; + + const handleEditEvent = (event?: ExpandedEvent) => { + router.push({ + pathname: "/editEvent", + params: { + mode: "calendar", + id: event?.id, + }, + }); }; const handleDeleteEvent = (event: ExpandedEvent) => { @@ -222,12 +249,13 @@ const Calendar = () => { onDayPress={handleDayPress} /> void; - onEditEvent: (event: ExpandedEvent) => void; + onEditEvent: (event?: ExpandedEvent) => void; onDeleteEvent: (event: ExpandedEvent) => void; + onCreateEvent: () => void; }; const EventOverlay = ({ @@ -256,7 +285,10 @@ const EventOverlay = ({ onClose, onEditEvent, onDeleteEvent, + onCreateEvent, }: EventOverlayProps) => { + const { theme } = useThemeStore(); + if (!date) return null; const dateString = date.toLocaleDateString("de-DE", { @@ -268,12 +300,26 @@ const EventOverlay = ({ const subtitle = `${events.length} ${events.length === 1 ? "Termin" : "Termine"}`; + const addEventAttachment = ( + + + + Neuen Termin erstellen + + + ); + return ( void; }; +const INITIAL_RANGE = 12; // 12 months before and after current + const MonthSelector = ({ modalVisible, onClose, @@ -307,131 +355,98 @@ const MonthSelector = ({ currentMonthIndex, onSelectMonth, }: MonthSelectorProps) => { - const { theme } = useThemeStore(); - const heightAnim = useRef(new Animated.Value(0)).current; - const listRef = useRef>>(null); - const INITIAL_RANGE = 12; // 12 months before and after current - const [monthSelectorData, setMonthSelectorData] = useState([]); - const appendMonths = (direction: "start" | "end", count: number) => { - setMonthSelectorData((prevData) => { - if (prevData.length === 0) return prevData; + const appendMonths = useCallback( + (direction: "start" | "end", count: number) => { + setMonthSelectorData((prevData) => { + if (prevData.length === 0) return prevData; - const newMonths: MonthItem[] = []; - const referenceMonth = - direction === "start" ? prevData[0] : prevData[prevData.length - 1]; + const newMonths: MonthItem[] = []; + const referenceMonth = + direction === "start" ? prevData[0] : prevData[prevData.length - 1]; - for (let i = 1; i <= count; i++) { - const offset = direction === "start" ? -i : i; - let year = referenceMonth.year; - let month = referenceMonth.monthIndex + offset; + for (let i = 1; i <= count; i++) { + const offset = direction === "start" ? -i : i; + let year = referenceMonth.year; + let month = referenceMonth.monthIndex + offset; - while (month < 0) { - month += 12; - year--; - } - while (month > 11) { - month -= 12; - year++; + while (month < 0) { + month += 12; + year--; + } + while (month > 11) { + month -= 12; + year++; + } + + const newMonth: MonthItem = { + id: `${year}-${String(month + 1).padStart(2, "0")}`, + year, + monthIndex: month, + label: `${MONTHS[month]} ${year}`, + }; + + if (direction === "start") { + newMonths.unshift(newMonth); + } else { + newMonths.push(newMonth); + } } - const newMonth: MonthItem = { - id: `${year}-${String(month + 1).padStart(2, "0")}`, - year, - monthIndex: month, - label: `${MONTHS[month]} ${year}`, - }; - - if (direction === "start") { - newMonths.unshift(newMonth); - } else { - newMonths.push(newMonth); - } - } - - return direction === "start" - ? [...newMonths, ...prevData] - : [...prevData, ...newMonths]; - }); - }; + return direction === "start" + ? [...newMonths, ...prevData] + : [...prevData, ...newMonths]; + }); + }, + [], + ); + // Generate fresh data when modal opens, clear when closes useEffect(() => { if (modalVisible) { - // Generate fresh data centered on current month setMonthSelectorData( generateMonths(currentYear, currentMonthIndex, INITIAL_RANGE), ); - Animated.timing(heightAnim, { - toValue: 200, - duration: 200, - useNativeDriver: false, - }).start(); } else { - heightAnim.setValue(0); - // Clear data when closing setMonthSelectorData([]); } - }, [modalVisible, heightAnim, currentYear, currentMonthIndex]); + }, [modalVisible, currentYear, currentMonthIndex]); - const renderItem = ({ item }: { item: MonthItem }) => ( - { - onSelectMonth(item.year, item.monthIndex); - onClose(); - }} - > - - - {item.label} - - - + const handleSelect = useCallback( + (item: MonthItem) => { + onSelectMonth(item.year, item.monthIndex); + onClose(); + }, + [onSelectMonth, onClose], ); return ( - - - item.id} + renderItem={(item, theme) => ( + - item.id} - data={monthSelectorData} - initialScrollIndex={INITIAL_RANGE} - onEndReachedThreshold={0.5} - onEndReached={() => appendMonths("end", 12)} - onStartReachedThreshold={0.5} - onStartReached={() => appendMonths("start", 12)} - renderItem={renderItem} - /> - - - + + {item.label} + + + )} + onSelect={handleSelect} + height={200} + initialScrollIndex={INITIAL_RANGE} + onEndReached={() => appendMonths("end", 12)} + onStartReached={() => appendMonths("start", 12)} + /> ); }; @@ -445,29 +460,16 @@ type CalendarHeaderProps = { const CalendarHeader = (props: CalendarHeaderProps) => { const { theme } = useThemeStore(); - const [modalVisible, setModalVisible] = useState(false); - const [dropdownPosition, setDropdownPosition] = useState({ - top: 0, - left: 0, - width: 0, - }); - const containerRef = useRef(null); + const dropdown = useDropdownPosition(); const prevMonth = () => props.changeMonth(-1); const nextMonth = () => props.changeMonth(1); - const measureAndOpen = () => { - containerRef.current?.measureInWindow((x, y, width, height) => { - setDropdownPosition({ top: y + height, left: x, width }); - setModalVisible(true); - }); - }; - return (
@@ -486,15 +488,15 @@ const CalendarHeader = (props: CalendarHeaderProps) => { // Android shadow elevation: 6, }} - onPress={measureAndOpen} + onPress={dropdown.open} > setModalVisible(false)} - position={dropdownPosition} + modalVisible={dropdown.visible} + onClose={dropdown.close} + position={dropdown.position} currentYear={props.currentYear} currentMonthIndex={props.monthIndex} onSelectMonth={(year, month) => { @@ -561,7 +563,7 @@ type CalendarGridProps = { month: Month; year: number; eventsByDate: Map; - onDayPress: (date: Date, hasEvents: boolean) => void; + onDayPress: (date: Date) => void; }; const CalendarGrid = (props: CalendarGridProps) => { @@ -602,7 +604,7 @@ const CalendarGrid = (props: CalendarGridProps) => { date={date} month={props.month} hasEvents={hasEvents} - onPress={() => props.onDayPress(date, hasEvents)} + onPress={() => props.onDayPress(date)} /> ); })} diff --git a/apps/client/src/app/(tabs)/chat.tsx b/apps/client/src/app/(tabs)/chat.tsx index fc2f5f9..957d755 100644 --- a/apps/client/src/app/(tabs)/chat.tsx +++ b/apps/client/src/app/(tabs)/chat.tsx @@ -9,7 +9,7 @@ import { } from "react-native"; import { useThemeStore } from "../../stores/ThemeStore"; import React, { useState, useRef, useEffect, useCallback } from "react"; -import { useFocusEffect } from "expo-router"; +import { useFocusEffect, router } from "expo-router"; import Header from "../../components/Header"; import BaseBackground from "../../components/BaseBackground"; import { FlashList } from "@shopify/flash-list"; @@ -38,6 +38,7 @@ type ChatMessageProps = { proposedChanges?: ProposedEventChange[]; onConfirm?: (proposalId: string, proposal: ProposedEventChange) => void; onReject?: (proposalId: string) => void; + onEdit?: (proposalId: string, proposal: ProposedEventChange) => void; }; type ChatInputProps = { @@ -62,6 +63,7 @@ const Chat = () => { const [currentConversationId, setCurrentConversationId] = useState< string | undefined >(); + const [hasLoadedMessages, setHasLoadedMessages] = useState(false); useEffect(() => { const keyboardDidShow = Keyboard.addListener( @@ -71,10 +73,11 @@ const Chat = () => { return () => keyboardDidShow.remove(); }, []); - // Load existing messages from database once authenticated and screen is focused + // Load existing messages from database only once (on initial mount) + // Skip on subsequent focus events to preserve local edits (e.g., edited proposals) useFocusEffect( useCallback(() => { - if (isAuthLoading || !isAuthenticated) return; + if (isAuthLoading || !isAuthenticated || hasLoadedMessages) return; const fetchMessages = async () => { try { @@ -91,10 +94,12 @@ const Chat = () => { } } catch (error) { console.error("Failed to load messages:", error); + } finally { + setHasLoadedMessages(true); } }; fetchMessages(); - }, [isAuthLoading, isAuthenticated]), + }, [isAuthLoading, isAuthenticated, hasLoadedMessages]), ); const scrollToEnd = () => { @@ -159,6 +164,22 @@ const Chat = () => { } }; + const handleEditProposal = ( + messageId: string, + conversationId: string, + proposalId: string, + proposal: ProposedEventChange, + ) => { + router.push({ + pathname: "/editEvent", + params: { + mode: "chat", + eventData: JSON.stringify(proposal.event), + proposalContext: JSON.stringify({ messageId, proposalId, conversationId }), + }, + }); + }; + const handleSend = async (text: string) => { // Show user message immediately const userMessage: MessageData = { @@ -238,6 +259,14 @@ const Chat = () => { proposalId, ) } + onEdit={(proposalId, proposal) => + handleEditProposal( + item.id, + item.conversationId!, + proposalId, + proposal, + ) + } /> )} keyExtractor={(item) => item.id} @@ -334,6 +363,7 @@ const ChatMessage = ({ proposedChanges, onConfirm, onReject, + onEdit, }: ChatMessageProps) => { const { theme } = useThemeStore(); const [currentIndex, setCurrentIndex] = useState(0); @@ -361,7 +391,7 @@ const ChatMessage = ({ {content} - {hasProposals && currentProposal && onConfirm && onReject && ( + {hasProposals && currentProposal && onConfirm && onReject && onEdit && ( {/* Event card with optional navigation arrows */} @@ -381,8 +411,9 @@ const ChatMessage = ({ onConfirm(currentProposal.id, currentProposal)} + onConfirm={(proposal) => onConfirm(proposal.id, proposal)} onReject={() => onReject(currentProposal.id)} + onEdit={(proposal) => onEdit(proposal.id, proposal)} /> diff --git a/apps/client/src/app/(tabs)/settings.tsx b/apps/client/src/app/(tabs)/settings.tsx index a43cd69..831961c 100644 --- a/apps/client/src/app/(tabs)/settings.tsx +++ b/apps/client/src/app/(tabs)/settings.tsx @@ -5,7 +5,7 @@ import { useThemeStore } from "../../stores/ThemeStore"; import { AuthService } from "../../services/AuthService"; import { router } from "expo-router"; import { Ionicons } from "@expo/vector-icons"; -import Header from "../../components/Header"; +import { SimpleHeader } from "../../components/Header"; import { THEMES } from "../../Themes"; const handleLogout = async () => { @@ -18,11 +18,7 @@ const Settings = () => { return ( -
- - Settings - -
+ {" "} diff --git a/apps/client/src/app/_layout.tsx b/apps/client/src/app/_layout.tsx index fb120f2..11912f7 100644 --- a/apps/client/src/app/_layout.tsx +++ b/apps/client/src/app/_layout.tsx @@ -7,8 +7,9 @@ export default function RootLayout() { - - + + {/* */} + {/* */} ); } diff --git a/apps/client/src/app/editEvent.tsx b/apps/client/src/app/editEvent.tsx new file mode 100644 index 0000000..0cabf53 --- /dev/null +++ b/apps/client/src/app/editEvent.tsx @@ -0,0 +1,597 @@ +import { + View, + Text, + TextInput, + Pressable, + ActivityIndicator, +} from "react-native"; +import { Frequency, rrulestr } from "rrule"; +import BaseBackground from "../components/BaseBackground"; +import { useThemeStore } from "../stores/ThemeStore"; +import { useCallback, useEffect, useState } from "react"; +import { router, useLocalSearchParams } from "expo-router"; +import Header, { HeaderButton } from "../components/Header"; +import { + DatePickerButton, + TimePickerButton, +} from "../components/DateTimePicker"; +import { Ionicons } from "@expo/vector-icons"; +import { ScrollableDropdown } from "../components/ScrollableDropdown"; +import { useDropdownPosition } from "../hooks/useDropdownPosition"; +import { EventService, ChatService } from "../services"; +import { buildRRule, CreateEventDTO } from "@calchat/shared"; +import { useChatStore } from "../stores"; + +// Direct store access for getting current state in callbacks +const getChatStoreState = () => useChatStore.getState(); + +type EditEventTextFieldProps = { + titel: string; + text?: string; + focused?: boolean; + className?: string; + multiline?: boolean; + onValueChange?: (text: string) => void; +}; + +const EditEventTextField = (props: EditEventTextFieldProps) => { + const { theme } = useThemeStore(); + const [focused, setFocused] = useState(props.focused ?? false); + + return ( + + + {props.titel} + + setFocused(true)} + onBlur={() => setFocused(false)} + /> + + ); +}; + +type PickerRowProps = { + title: string; + showLabels?: boolean; + dateValue: Date; + onDateChange: (date: Date) => void; + onTimeChange: (date: Date) => void; +}; + +const PickerRow = ({ + showLabels, + dateValue, + title, + onDateChange, + onTimeChange, +}: PickerRowProps) => { + const { theme } = useThemeStore(); + return ( + + + {title} + + + + + + + ); +}; + +type RepeatType = "Tag" | "Woche" | "Monat" | "Jahr"; + +const REPEAT_TYPE_LABELS: Record = { + Tag: "Tage", + Woche: "Wochen", + Monat: "Monate", + Jahr: "Jahre", +}; + +type RepeatPressableProps = { + focused: boolean; + repeatType: RepeatType; + setRepeatType: (repeatType: RepeatType) => void; +}; + +const RepeatPressable = ({ + focused, + repeatType, + setRepeatType, +}: RepeatPressableProps) => { + const { theme } = useThemeStore(); + return ( + setRepeatType(repeatType)} + > + + {repeatType} + + + ); +}; + +type RepeatSelectorProps = { + repeatCount: number; + onRepeatCountChange: (count: number) => void; + repeatType: RepeatType; + onRepeatTypeChange: (type: RepeatType) => void; +}; + +// Static data for repeat count dropdown (1-120) +const REPEAT_COUNT_DATA = Array.from({ length: 120 }, (_, i) => i + 1); + +const RepeatSelector = ({ + repeatCount, + onRepeatCountChange, + repeatType, + onRepeatTypeChange, +}: RepeatSelectorProps) => { + const { theme } = useThemeStore(); + const dropdown = useDropdownPosition(2); + + const handleSelectCount = useCallback( + (count: number) => { + onRepeatCountChange(count); + dropdown.close(); + }, + [onRepeatCountChange, dropdown], + ); + + const typeLabel = REPEAT_TYPE_LABELS[repeatType]; + + return ( + + {/* Repeat Type Selection */} + + + + + + + + {/* Repeat Count Selection */} + + + Alle{" "} + + + + {repeatCount} + + + + {" "} + {typeLabel} + + + + {/* Count Dropdown */} + String(n)} + renderItem={(n, theme) => ( + + + {n} + + + )} + onSelect={handleSelectCount} + heightRatio={0.4} + initialScrollIndex={repeatCount - 1} + /> + + ); +}; + +type EditEventHeaderProps = { + id?: string; + mode?: "calendar" | "chat"; +}; + +const EditEventHeader = ({ id, mode }: EditEventHeaderProps) => { + const getTitle = () => { + if (mode === "chat") return "Edit Proposal"; + return id ? "Edit Meeting" : "New Meeting"; + }; + + return ( +
+ + + {getTitle()} + +
+ ); +}; + +type EditEventParams = { + id?: string; + date?: string; + mode?: "calendar" | "chat"; + eventData?: string; + proposalContext?: string; +}; + +type ProposalContext = { + messageId: string; + proposalId: string; + conversationId: string; +}; + +const EditEventScreen = () => { + const { id, date, mode, eventData, proposalContext } = + useLocalSearchParams(); + const { theme } = useThemeStore(); + const updateMessage = useChatStore((state) => state.updateMessage); + + // Only show loading if we need to fetch from API (calendar mode with id) + const [isLoading, setIsLoading] = useState( + mode !== "chat" && !!id && !eventData, + ); + + // Initialize dates from URL parameter or use current time + const initialDate = date ? new Date(date) : new Date(); + const initialEndDate = new Date(initialDate.getTime() + 60 * 60 * 1000); + + const [repeatVisible, setRepeatVisible] = useState(false); + const [repeatCount, setRepeatCount] = useState(1); + const [repeatType, setRepeatType] = useState("Tag"); + const [startDate, setStartDate] = useState(initialDate); + const [endDate, setEndDate] = useState(initialEndDate); + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + + // Helper to populate form from event data + const populateFormFromEvent = useCallback((event: CreateEventDTO) => { + setStartDate(new Date(event.startTime)); + setEndDate(new Date(event.endTime)); + setTitle(event.title); + if (event.description) { + setDescription(event.description); + } + + if (event.recurrenceRule) { + setRepeatVisible(true); + + const rrule = rrulestr(event.recurrenceRule); + if (rrule.options.interval) { + setRepeatCount(rrule.options.interval); + } + switch (rrule.options.freq) { + case Frequency.DAILY: + setRepeatType("Tag"); + break; + case Frequency.WEEKLY: + setRepeatType("Woche"); + break; + case Frequency.MONTHLY: + setRepeatType("Monat"); + break; + case Frequency.YEARLY: + setRepeatType("Jahr"); + break; + } + } + }, []); + + // Load event data based on mode + useEffect(() => { + // Chat mode: load from eventData JSON parameter + if (mode === "chat" && eventData) { + try { + const event = JSON.parse(eventData) as CreateEventDTO; + populateFormFromEvent(event); + } catch (error) { + console.error("Failed to parse eventData:", error); + } + return; + } + + // Calendar mode with id: fetch from API + if (id && !eventData) { + const fetchEvent = async () => { + try { + const event = await EventService.getById(id); + populateFormFromEvent({ + title: event.title, + description: event.description, + startTime: event.startTime, + endTime: event.endTime, + recurrenceRule: event.recurrenceRule, + }); + } catch (error) { + console.error("Failed to load event: ", error); + } finally { + setIsLoading(false); + } + }; + + fetchEvent(); + } + }, [id, mode, eventData, populateFormFromEvent]); + + if (isLoading) { + return ( + + + + + + + ); + } + + const handleStartDateChange = (date: Date) => { + // Keep the time from startDate, update the date part + const newStart = new Date(startDate); + newStart.setFullYear(date.getFullYear(), date.getMonth(), date.getDate()); + setStartDate(newStart); + + // If end date is before new start date, adjust it + if (endDate < newStart) { + const newEnd = new Date(newStart); + newEnd.setHours(newStart.getHours() + 1); + setEndDate(newEnd); + } + }; + + const handleStartTimeChange = (date: Date) => { + // Keep the date from startDate, update the time part + const newStart = new Date(startDate); + newStart.setHours(date.getHours(), date.getMinutes(), 0, 0); + setStartDate(newStart); + + // If end time is before new start time on the same day, adjust it + if (endDate <= newStart) { + const newEnd = new Date(newStart); + newEnd.setHours(newStart.getHours() + 1); + setEndDate(newEnd); + } + }; + + const handleEndDateChange = (date: Date) => { + // Keep the time from endDate, update the date part + const newEnd = new Date(endDate); + newEnd.setFullYear(date.getFullYear(), date.getMonth(), date.getDate()); + setEndDate(newEnd); + }; + + const handleEndTimeChange = (date: Date) => { + // Keep the date from endDate, update the time part + const newEnd = new Date(endDate); + newEnd.setHours(date.getHours(), date.getMinutes(), 0, 0); + setEndDate(newEnd); + }; + + const handleSave = async () => { + const eventObject: CreateEventDTO = { + title, + description: description === "" ? undefined : description, + startTime: startDate, + endTime: endDate, + recurrenceRule: repeatVisible + ? buildRRule(repeatType, repeatCount) + : undefined, + }; + + // Chat mode: update proposal locally and on server + if (mode === "chat" && proposalContext) { + try { + const context = JSON.parse(proposalContext) as ProposalContext; + + // Update locally in ChatStore + const currentMessages = getChatStoreState().messages; + const message = currentMessages.find((m) => m.id === context.messageId); + + if (message?.proposedChanges) { + const updatedProposals = message.proposedChanges.map((p) => + p.id === context.proposalId ? { ...p, event: eventObject } : p, + ); + updateMessage(context.messageId, { + proposedChanges: updatedProposals, + }); + } + + // Persist to server + await ChatService.updateProposalEvent( + context.messageId, + context.proposalId, + eventObject, + ); + + router.back(); + } catch (error) { + console.error("Failed to update proposal:", error); + } + return; + } + + // Calendar mode: call API + try { + if (id) { + await EventService.update(id, eventObject); + } else { + await EventService.create(eventObject); + } + router.back(); + } catch (error) { + console.error("Creating/Updating event failed!", error); + } + }; + + const getButtonText = () => { + if (mode === "chat") { + return "Fertig"; + } + return id ? "Aktualisiere Termin" : "Erstelle neuen Termin"; + }; + + return ( + + + + {/* Date and Time */} + + + + + + {/* TODO: Reminder */} + + {/* Notes */} + + + {/* Repeat Toggle Button */} + setRepeatVisible(!repeatVisible)} + > + + + Wiederholen + + + + {/* Repeat Selector (shown when toggle is active) */} + {repeatVisible && ( + + )} + + + + {/* Send new or updated Event */} + + + {mode !== "chat" && ( + + )} + + {getButtonText()} + + + + + ); +}; + +export default EditEventScreen; diff --git a/apps/client/src/components/CardBase.tsx b/apps/client/src/components/CardBase.tsx index 7d3331a..0fd63b9 100644 --- a/apps/client/src/components/CardBase.tsx +++ b/apps/client/src/components/CardBase.tsx @@ -8,6 +8,7 @@ type CardBaseProps = { title: string; subtitle?: string; children: ReactNode; + attachment?: ReactNode; // renders between children and footer footer?: { label: string; onPress: () => void; @@ -27,6 +28,7 @@ export const CardBase = ({ title, subtitle, children, + attachment, footer, className = "", scrollable = false, @@ -94,6 +96,8 @@ export const CardBase = ({ contentElement )} + {attachment} + {/* Footer (optional) */} {footer && ( void; +}; + +const DateTimePickerButton = ({ + mode, + label, + value, + onChange, + className, +}: DateTimePickerButtonProps) => { + const { theme } = useThemeStore(); + const [showPicker, setShowPicker] = useState(false); + const isDark = theme === THEMES.defaultDark; + + const handleChange = (event: DateTimePickerEvent, selectedDate?: Date) => { + if (Platform.OS === "android") { + setShowPicker(false); + } + if (event.type === "set" && selectedDate) { + onChange(selectedDate); + } + }; + + const formattedValue = + mode === "date" + ? value.toLocaleDateString("de-DE", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }) + : value.toLocaleTimeString("de-DE", { + hour: "2-digit", + minute: "2-digit", + }); + + return ( + + {label && ( + + {label} + + )} + setShowPicker(true)} + className="w-full rounded-lg px-3 py-2 border" + style={{ + backgroundColor: theme.messageBorderBg, + borderColor: theme.borderPrimary, + }} + > + + {formattedValue} + + + + {Platform.OS === "ios" ? ( + setShowPicker(false)} + > + setShowPicker(false)} + > + + + setShowPicker(false)} className="p-2"> + + Fertig + + + + + + + + ) : ( + showPicker && ( + + ) + )} + + ); +}; + +// Convenience wrappers for simpler usage +export const DatePickerButton = ( + props: Omit +) => ; + +export const TimePickerButton = ( + props: Omit +) => ; + +export default DateTimePickerButton; diff --git a/apps/client/src/components/EventCardBase.tsx b/apps/client/src/components/EventCardBase.tsx index 8810784..8f731de 100644 --- a/apps/client/src/components/EventCardBase.tsx +++ b/apps/client/src/components/EventCardBase.tsx @@ -3,6 +3,12 @@ import { Feather } from "@expo/vector-icons"; import { ReactNode } from "react"; import { useThemeStore } from "../stores/ThemeStore"; import { CardBase } from "./CardBase"; +import { + isMultiDayEvent, + formatDateWithWeekday, + formatDateWithWeekdayShort, + formatTime, +} from "@calchat/shared"; type EventCardBaseProps = { className?: string; @@ -14,24 +20,6 @@ type EventCardBaseProps = { children?: ReactNode; }; -function formatDate(date: Date): string { - const d = new Date(date); - return d.toLocaleDateString("de-DE", { - weekday: "short", - day: "2-digit", - month: "2-digit", - year: "numeric", - }); -} - -function formatTime(date: Date): string { - const d = new Date(date); - return d.toLocaleTimeString("de-DE", { - hour: "2-digit", - minute: "2-digit", - }); -} - function formatDuration(start: Date, end: Date): string { const startDate = new Date(start); const endDate = new Date(end); @@ -62,6 +50,7 @@ export const EventCardBase = ({ children, }: EventCardBaseProps) => { const { theme } = useThemeStore(); + const multiDay = isMultiDayEvent(startTime, endTime); return ( @@ -73,9 +62,16 @@ export const EventCardBase = ({ color={theme.textPrimary} style={{ marginRight: 8 }} /> - - {formatDate(startTime)} - + {multiDay ? ( + + {formatDateWithWeekdayShort(startTime)} →{" "} + {formatDateWithWeekday(endTime)} + + ) : ( + + {formatDateWithWeekday(startTime)} + + )}
{/* Time with duration */} @@ -86,10 +82,16 @@ export const EventCardBase = ({ color={theme.textPrimary} style={{ marginRight: 8 }} /> - - {formatTime(startTime)} - {formatTime(endTime)} ( - {formatDuration(startTime, endTime)}) - + {multiDay ? ( + + {formatTime(startTime)} → {formatTime(endTime)} + + ) : ( + + {formatTime(startTime)} - {formatTime(endTime)} ( + {formatDuration(startTime, endTime)}) + + )}
{/* Recurring indicator */} diff --git a/apps/client/src/components/Header.tsx b/apps/client/src/components/Header.tsx index eea7bf7..0685ae7 100644 --- a/apps/client/src/components/Header.tsx +++ b/apps/client/src/components/Header.tsx @@ -1,6 +1,7 @@ -import { View } from "react-native"; +import { View, Text, Pressable } from "react-native"; import { useThemeStore } from "../stores/ThemeStore"; -import { ReactNode } from "react"; +import { ComponentProps, ReactNode } from "react"; +import { Ionicons } from "@expo/vector-icons"; type HeaderProps = { children?: ReactNode; @@ -37,4 +38,54 @@ const Header = (props: HeaderProps) => { ); }; +type HeaderButton = { + className?: string; + iconName: ComponentProps["name"]; + iconSize: number; + onPress?: () => void; +}; + +export const HeaderButton = (props: HeaderButton) => { + const { theme } = useThemeStore(); + + return ( + + + + ); +}; + +type SimpleHeaderProps = { + text: string; +}; + +export const SimpleHeader = ({ text }: SimpleHeaderProps) => ( +
+ + {text} + +
+); + export default Header; diff --git a/apps/client/src/components/ModalBase.tsx b/apps/client/src/components/ModalBase.tsx index 9d589a9..e44abec 100644 --- a/apps/client/src/components/ModalBase.tsx +++ b/apps/client/src/components/ModalBase.tsx @@ -9,6 +9,7 @@ type ModalBaseProps = { title: string; subtitle?: string; children: ReactNode; + attachment?: ReactNode; footer?: { label: string; onPress: () => void; @@ -23,6 +24,7 @@ export const ModalBase = ({ title, subtitle, children, + attachment, footer, scrollable, maxContentHeight, @@ -55,6 +57,7 @@ export const ModalBase = ({ void; + onConfirm: (proposal: ProposedEventChange) => void; onReject: () => void; + onEdit?: (proposal: ProposedEventChange) => void; }; -const ConfirmRejectButtons = ({ +const ActionButtons = ({ isDisabled, respondedAction, + showEdit, onConfirm, onReject, + onEdit, }: { isDisabled: boolean; respondedAction?: "confirm" | "reject"; + showEdit: boolean; onConfirm: () => void; onReject: () => void; + onEdit?: () => void; }) => { const { theme } = useThemeStore(); return ( @@ -56,6 +62,19 @@ const ConfirmRejectButtons = ({ Ablehnen + {showEdit && onEdit && ( + + + + )}
); }; @@ -64,6 +83,7 @@ export const ProposedEventCard = ({ proposedChange, onConfirm, onReject, + onEdit, }: ProposedEventCardProps) => { const { theme } = useThemeStore(); const event = proposedChange.event; @@ -79,7 +99,7 @@ export const ProposedEventCard = ({ const newUntilDate = proposedChange.action === "update" && event?.recurrenceRule && - parseRRule(event.recurrenceRule)?.until; + rrulestr(event.recurrenceRule).options.until; if (!event) { return null; @@ -93,7 +113,7 @@ export const ProposedEventCard = ({ startTime={event.startTime} endTime={event.endTime} description={event.description} - isRecurring={event.isRecurring} + isRecurring={!!event.recurrenceRule} > {/* Show new exception date for delete/single actions */} {newExceptionDate && ( @@ -123,11 +143,13 @@ export const ProposedEventCard = ({ )} - onConfirm(proposedChange)} onReject={onReject} + onEdit={onEdit ? () => onEdit(proposedChange) : undefined} /> diff --git a/apps/client/src/components/ScrollableDropdown.tsx b/apps/client/src/components/ScrollableDropdown.tsx new file mode 100644 index 0000000..54522d6 --- /dev/null +++ b/apps/client/src/components/ScrollableDropdown.tsx @@ -0,0 +1,107 @@ +import { useRef, useEffect } from "react"; +import { Modal, Pressable, Animated, useWindowDimensions } from "react-native"; +import { FlashList } from "@shopify/flash-list"; +import { useThemeStore } from "../stores/ThemeStore"; +import { Theme } from "../Themes"; + +export type ScrollableDropdownProps = { + visible: boolean; + onClose: () => void; + position: { + top?: number; + bottom?: number; + left: number; + width: number; + }; + data: T[]; + keyExtractor: (item: T) => string; + renderItem: (item: T, theme: Theme) => React.ReactNode; + onSelect: (item: T) => void; + height?: number; + heightRatio?: number; // Alternative: fraction of screen height (0-1) + initialScrollIndex?: number; + // Infinite scroll (optional) + onEndReached?: () => void; + onStartReached?: () => void; +}; + +export const ScrollableDropdown = ({ + visible, + onClose, + position, + data, + keyExtractor, + renderItem, + onSelect, + height = 200, + heightRatio, + initialScrollIndex = 0, + onEndReached, + onStartReached, +}: ScrollableDropdownProps) => { + const { theme } = useThemeStore(); + const { height: screenHeight } = useWindowDimensions(); + const heightAnim = useRef(new Animated.Value(0)).current; + const listRef = useRef>>(null); + + // Calculate actual height: use heightRatio if provided, otherwise fall back to height + const actualHeight = heightRatio ? screenHeight * heightRatio : height; + // Calculate top position: use top if provided, otherwise calculate from bottom + const topValue = + position.top ?? screenHeight - actualHeight - (position.bottom ?? 0); + + useEffect(() => { + if (visible) { + Animated.timing(heightAnim, { + toValue: actualHeight, + duration: 200, + useNativeDriver: false, + }).start(); + } else { + heightAnim.setValue(0); + } + }, [visible, heightAnim, actualHeight]); + + return ( + + + + ( + onSelect(item)}> + {renderItem(item, theme)} + + )} + /> + + + + ); +}; diff --git a/apps/client/src/hooks/useDropdownPosition.ts b/apps/client/src/hooks/useDropdownPosition.ts new file mode 100644 index 0000000..84cc636 --- /dev/null +++ b/apps/client/src/hooks/useDropdownPosition.ts @@ -0,0 +1,37 @@ +import { useCallback, useRef, useState } from "react"; +import { View } from "react-native"; + +type DropdownPosition = { + top: number; + left: number; + width: number; +}; + +/** + * Hook for managing dropdown position measurement and visibility. + * @param widthMultiplier - Multiply the measured width (default: 1) + */ +export const useDropdownPosition = (widthMultiplier = 1) => { + const [visible, setVisible] = useState(false); + const [position, setPosition] = useState({ + top: 0, + left: 0, + width: 0, + }); + const ref = useRef(null); + + const open = useCallback(() => { + ref.current?.measureInWindow((x, y, width, height) => { + setPosition({ + top: y + height, + left: x, + width: width * widthMultiplier, + }); + setVisible(true); + }); + }, [widthMultiplier]); + + const close = useCallback(() => setVisible(false), []); + + return { ref, visible, position, open, close }; +}; diff --git a/apps/client/src/services/ChatService.ts b/apps/client/src/services/ChatService.ts index 3b2016c..8d66b47 100644 --- a/apps/client/src/services/ChatService.ts +++ b/apps/client/src/services/ChatService.ts @@ -85,4 +85,15 @@ export const ChatService = { return ApiClient.get(url); }, + + updateProposalEvent: async ( + messageId: string, + proposalId: string, + event: CreateEventDTO, + ): Promise => { + return ApiClient.put(`/chat/messages/${messageId}/proposal`, { + proposalId, + event, + }); + }, }; diff --git a/apps/server/src/ai/utils/toolDefinitions.ts b/apps/server/src/ai/utils/toolDefinitions.ts index e9d602a..553f56c 100644 --- a/apps/server/src/ai/utils/toolDefinitions.ts +++ b/apps/server/src/ai/utils/toolDefinitions.ts @@ -94,10 +94,6 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [ type: "string", description: "Optional event description", }, - isRecurring: { - type: "boolean", - description: "Whether this is a recurring event", - }, recurrenceRule: { type: "string", description: "RRULE format string for recurring events", @@ -133,10 +129,6 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [ type: "string", description: "New description (optional). NEVER put RRULE here!", }, - isRecurring: { - type: "boolean", - description: "Whether this is a recurring event (optional)", - }, recurrenceRule: { type: "string", description: diff --git a/apps/server/src/ai/utils/toolExecutor.ts b/apps/server/src/ai/utils/toolExecutor.ts index a337ec3..fbdae98 100644 --- a/apps/server/src/ai/utils/toolExecutor.ts +++ b/apps/server/src/ai/utils/toolExecutor.ts @@ -57,7 +57,6 @@ export function executeToolCall( startTime: new Date(args.startTime as string), endTime: new Date(args.endTime as string), description: args.description as string | undefined, - isRecurring: args.isRecurring as boolean | undefined, recurrenceRule: args.recurrenceRule as string | undefined, }; const dateStr = formatDate(event.startTime); @@ -88,8 +87,6 @@ export function executeToolCall( updates.startTime = new Date(args.startTime as string); if (args.endTime) updates.endTime = new Date(args.endTime as string); if (args.description) updates.description = args.description; - if (args.isRecurring !== undefined) - updates.isRecurring = args.isRecurring; if (args.recurrenceRule) updates.recurrenceRule = args.recurrenceRule; // Build event object for display (merge existing with updates) @@ -99,8 +96,6 @@ export function executeToolCall( endTime: (updates.endTime as Date) || existingEvent.endTime, description: (updates.description as string) || existingEvent.description, - isRecurring: - (updates.isRecurring as boolean) ?? existingEvent.isRecurring, recurrenceRule: (updates.recurrenceRule as string) || existingEvent.recurrenceRule, exceptionDates: existingEvent.exceptionDates, @@ -131,7 +126,7 @@ export function executeToolCall( // Build descriptive content based on delete mode let modeDescription = ""; - if (existingEvent.isRecurring) { + if (existingEvent.recurrenceRule) { switch (deleteMode) { case "single": modeDescription = " (nur dieses Vorkommen)"; @@ -155,12 +150,11 @@ export function executeToolCall( startTime: existingEvent.startTime, endTime: existingEvent.endTime, description: existingEvent.description, - isRecurring: existingEvent.isRecurring, recurrenceRule: existingEvent.recurrenceRule, exceptionDates: existingEvent.exceptionDates, }, - deleteMode: existingEvent.isRecurring ? deleteMode : undefined, - occurrenceDate: existingEvent.isRecurring + deleteMode: existingEvent.recurrenceRule ? deleteMode : undefined, + occurrenceDate: existingEvent.recurrenceRule ? occurrenceDate : undefined, }, diff --git a/apps/server/src/controllers/ChatController.ts b/apps/server/src/controllers/ChatController.ts index cab9159..688c159 100644 --- a/apps/server/src/controllers/ChatController.ts +++ b/apps/server/src/controllers/ChatController.ts @@ -150,4 +150,33 @@ export class ChatController { } } } + + async updateProposalEvent( + req: AuthenticatedRequest, + res: Response, + ): Promise { + try { + const { messageId } = req.params; + const { proposalId, event } = req.body as { + proposalId: string; + event: CreateEventDTO; + }; + const message = await this.chatService.updateProposalEvent( + messageId, + proposalId, + event, + ); + if (message) { + res.json(message); + } else { + res.status(404).json({ error: "Message or proposal not found" }); + } + } catch (error) { + log.error( + { error, messageId: req.params.messageId }, + "Error updating proposal event", + ); + res.status(500).json({ error: "Failed to update proposal event" }); + } + } } diff --git a/apps/server/src/repositories/mongo/MongoChatRepository.ts b/apps/server/src/repositories/mongo/MongoChatRepository.ts index 90c341b..9425acb 100644 --- a/apps/server/src/repositories/mongo/MongoChatRepository.ts +++ b/apps/server/src/repositories/mongo/MongoChatRepository.ts @@ -2,6 +2,7 @@ import { ChatMessage, Conversation, CreateMessageDTO, + CreateEventDTO, GetMessagesOptions, UpdateMessageDTO, } from "@calchat/shared"; @@ -82,4 +83,17 @@ export class MongoChatRepository implements ChatRepository { ); return doc ? (doc.toJSON() as unknown as ChatMessage) : null; } + + async updateProposalEvent( + messageId: string, + proposalId: string, + event: CreateEventDTO, + ): Promise { + const doc = await ChatMessageModel.findOneAndUpdate( + { _id: messageId, "proposedChanges.id": proposalId }, + { $set: { "proposedChanges.$.event": event } }, + { new: true }, + ); + return doc ? (doc.toJSON() as unknown as ChatMessage) : null; + } } diff --git a/apps/server/src/repositories/mongo/models/ChatModel.ts b/apps/server/src/repositories/mongo/models/ChatModel.ts index ccb206b..92d69c9 100644 --- a/apps/server/src/repositories/mongo/models/ChatModel.ts +++ b/apps/server/src/repositories/mongo/models/ChatModel.ts @@ -23,7 +23,6 @@ const EventSchema = new Schema( startTime: { type: Date, required: true }, endTime: { type: Date, required: true }, note: { type: String }, - isRecurring: { type: Boolean }, recurrenceRule: { type: String }, exceptionDates: { type: [String] }, }, @@ -37,7 +36,6 @@ const UpdatesSchema = new Schema( startTime: { type: Date }, endTime: { type: Date }, note: { type: String }, - isRecurring: { type: Boolean }, recurrenceRule: { type: String }, }, { _id: false }, diff --git a/apps/server/src/repositories/mongo/models/EventModel.ts b/apps/server/src/repositories/mongo/models/EventModel.ts index 99db0bd..ca1908c 100644 --- a/apps/server/src/repositories/mongo/models/EventModel.ts +++ b/apps/server/src/repositories/mongo/models/EventModel.ts @@ -2,16 +2,22 @@ import mongoose, { Schema, Document, Model } from "mongoose"; import { CalendarEvent } from "@calchat/shared"; import { IdVirtual } from "./types"; -export interface EventDocument extends Omit, Document { +interface EventVirtuals extends IdVirtual { + isRecurring: boolean; +} + +export interface EventDocument + extends Omit, + Document { toJSON(): CalendarEvent; } const EventSchema = new Schema< EventDocument, - Model, + Model, {}, {}, - IdVirtual + EventVirtuals >( { userId: { @@ -39,10 +45,6 @@ const EventSchema = new Schema< note: { type: String, }, - isRecurring: { - type: Boolean, - default: false, - }, recurrenceRule: { type: String, }, @@ -59,6 +61,11 @@ const EventSchema = new Schema< return this._id.toString(); }, }, + isRecurring: { + get() { + return !!this.recurrenceRule; + }, + }, }, toJSON: { virtuals: true, diff --git a/apps/server/src/routes/chat.routes.ts b/apps/server/src/routes/chat.routes.ts index 14f64d2..ed85dfc 100644 --- a/apps/server/src/routes/chat.routes.ts +++ b/apps/server/src/routes/chat.routes.ts @@ -19,6 +19,9 @@ export function createChatRoutes(chatController: ChatController): Router { router.get("/conversations/:id", (req, res) => chatController.getConversation(req, res), ); + router.put("/messages/:messageId/proposal", (req, res) => + chatController.updateProposalEvent(req, res), + ); return router; } diff --git a/apps/server/src/services/ChatService.ts b/apps/server/src/services/ChatService.ts index 9ad7322..4391383 100644 --- a/apps/server/src/services/ChatService.ts +++ b/apps/server/src/services/ChatService.ts @@ -41,7 +41,6 @@ const staticResponses: TestResponse[] = [ startTime: getDay("Wednesday", 1, 18, 0), endTime: getDay("Wednesday", 1, 19, 30), description: "Wöchentliches Training", - isRecurring: true, recurrenceRule: "FREQ=WEEKLY;BYDAY=WE", }, }, @@ -158,7 +157,6 @@ const staticResponses: TestResponse[] = [ startTime: getDay("Monday", 1, 7, 0), endTime: getDay("Monday", 1, 8, 0), description: "Morgen-Yoga", - isRecurring: true, recurrenceRule: "FREQ=WEEKLY;BYDAY=MO,WE,FR", }, }, @@ -170,7 +168,6 @@ const staticResponses: TestResponse[] = [ startTime: getDay("Tuesday", 1, 18, 0), endTime: getDay("Tuesday", 1, 19, 0), description: "Abendlauf im Park", - isRecurring: true, recurrenceRule: "FREQ=WEEKLY;BYDAY=TU,TH", }, }, @@ -215,7 +212,6 @@ const staticResponses: TestResponse[] = [ title: "Badezimmer putzen", startTime: getDay("Saturday", 1, 10, 0), endTime: getDay("Saturday", 1, 11, 0), - isRecurring: true, recurrenceRule: "FREQ=WEEKLY;BYDAY=SA", }, }, @@ -255,7 +251,6 @@ const staticResponses: TestResponse[] = [ title: "Mamas Geburtstag", startTime: getDay("Thursday", 2, 0, 0), endTime: getDay("Thursday", 2, 23, 59), - isRecurring: true, recurrenceRule: "FREQ=YEARLY", }, }, @@ -273,7 +268,6 @@ const staticResponses: TestResponse[] = [ title: "Fitnessstudio Probetraining", startTime: getDay("Tuesday", 1, 18, 0), endTime: getDay("Tuesday", 1, 19, 30), - isRecurring: true, recurrenceRule: "FREQ=WEEKLY;BYDAY=TU;COUNT=8", }, }, @@ -327,7 +321,6 @@ const staticResponses: TestResponse[] = [ title: "Spanischkurs VHS", startTime: getDay("Thursday", 1, 19, 0), endTime: getDay("Thursday", 1, 20, 30), - isRecurring: true, recurrenceRule: "FREQ=WEEKLY;BYDAY=TH,SA;COUNT=8", }, }, @@ -373,7 +366,6 @@ async function getTestResponse( exceptionDate.getTime() + 90 * 60 * 1000, ), // +90 min description: sportEvent.description, - isRecurring: sportEvent.isRecurring, recurrenceRule: sportEvent.recurrenceRule, exceptionDates: sportEvent.exceptionDates, }, @@ -412,7 +404,6 @@ async function getTestResponse( startTime: sportEvent.startTime, endTime: sportEvent.endTime, description: sportEvent.description, - isRecurring: sportEvent.isRecurring, recurrenceRule: newRule, exceptionDates: sportEvent.exceptionDates, }, @@ -452,7 +443,6 @@ async function getTestResponse( exceptionDate.getTime() + 90 * 60 * 1000, ), // +90 min description: sportEvent.description, - isRecurring: sportEvent.isRecurring, recurrenceRule: sportEvent.recurrenceRule, exceptionDates: sportEvent.exceptionDates, }, @@ -488,7 +478,6 @@ async function getTestResponse( startTime: jensEvent.startTime, endTime: jensEvent.endTime, description: jensEvent.description, - isRecurring: jensEvent.isRecurring, }, }, ], @@ -718,4 +707,12 @@ export class ChatService { return this.chatRepo.getMessages(conversationId, options); } + + async updateProposalEvent( + messageId: string, + proposalId: string, + event: CreateEventDTO, + ): Promise { + return this.chatRepo.updateProposalEvent(messageId, proposalId, event); + } } diff --git a/apps/server/src/services/interfaces/ChatRepository.ts b/apps/server/src/services/interfaces/ChatRepository.ts index 7e8d097..0dcee01 100644 --- a/apps/server/src/services/interfaces/ChatRepository.ts +++ b/apps/server/src/services/interfaces/ChatRepository.ts @@ -2,6 +2,7 @@ import { ChatMessage, Conversation, CreateMessageDTO, + CreateEventDTO, GetMessagesOptions, UpdateMessageDTO, } from "@calchat/shared"; @@ -32,4 +33,10 @@ export interface ChatRepository { proposalId: string, respondedAction: "confirm" | "reject", ): Promise; + + updateProposalEvent( + messageId: string, + proposalId: string, + event: CreateEventDTO, + ): Promise; } diff --git a/apps/server/src/utils/recurrenceExpander.ts b/apps/server/src/utils/recurrenceExpander.ts index 36645fa..99d3745 100644 --- a/apps/server/src/utils/recurrenceExpander.ts +++ b/apps/server/src/utils/recurrenceExpander.ts @@ -44,9 +44,13 @@ export function expandRecurringEvents( const endTime = new Date(event.endTime); const duration = endTime.getTime() - startTime.getTime(); + // For multi-day events: adjust range start back by event duration + // to find events that start before rangeStart but extend into the range + const adjustedRangeStart = new Date(rangeStart.getTime() - duration); + if (!event.isRecurring || !event.recurrenceRule) { - // Non-recurring event: add as-is if within range - if (startTime >= rangeStart && startTime <= rangeEnd) { + // Non-recurring event: add if it overlaps with the range + if (endTime >= rangeStart && startTime <= rangeEnd) { expanded.push({ ...event, occurrenceStart: startTime, @@ -64,9 +68,11 @@ export function expandRecurringEvents( `DTSTART:${formatRRuleDateString(startTime)}\nRRULE:${ruleString}`, ); - // Get occurrences within the range (using fake UTC dates) + // Get occurrences within the adjusted range (using fake UTC dates) + // Use adjustedRangeStart to catch multi-day events that start before + // rangeStart but still extend into the range const occurrences = rule.between( - toRRuleDate(rangeStart), + toRRuleDate(adjustedRangeStart), toRRuleDate(rangeEnd), true, // inclusive ); @@ -78,6 +84,11 @@ export function expandRecurringEvents( const occurrenceStart = fromRRuleDate(occurrence); const occurrenceEnd = new Date(occurrenceStart.getTime() + duration); + // Only include if occurrence actually overlaps with the original range + if (occurrenceEnd < rangeStart || occurrenceStart > rangeEnd) { + continue; + } + // Skip if this occurrence is in the exception dates const dateKey = formatDateKey(occurrenceStart); if (exceptionSet.has(dateKey)) { diff --git a/package-lock.json b/package-lock.json index a86bfcc..21d910f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "dependencies": { "@calchat/shared": "*", "@expo/vector-icons": "^15.0.3", + "@react-native-community/datetimepicker": "8.4.4", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", @@ -52,6 +53,7 @@ "react-native-screens": "~4.16.0", "react-native-web": "~0.21.0", "react-native-worklets": "0.5.1", + "rrule": "^2.8.1", "zustand": "^5.0.9" }, "devDependencies": { @@ -2647,6 +2649,29 @@ } } }, + "node_modules/@react-native-community/datetimepicker": { + "version": "8.4.4", + "resolved": "https://registry.npmjs.org/@react-native-community/datetimepicker/-/datetimepicker-8.4.4.tgz", + "integrity": "sha512-bc4ZixEHxZC9/qf5gbdYvIJiLZ5CLmEsC3j+Yhe1D1KC/3QhaIfGDVdUcid0PdlSoGOSEq4VlB93AWyetEyBSQ==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "expo": ">=52.0.0", + "react": "*", + "react-native": "*", + "react-native-windows": "*" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "react-native-windows": { + "optional": true + } + } + }, "node_modules/@react-native/assets-registry": { "version": "0.81.5", "license": "MIT", @@ -10537,6 +10562,8 @@ }, "node_modules/rrule": { "version": "2.8.1", + "resolved": "https://registry.npmjs.org/rrule/-/rrule-2.8.1.tgz", + "integrity": "sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==", "license": "BSD-3-Clause", "dependencies": { "tslib": "^2.4.0" diff --git a/packages/shared/src/models/CalendarEvent.ts b/packages/shared/src/models/CalendarEvent.ts index 1baf29d..5fcb701 100644 --- a/packages/shared/src/models/CalendarEvent.ts +++ b/packages/shared/src/models/CalendarEvent.ts @@ -26,7 +26,6 @@ export interface CreateEventDTO { startTime: Date; endTime: Date; note?: string; - isRecurring?: boolean; recurrenceRule?: string; exceptionDates?: string[]; // For display in proposals } @@ -37,7 +36,6 @@ export interface UpdateEventDTO { startTime?: Date; endTime?: Date; note?: string; - isRecurring?: boolean; recurrenceRule?: string; exceptionDates?: string[]; } diff --git a/packages/shared/src/utils/dateHelpers.ts b/packages/shared/src/utils/dateHelpers.ts index a0a94b5..29a7eca 100644 --- a/packages/shared/src/utils/dateHelpers.ts +++ b/packages/shared/src/utils/dateHelpers.ts @@ -34,3 +34,15 @@ export function getDay( result.setHours(hour, minute, 0, 0); return result; } + +/** + * Check if an event spans multiple days. + * Compares dates at midnight to determine if start and end are on different calendar days. + */ +export function isMultiDayEvent(start: Date, end: Date): boolean { + const startDate = new Date(start); + const endDate = new Date(end); + startDate.setHours(0, 0, 0, 0); + endDate.setHours(0, 0, 0, 0); + return startDate.getTime() !== endDate.getTime(); +} diff --git a/packages/shared/src/utils/formatters.ts b/packages/shared/src/utils/formatters.ts index 1bb9ebe..bb24d1c 100644 --- a/packages/shared/src/utils/formatters.ts +++ b/packages/shared/src/utils/formatters.ts @@ -46,3 +46,26 @@ export function formatDateWithWeekday(date: Date): string { year: "numeric", }); } + +/** + * Format date as DD.MM. (short, without year) + */ +export function formatDateShort(date: Date): string { + const d = new Date(date); + return d.toLocaleDateString("de-DE", { + day: "2-digit", + month: "2-digit", + }); +} + +/** + * Format date with weekday short as "Mo., DD.MM." + */ +export function formatDateWithWeekdayShort(date: Date): string { + const d = new Date(date); + return d.toLocaleDateString("de-DE", { + weekday: "short", + day: "2-digit", + month: "2-digit", + }); +} diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts index 2783c54..0c1f464 100644 --- a/packages/shared/src/utils/index.ts +++ b/packages/shared/src/utils/index.ts @@ -1,3 +1,3 @@ export * from "./dateHelpers"; -export * from "./rruleHelpers"; export * from "./formatters"; +export * from "./rruleHelpers"; diff --git a/packages/shared/src/utils/rruleHelpers.ts b/packages/shared/src/utils/rruleHelpers.ts index 44171a3..51717c3 100644 --- a/packages/shared/src/utils/rruleHelpers.ts +++ b/packages/shared/src/utils/rruleHelpers.ts @@ -1,49 +1,29 @@ -import { rrulestr, Frequency } from "rrule"; +/** + * RRULE building and parsing helpers. + */ -export interface ParsedRRule { - freq: string; // "YEARLY", "MONTHLY", "WEEKLY", "DAILY", etc. - until?: Date; - count?: number; - interval?: number; - byDay?: string[]; // ["MO", "WE", "FR"] -} +export type RepeatType = "Tag" | "Woche" | "Monat" | "Jahr"; -const FREQ_NAMES: Record = { - [Frequency.YEARLY]: "YEARLY", - [Frequency.MONTHLY]: "MONTHLY", - [Frequency.WEEKLY]: "WEEKLY", - [Frequency.DAILY]: "DAILY", - [Frequency.HOURLY]: "HOURLY", - [Frequency.MINUTELY]: "MINUTELY", - [Frequency.SECONDLY]: "SECONDLY", +const REPEAT_TYPE_TO_FREQ: Record = { + Tag: "DAILY", + Woche: "WEEKLY", + Monat: "MONTHLY", + Jahr: "YEARLY", }; /** - * Parses an RRULE string and extracts the relevant fields. - * Handles both with and without "RRULE:" prefix. + * Build an RRULE string from repeat count and type. + * + * @param repeatType - The type of repetition (Tag, Woche, Monat, Jahr) + * @param interval - The interval between repetitions (default: 1) + * @returns RRULE string like "FREQ=WEEKLY;INTERVAL=2" */ -export function parseRRule(ruleString: string): ParsedRRule | null { - if (!ruleString) { - return null; +export function buildRRule(repeatType: RepeatType, interval: number = 1): string { + const freq = REPEAT_TYPE_TO_FREQ[repeatType]; + + if (interval <= 1) { + return `FREQ=${freq}`; } - try { - // Ensure RRULE: prefix is present - const normalized = ruleString.startsWith("RRULE:") - ? ruleString - : `RRULE:${ruleString}`; - - const rule = rrulestr(normalized); - const options = rule.options; - - return { - freq: FREQ_NAMES[options.freq] || "UNKNOWN", - until: options.until || undefined, - count: options.count || undefined, - interval: options.interval > 1 ? options.interval : undefined, - byDay: options.byweekday?.map((d) => d.toString()) || undefined, - }; - } catch { - return null; - } + return `FREQ=${freq};INTERVAL=${interval}`; }