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
This commit is contained in:
2026-01-31 18:46:31 +01:00
parent 617543a603
commit 6f0d172bf2
33 changed files with 1394 additions and 289 deletions

View File

@@ -76,6 +76,7 @@ src/
│ │ ├── chat.tsx # Chat screen (AI conversation) │ │ ├── chat.tsx # Chat screen (AI conversation)
│ │ ├── calendar.tsx # Calendar overview │ │ ├── calendar.tsx # Calendar overview
│ │ └── settings.tsx # Settings screen (theme switcher, logout) │ │ └── settings.tsx # Settings screen (theme switcher, logout)
│ ├── editEvent.tsx # Event edit screen (dual-mode: calendar/chat)
│ ├── event/ │ ├── event/
│ │ └── [id].tsx # Event detail screen (dynamic route) │ │ └── [id].tsx # Event detail screen (dynamic route)
│ └── note/ │ └── note/
@@ -93,8 +94,10 @@ src/
│ ├── EventCardBase.tsx # Event card layout with icons (uses CardBase) │ ├── EventCardBase.tsx # Event card layout with icons (uses CardBase)
│ ├── EventCard.tsx # Calendar event card (uses EventCardBase + edit/delete buttons) │ ├── EventCard.tsx # Calendar event card (uses EventCardBase + edit/delete buttons)
│ ├── EventConfirmDialog.tsx # AI-proposed event confirmation modal (skeleton) │ ├── 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/edit buttons)
── DeleteEventModal.tsx # Delete confirmation modal (uses ModalBase) ── 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 ├── Themes.tsx # Theme definitions: THEMES object with defaultLight/defaultDark, Theme type
├── logging/ ├── logging/
│ ├── index.ts # Re-exports │ ├── 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) │ ├── 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 │ ├── AuthService.ts # login(), register(), logout() - calls API and updates AuthStore
│ ├── EventService.ts # getAll(), getById(), getByDateRange(), create(), update(), delete(mode, occurrenceDate) │ ├── EventService.ts # getAll(), getById(), getByDateRange(), create(), update(), delete(mode, occurrenceDate)
│ └── ChatService.ts # sendMessage(), confirmEvent(deleteMode, occurrenceDate), rejectEvent(), getConversations(), getConversation() │ └── ChatService.ts # sendMessage(), confirmEvent(deleteMode, occurrenceDate), rejectEvent(), getConversations(), getConversation(), updateProposalEvent()
── stores/ # Zustand state management ── stores/ # Zustand state management
├── index.ts # Re-exports all stores ├── index.ts # Re-exports all stores
├── AuthStore.ts # user, isAuthenticated, isLoading, login(), logout(), loadStoredUser() ├── AuthStore.ts # user, isAuthenticated, isLoading, login(), logout(), loadStoredUser()
│ # Uses expo-secure-store (native) / localStorage (web) │ # Uses expo-secure-store (native) / localStorage (web)
├── ChatStore.ts # messages[], isWaitingForResponse, addMessage(), addMessages(), updateMessage(), clearMessages(), setWaitingForResponse(), chatMessageToMessageData() ├── ChatStore.ts # messages[], isWaitingForResponse, addMessage(), addMessages(), updateMessage(), clearMessages(), setWaitingForResponse(), chatMessageToMessageData()
├── EventsStore.ts # events[], setEvents(), addEvent(), updateEvent(), deleteEvent() ├── EventsStore.ts # events[], setEvents(), addEvent(), updateEvent(), deleteEvent()
└── ThemeStore.ts # theme, setTheme() - reactive theme switching with Zustand └── 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. **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 ├── app.ts # Entry point, DI setup, Express config
├── controllers/ # Request handlers + middleware (per architecture diagram) ├── controllers/ # Request handlers + middleware (per architecture diagram)
│ ├── AuthController.ts # login(), register(), refresh(), logout() │ ├── 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() │ ├── EventController.ts # create(), getById(), getAll(), getByDateRange(), update(), delete()
│ ├── AuthMiddleware.ts # authenticate() - X-User-Id header validation │ ├── AuthMiddleware.ts # authenticate() - X-User-Id header validation
│ └── LoggingMiddleware.ts # httpLogger - pino-http request logging │ └── LoggingMiddleware.ts # httpLogger - pino-http request logging
@@ -290,6 +295,7 @@ src/
- `POST /api/chat/reject/:conversationId/:messageId` - Reject proposed event (protected) - `POST /api/chat/reject/:conversationId/:messageId` - Reject proposed event (protected)
- `GET /api/chat/conversations` - Get all conversations (protected) - `GET /api/chat/conversations` - Get all conversations (protected)
- `GET /api/chat/conversations/:id` - Get messages of a conversation with cursor-based pagination (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 - `GET /health` - Health check
- `POST /api/ai/test` - AI test endpoint (development only) - `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 - `utils/recurrenceExpander`: expandRecurringEvents() using rrule library for RRULE parsing
- `ChatController`: getConversations(), getConversation() with cursor-based pagination support - `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 - `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) - `MongoChatRepository`: Full CRUD implemented (getConversationsByUser, createConversation, getMessages with cursor pagination, createMessage, updateMessage, updateProposalResponse, updateProposalEvent)
- `ChatRepository` interface: updateMessage() and updateProposalResponse() for per-proposal respondedAction tracking - `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 - `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/`: 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 - `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 - Auto-scroll to end on new messages and keyboard show
- keyboardDismissMode="interactive" and keyboardShouldPersistTaps="handled" - keyboardDismissMode="interactive" and keyboardShouldPersistTaps="handled"
- `EventService`: getAll(), getById(), getByDateRange(), create(), update(), delete(mode, occurrenceDate) - fully implemented with recurring delete modes - `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 - `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 - `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 - `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) - `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 - `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 - `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.) - `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 - `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 - `TypingIndicator`: Animated typing indicator component showing `. → .. → ...` loop while waiting for AI response
- Event Detail and Note screens exist as skeletons - 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 ## Building

View File

@@ -14,6 +14,7 @@
"dependencies": { "dependencies": {
"@calchat/shared": "*", "@calchat/shared": "*",
"@expo/vector-icons": "^15.0.3", "@expo/vector-icons": "^15.0.3",
"@react-native-community/datetimepicker": "8.4.4",
"@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3", "@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8", "@react-navigation/native": "^7.1.8",
@@ -43,6 +44,7 @@
"react-native-screens": "~4.16.0", "react-native-screens": "~4.16.0",
"react-native-web": "~0.21.0", "react-native-web": "~0.21.0",
"react-native-worklets": "0.5.1", "react-native-worklets": "0.5.1",
"rrule": "^2.8.1",
"zustand": "^5.0.9" "zustand": "^5.0.9"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -46,8 +46,8 @@ export const THEMES = {
messageBorderBg: "#3A3430", messageBorderBg: "#3A3430",
placeholderBg: "#4A4440", placeholderBg: "#4A4440",
calenderBg: "#3D2A1A", calenderBg: "#3D2A1A",
confirmButton: "#22c55e", confirmButton: "#136e34",
rejectButton: "#ef4444", rejectButton: "#bd1010",
disabledButton: "#555", disabledButton: "#555",
buttonText: "#FFFFFF", buttonText: "#FFFFFF",
textPrimary: "#FFFFFF", textPrimary: "#FFFFFF",

View File

@@ -1,4 +1,4 @@
import { Animated, Modal, Pressable, Text, View } from "react-native"; import { Pressable, Text, View } from "react-native";
import { import {
DAYS, DAYS,
MONTHS, MONTHS,
@@ -10,20 +10,20 @@ import Header from "../../components/Header";
import { EventCard } from "../../components/EventCard"; import { EventCard } from "../../components/EventCard";
import { DeleteEventModal } from "../../components/DeleteEventModal"; import { DeleteEventModal } from "../../components/DeleteEventModal";
import { ModalBase } from "../../components/ModalBase"; import { ModalBase } from "../../components/ModalBase";
import { ScrollableDropdown } from "../../components/ScrollableDropdown";
import React, { import React, {
useCallback, useCallback,
useEffect, useEffect,
useMemo, useMemo,
useRef,
useState, useState,
} from "react"; } from "react";
import { useFocusEffect } from "expo-router"; import { router, useFocusEffect } from "expo-router";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { useThemeStore } from "../../stores/ThemeStore"; import { useThemeStore } from "../../stores/ThemeStore";
import BaseBackground from "../../components/BaseBackground"; import BaseBackground from "../../components/BaseBackground";
import { FlashList } from "@shopify/flash-list";
import { EventService } from "../../services"; import { EventService } from "../../services";
import { useEventsStore } from "../../stores"; import { useEventsStore } from "../../stores";
import { useDropdownPosition } from "../../hooks/useDropdownPosition";
// MonthSelector types and helpers // MonthSelector types and helpers
type MonthItem = { type MonthItem = {
@@ -74,6 +74,7 @@ const Calendar = () => {
const [monthIndex, setMonthIndex] = useState(new Date().getMonth()); const [monthIndex, setMonthIndex] = useState(new Date().getMonth());
const [currentYear, setCurrentYear] = useState(new Date().getFullYear()); const [currentYear, setCurrentYear] = useState(new Date().getFullYear());
const [selectedDate, setSelectedDate] = useState<Date | null>(null); const [selectedDate, setSelectedDate] = useState<Date | null>(null);
const [overlayVisible, setOverlayVisible] = useState(false);
// State for delete modal // State for delete modal
const [deleteModalVisible, setDeleteModalVisible] = useState(false); const [deleteModalVisible, setDeleteModalVisible] = useState(false);
@@ -114,20 +115,33 @@ const Calendar = () => {
// Load events when tab gains focus or month/year changes // Load events when tab gains focus or month/year changes
// NOTE: Wrapper needed because loadEvents is async (returns Promise) // NOTE: Wrapper needed because loadEvents is async (returns Promise)
// and useFocusEffect expects a sync function (optionally returning cleanup) // and useFocusEffect expects a sync function (optionally returning cleanup)
// Also re-open overlay if selectedDate exists (for back navigation from editEvent)
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
loadEvents(); loadEvents();
}, [loadEvents]), if (selectedDate) {
setOverlayVisible(true);
}
}, [loadEvents, selectedDate]),
); );
// Group events by date (YYYY-MM-DD format) // Group events by date (YYYY-MM-DD format)
// Multi-day events are added to all days they span
const eventsByDate = useMemo(() => { const eventsByDate = useMemo(() => {
const map = new Map<string, ExpandedEvent[]>(); const map = new Map<string, ExpandedEvent[]>();
events.forEach((e) => { events.forEach((e) => {
const date = new Date(e.occurrenceStart); const start = new Date(e.occurrenceStart);
const key = getDateKey(date); 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, []); if (!map.has(key)) map.set(key, []);
map.get(key)!.push(e); map.get(key)!.push(e);
current.setDate(current.getDate() + 1);
}
}); });
return map; return map;
}, [events]); }, [events]);
@@ -147,19 +161,32 @@ const Calendar = () => {
}); });
}; };
const handleDayPress = (date: Date, hasEvents: boolean) => { const handleDayPress = (date: Date) => {
if (hasEvents) {
setSelectedDate(date); setSelectedDate(date);
} setOverlayVisible(true);
}; };
const handleCloseOverlay = () => { const handleCloseOverlay = () => {
setSelectedDate(null); setSelectedDate(null);
setOverlayVisible(false);
}; };
const handleEditEvent = (event: ExpandedEvent) => { const handleCreateEvent = () => {
console.log("Edit event:", event.id); setOverlayVisible(false);
// TODO: Navigate to event edit screen 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) => { const handleDeleteEvent = (event: ExpandedEvent) => {
@@ -222,12 +249,13 @@ const Calendar = () => {
onDayPress={handleDayPress} onDayPress={handleDayPress}
/> />
<EventOverlay <EventOverlay
visible={selectedDate !== null && !deleteModalVisible} visible={overlayVisible && !deleteModalVisible}
date={selectedDate} date={selectedDate}
events={selectedDateEvents} events={selectedDateEvents}
onClose={handleCloseOverlay} onClose={handleCloseOverlay}
onEditEvent={handleEditEvent} onEditEvent={handleEditEvent}
onDeleteEvent={handleDeleteEvent} onDeleteEvent={handleDeleteEvent}
onCreateEvent={handleCreateEvent}
/> />
<DeleteEventModal <DeleteEventModal
visible={deleteModalVisible} visible={deleteModalVisible}
@@ -245,8 +273,9 @@ type EventOverlayProps = {
date: Date | null; date: Date | null;
events: ExpandedEvent[]; events: ExpandedEvent[];
onClose: () => void; onClose: () => void;
onEditEvent: (event: ExpandedEvent) => void; onEditEvent: (event?: ExpandedEvent) => void;
onDeleteEvent: (event: ExpandedEvent) => void; onDeleteEvent: (event: ExpandedEvent) => void;
onCreateEvent: () => void;
}; };
const EventOverlay = ({ const EventOverlay = ({
@@ -256,7 +285,10 @@ const EventOverlay = ({
onClose, onClose,
onEditEvent, onEditEvent,
onDeleteEvent, onDeleteEvent,
onCreateEvent,
}: EventOverlayProps) => { }: EventOverlayProps) => {
const { theme } = useThemeStore();
if (!date) return null; if (!date) return null;
const dateString = date.toLocaleDateString("de-DE", { const dateString = date.toLocaleDateString("de-DE", {
@@ -268,12 +300,26 @@ const EventOverlay = ({
const subtitle = `${events.length} ${events.length === 1 ? "Termin" : "Termine"}`; const subtitle = `${events.length} ${events.length === 1 ? "Termin" : "Termine"}`;
const addEventAttachment = (
<Pressable
className="flex flex-row justify-center items-center py-3"
style={{ backgroundColor: theme.confirmButton }}
onPress={onCreateEvent}
>
<Ionicons name="add-outline" size={24} color={theme.buttonText} />
<Text style={{ color: theme.buttonText }} className="font-semibold ml-1">
Neuen Termin erstellen
</Text>
</Pressable>
);
return ( return (
<ModalBase <ModalBase
visible={visible} visible={visible}
onClose={onClose} onClose={onClose}
title={dateString} title={dateString}
subtitle={subtitle} subtitle={subtitle}
attachment={addEventAttachment}
footer={{ label: "Schliessen", onPress: onClose }} footer={{ label: "Schliessen", onPress: onClose }}
scrollable={true} scrollable={true}
maxContentHeight={400} maxContentHeight={400}
@@ -299,6 +345,8 @@ type MonthSelectorProps = {
onSelectMonth: (year: number, monthIndex: number) => void; onSelectMonth: (year: number, monthIndex: number) => void;
}; };
const INITIAL_RANGE = 12; // 12 months before and after current
const MonthSelector = ({ const MonthSelector = ({
modalVisible, modalVisible,
onClose, onClose,
@@ -307,14 +355,10 @@ const MonthSelector = ({
currentMonthIndex, currentMonthIndex,
onSelectMonth, onSelectMonth,
}: MonthSelectorProps) => { }: MonthSelectorProps) => {
const { theme } = useThemeStore();
const heightAnim = useRef(new Animated.Value(0)).current;
const listRef = useRef<React.ComponentRef<typeof FlashList<MonthItem>>>(null);
const INITIAL_RANGE = 12; // 12 months before and after current
const [monthSelectorData, setMonthSelectorData] = useState<MonthItem[]>([]); const [monthSelectorData, setMonthSelectorData] = useState<MonthItem[]>([]);
const appendMonths = (direction: "start" | "end", count: number) => { const appendMonths = useCallback(
(direction: "start" | "end", count: number) => {
setMonthSelectorData((prevData) => { setMonthSelectorData((prevData) => {
if (prevData.length === 0) return prevData; if (prevData.length === 0) return prevData;
@@ -354,33 +398,37 @@ const MonthSelector = ({
? [...newMonths, ...prevData] ? [...newMonths, ...prevData]
: [...prevData, ...newMonths]; : [...prevData, ...newMonths];
}); });
}; },
[],
);
// Generate fresh data when modal opens, clear when closes
useEffect(() => { useEffect(() => {
if (modalVisible) { if (modalVisible) {
// Generate fresh data centered on current month
setMonthSelectorData( setMonthSelectorData(
generateMonths(currentYear, currentMonthIndex, INITIAL_RANGE), generateMonths(currentYear, currentMonthIndex, INITIAL_RANGE),
); );
Animated.timing(heightAnim, {
toValue: 200,
duration: 200,
useNativeDriver: false,
}).start();
} else { } else {
heightAnim.setValue(0);
// Clear data when closing
setMonthSelectorData([]); setMonthSelectorData([]);
} }
}, [modalVisible, heightAnim, currentYear, currentMonthIndex]); }, [modalVisible, currentYear, currentMonthIndex]);
const renderItem = ({ item }: { item: MonthItem }) => ( const handleSelect = useCallback(
<Pressable (item: MonthItem) => {
onPress={() => {
onSelectMonth(item.year, item.monthIndex); onSelectMonth(item.year, item.monthIndex);
onClose(); onClose();
}} },
> [onSelectMonth, onClose],
);
return (
<ScrollableDropdown
visible={modalVisible}
onClose={onClose}
position={position}
data={monthSelectorData}
keyExtractor={(item) => item.id}
renderItem={(item, theme) => (
<View <View
className="w-full flex justify-center items-center py-2" className="w-full flex justify-center items-center py-2"
style={{ style={{
@@ -392,46 +440,13 @@ const MonthSelector = ({
{item.label} {item.label}
</Text> </Text>
</View> </View>
</Pressable> )}
); onSelect={handleSelect}
height={200}
return (
<Modal
visible={modalVisible}
transparent={true}
animationType="none"
onRequestClose={onClose}
>
<Pressable className="flex-1 rounded-lg" onPress={onClose}>
<Animated.View
className="absolute overflow-hidden"
style={{
top: position.top,
left: position.left,
width: position.width,
height: heightAnim,
backgroundColor: theme.primeBg,
borderWidth: 2,
borderColor: theme.borderPrimary,
borderRadius: 8,
}}
>
<FlashList
className="w-full"
style={{ borderRadius: 8 }}
ref={listRef}
keyExtractor={(item) => item.id}
data={monthSelectorData}
initialScrollIndex={INITIAL_RANGE} initialScrollIndex={INITIAL_RANGE}
onEndReachedThreshold={0.5}
onEndReached={() => appendMonths("end", 12)} onEndReached={() => appendMonths("end", 12)}
onStartReachedThreshold={0.5}
onStartReached={() => appendMonths("start", 12)} onStartReached={() => appendMonths("start", 12)}
renderItem={renderItem}
/> />
</Animated.View>
</Pressable>
</Modal>
); );
}; };
@@ -445,29 +460,16 @@ type CalendarHeaderProps = {
const CalendarHeader = (props: CalendarHeaderProps) => { const CalendarHeader = (props: CalendarHeaderProps) => {
const { theme } = useThemeStore(); const { theme } = useThemeStore();
const [modalVisible, setModalVisible] = useState(false); const dropdown = useDropdownPosition();
const [dropdownPosition, setDropdownPosition] = useState({
top: 0,
left: 0,
width: 0,
});
const containerRef = useRef<View>(null);
const prevMonth = () => props.changeMonth(-1); const prevMonth = () => props.changeMonth(-1);
const nextMonth = () => 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 ( return (
<Header className="flex flex-row items-center justify-between"> <Header className="flex flex-row items-center justify-between">
<ChangeMonthButton onPress={prevMonth} icon="chevron-back" /> <ChangeMonthButton onPress={prevMonth} icon="chevron-back" />
<View <View
ref={containerRef} ref={dropdown.ref}
className="relative flex flex-row items-center justify-around" className="relative flex flex-row items-center justify-around"
> >
<Text className="text-4xl px-1" style={{ color: theme.textPrimary }}> <Text className="text-4xl px-1" style={{ color: theme.textPrimary }}>
@@ -486,15 +488,15 @@ const CalendarHeader = (props: CalendarHeaderProps) => {
// Android shadow // Android shadow
elevation: 6, elevation: 6,
}} }}
onPress={measureAndOpen} onPress={dropdown.open}
> >
<Ionicons name="chevron-down" size={28} color={theme.primeFg} /> <Ionicons name="chevron-down" size={28} color={theme.primeFg} />
</Pressable> </Pressable>
</View> </View>
<MonthSelector <MonthSelector
modalVisible={modalVisible} modalVisible={dropdown.visible}
onClose={() => setModalVisible(false)} onClose={dropdown.close}
position={dropdownPosition} position={dropdown.position}
currentYear={props.currentYear} currentYear={props.currentYear}
currentMonthIndex={props.monthIndex} currentMonthIndex={props.monthIndex}
onSelectMonth={(year, month) => { onSelectMonth={(year, month) => {
@@ -561,7 +563,7 @@ type CalendarGridProps = {
month: Month; month: Month;
year: number; year: number;
eventsByDate: Map<string, ExpandedEvent[]>; eventsByDate: Map<string, ExpandedEvent[]>;
onDayPress: (date: Date, hasEvents: boolean) => void; onDayPress: (date: Date) => void;
}; };
const CalendarGrid = (props: CalendarGridProps) => { const CalendarGrid = (props: CalendarGridProps) => {
@@ -602,7 +604,7 @@ const CalendarGrid = (props: CalendarGridProps) => {
date={date} date={date}
month={props.month} month={props.month}
hasEvents={hasEvents} hasEvents={hasEvents}
onPress={() => props.onDayPress(date, hasEvents)} onPress={() => props.onDayPress(date)}
/> />
); );
})} })}

View File

@@ -9,7 +9,7 @@ import {
} from "react-native"; } from "react-native";
import { useThemeStore } from "../../stores/ThemeStore"; import { useThemeStore } from "../../stores/ThemeStore";
import React, { useState, useRef, useEffect, useCallback } from "react"; 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 Header from "../../components/Header";
import BaseBackground from "../../components/BaseBackground"; import BaseBackground from "../../components/BaseBackground";
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
@@ -38,6 +38,7 @@ type ChatMessageProps = {
proposedChanges?: ProposedEventChange[]; proposedChanges?: ProposedEventChange[];
onConfirm?: (proposalId: string, proposal: ProposedEventChange) => void; onConfirm?: (proposalId: string, proposal: ProposedEventChange) => void;
onReject?: (proposalId: string) => void; onReject?: (proposalId: string) => void;
onEdit?: (proposalId: string, proposal: ProposedEventChange) => void;
}; };
type ChatInputProps = { type ChatInputProps = {
@@ -62,6 +63,7 @@ const Chat = () => {
const [currentConversationId, setCurrentConversationId] = useState< const [currentConversationId, setCurrentConversationId] = useState<
string | undefined string | undefined
>(); >();
const [hasLoadedMessages, setHasLoadedMessages] = useState(false);
useEffect(() => { useEffect(() => {
const keyboardDidShow = Keyboard.addListener( const keyboardDidShow = Keyboard.addListener(
@@ -71,10 +73,11 @@ const Chat = () => {
return () => keyboardDidShow.remove(); 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( useFocusEffect(
useCallback(() => { useCallback(() => {
if (isAuthLoading || !isAuthenticated) return; if (isAuthLoading || !isAuthenticated || hasLoadedMessages) return;
const fetchMessages = async () => { const fetchMessages = async () => {
try { try {
@@ -91,10 +94,12 @@ const Chat = () => {
} }
} catch (error) { } catch (error) {
console.error("Failed to load messages:", error); console.error("Failed to load messages:", error);
} finally {
setHasLoadedMessages(true);
} }
}; };
fetchMessages(); fetchMessages();
}, [isAuthLoading, isAuthenticated]), }, [isAuthLoading, isAuthenticated, hasLoadedMessages]),
); );
const scrollToEnd = () => { 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) => { const handleSend = async (text: string) => {
// Show user message immediately // Show user message immediately
const userMessage: MessageData = { const userMessage: MessageData = {
@@ -238,6 +259,14 @@ const Chat = () => {
proposalId, proposalId,
) )
} }
onEdit={(proposalId, proposal) =>
handleEditProposal(
item.id,
item.conversationId!,
proposalId,
proposal,
)
}
/> />
)} )}
keyExtractor={(item) => item.id} keyExtractor={(item) => item.id}
@@ -334,6 +363,7 @@ const ChatMessage = ({
proposedChanges, proposedChanges,
onConfirm, onConfirm,
onReject, onReject,
onEdit,
}: ChatMessageProps) => { }: ChatMessageProps) => {
const { theme } = useThemeStore(); const { theme } = useThemeStore();
const [currentIndex, setCurrentIndex] = useState(0); const [currentIndex, setCurrentIndex] = useState(0);
@@ -361,7 +391,7 @@ const ChatMessage = ({
{content} {content}
</Text> </Text>
{hasProposals && currentProposal && onConfirm && onReject && ( {hasProposals && currentProposal && onConfirm && onReject && onEdit && (
<View> <View>
{/* Event card with optional navigation arrows */} {/* Event card with optional navigation arrows */}
<View className="flex-row items-center"> <View className="flex-row items-center">
@@ -381,8 +411,9 @@ const ChatMessage = ({
<View className="flex-1"> <View className="flex-1">
<ProposedEventCard <ProposedEventCard
proposedChange={currentProposal} proposedChange={currentProposal}
onConfirm={() => onConfirm(currentProposal.id, currentProposal)} onConfirm={(proposal) => onConfirm(proposal.id, proposal)}
onReject={() => onReject(currentProposal.id)} onReject={() => onReject(currentProposal.id)}
onEdit={(proposal) => onEdit(proposal.id, proposal)}
/> />
</View> </View>

View File

@@ -5,7 +5,7 @@ import { useThemeStore } from "../../stores/ThemeStore";
import { AuthService } from "../../services/AuthService"; import { AuthService } from "../../services/AuthService";
import { router } from "expo-router"; import { router } from "expo-router";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import Header from "../../components/Header"; import { SimpleHeader } from "../../components/Header";
import { THEMES } from "../../Themes"; import { THEMES } from "../../Themes";
const handleLogout = async () => { const handleLogout = async () => {
@@ -18,11 +18,7 @@ const Settings = () => {
return ( return (
<BaseBackground> <BaseBackground>
<Header> <SimpleHeader text="Settings" />
<View className="h-full flex justify-center">
<Text className="text-center text-3xl font-bold">Settings</Text>
</View>
</Header>
<View className="flex items-center mt-4"> <View className="flex items-center mt-4">
<BaseButton onPress={handleLogout} solid={true}> <BaseButton onPress={handleLogout} solid={true}>
<Ionicons name="log-out-outline" size={24} color={theme.primeFg} />{" "} <Ionicons name="log-out-outline" size={24} color={theme.primeFg} />{" "}

View File

@@ -7,8 +7,9 @@ export default function RootLayout() {
<Stack.Screen name="(tabs)" /> <Stack.Screen name="(tabs)" />
<Stack.Screen name="login" /> <Stack.Screen name="login" />
<Stack.Screen name="register" /> <Stack.Screen name="register" />
<Stack.Screen name="event/[id]" /> <Stack.Screen name="editEvent" />
<Stack.Screen name="note/[id]" /> {/* <Stack.Screen name="event/[id]" /> */}
{/* <Stack.Screen name="note/[id]" /> */}
</Stack> </Stack>
); );
} }

View File

@@ -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 (
<View className={props.className}>
<Text className="text-xl" style={{ color: theme.textPrimary }}>
{props.titel}
</Text>
<TextInput
onChangeText={props.onValueChange}
value={props.text}
multiline={props.multiline}
className="flex-1 border border-solid rounded-2xl px-3 py-2 w-full h-11/12"
style={{
backgroundColor: theme.messageBorderBg,
color: theme.textPrimary,
textAlignVertical: "top",
borderColor: focused ? theme.chatBot : theme.borderPrimary,
}}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
/>
</View>
);
};
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 (
<View className="flex flex-row w-11/12 mt-4 items-end justify-between gap-x-2">
<Text className="text-xl pb-2" style={{ color: theme.textPrimary }}>
{title}
</Text>
<View className="flex flex-row w-10/12 gap-x-2">
<DatePickerButton
className="flex-1"
label={showLabels ? "Datum" : undefined}
value={dateValue}
onChange={onDateChange}
/>
<TimePickerButton
className="flex-1"
label={showLabels ? "Uhrzeit" : undefined}
value={dateValue}
onChange={onTimeChange}
/>
</View>
</View>
);
};
type RepeatType = "Tag" | "Woche" | "Monat" | "Jahr";
const REPEAT_TYPE_LABELS: Record<RepeatType, string> = {
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 (
<Pressable
className="px-4 py-2 rounded-lg border"
style={{
backgroundColor: focused ? theme.chatBot : theme.secondaryBg,
borderColor: theme.borderPrimary,
}}
onPress={() => setRepeatType(repeatType)}
>
<Text style={{ color: focused ? theme.buttonText : theme.textPrimary }}>
{repeatType}
</Text>
</Pressable>
);
};
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 (
<View className="mt-4">
{/* Repeat Type Selection */}
<View className="flex flex-row gap-2 mb-3">
<RepeatPressable
repeatType="Tag"
setRepeatType={onRepeatTypeChange}
focused={repeatType === "Tag"}
/>
<RepeatPressable
repeatType="Woche"
setRepeatType={onRepeatTypeChange}
focused={repeatType === "Woche"}
/>
<RepeatPressable
repeatType="Monat"
setRepeatType={onRepeatTypeChange}
focused={repeatType === "Monat"}
/>
<RepeatPressable
repeatType="Jahr"
setRepeatType={onRepeatTypeChange}
focused={repeatType === "Jahr"}
/>
</View>
{/* Repeat Count Selection */}
<View className="flex flex-row items-center">
<Text className="text-lg" style={{ color: theme.textPrimary }}>
Alle{" "}
</Text>
<Pressable
ref={dropdown.ref}
className="px-4 py-2 rounded-lg border"
style={{
backgroundColor: theme.secondaryBg,
borderColor: theme.borderPrimary,
}}
onPress={dropdown.open}
>
<Text className="text-lg" style={{ color: theme.textPrimary }}>
{repeatCount}
</Text>
</Pressable>
<Text className="text-lg" style={{ color: theme.textPrimary }}>
{" "}
{typeLabel}
</Text>
</View>
{/* Count Dropdown */}
<ScrollableDropdown
visible={dropdown.visible}
onClose={dropdown.close}
position={{
bottom: 12,
left: 10,
width: 100,
}}
data={REPEAT_COUNT_DATA}
keyExtractor={(n) => String(n)}
renderItem={(n, theme) => (
<View
className="w-full flex justify-center items-center py-2"
style={{
backgroundColor: n % 2 === 0 ? theme.primeBg : theme.secondaryBg,
}}
>
<Text className="text-xl" style={{ color: theme.textPrimary }}>
{n}
</Text>
</View>
)}
onSelect={handleSelectCount}
heightRatio={0.4}
initialScrollIndex={repeatCount - 1}
/>
</View>
);
};
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 (
<Header className="flex flex-row justify-center items-center">
<HeaderButton
className="absolute left-6"
iconName="arrow-back-outline"
iconSize={36}
onPress={router.back}
/>
<View className="h-full flex justify-center ml-4">
<Text className="text-center text-3xl font-bold">{getTitle()}</Text>
</View>
</Header>
);
};
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<EditEventParams>();
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<RepeatType>("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 (
<BaseBackground>
<EditEventHeader id={id} mode={mode} />
<View className="flex-1 justify-center items-center">
<ActivityIndicator size="large" color={theme.chatBot} />
</View>
</BaseBackground>
);
}
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 (
<BaseBackground>
<EditEventHeader id={id} mode={mode} />
<View className="h-full flex items-center">
{/* Date and Time */}
<View className="w-11/12">
<EditEventTextField
className="h-16 mt-2"
titel="Titel"
text={title}
onValueChange={setTitle}
/>
<PickerRow
title="Von"
dateValue={startDate}
onDateChange={handleStartDateChange}
onTimeChange={handleStartTimeChange}
showLabels
/>
<PickerRow
title="Bis"
dateValue={endDate}
onDateChange={handleEndDateChange}
onTimeChange={handleEndTimeChange}
/>
{/* TODO: Reminder */}
{/* Notes */}
<EditEventTextField
className="h-64 mt-6"
titel="Notizen"
text={description}
onValueChange={setDescription}
multiline
/>
{/* Repeat Toggle Button */}
<Pressable
className="flex flex-row w-1/3 h-10 mt-4 rounded-lg items-center justify-evenly"
style={{
backgroundColor: repeatVisible
? theme.chatBot
: theme.secondaryBg,
borderWidth: 1,
borderColor: theme.borderPrimary,
}}
onPress={() => setRepeatVisible(!repeatVisible)}
>
<Ionicons
name="repeat"
size={24}
color={repeatVisible ? theme.buttonText : theme.textPrimary}
/>
<Text
style={{
color: repeatVisible ? theme.buttonText : theme.textPrimary,
}}
>
Wiederholen
</Text>
</Pressable>
{/* Repeat Selector (shown when toggle is active) */}
{repeatVisible && (
<RepeatSelector
repeatCount={repeatCount}
onRepeatCountChange={setRepeatCount}
repeatType={repeatType}
onRepeatTypeChange={setRepeatType}
/>
)}
</View>
</View>
{/* Send new or updated Event */}
<View className="absolute bottom-16 w-full h-16">
<Pressable
className="flex flex-row justify-center items-center py-3"
onPress={handleSave}
style={{
backgroundColor: theme.confirmButton,
}}
>
{mode !== "chat" && (
<Ionicons name="add-outline" size={24} color={theme.buttonText} />
)}
<Text
style={{ color: theme.buttonText }}
className="font-semibold ml-1"
>
{getButtonText()}
</Text>
</Pressable>
</View>
</BaseBackground>
);
};
export default EditEventScreen;

View File

@@ -8,6 +8,7 @@ type CardBaseProps = {
title: string; title: string;
subtitle?: string; subtitle?: string;
children: ReactNode; children: ReactNode;
attachment?: ReactNode; // renders between children and footer
footer?: { footer?: {
label: string; label: string;
onPress: () => void; onPress: () => void;
@@ -27,6 +28,7 @@ export const CardBase = ({
title, title,
subtitle, subtitle,
children, children,
attachment,
footer, footer,
className = "", className = "",
scrollable = false, scrollable = false,
@@ -94,6 +96,8 @@ export const CardBase = ({
contentElement contentElement
)} )}
{attachment}
{/* Footer (optional) */} {/* Footer (optional) */}
{footer && ( {footer && (
<Pressable <Pressable

View File

@@ -0,0 +1,136 @@
import { useState } from "react";
import { Platform, Modal, Pressable, Text, View } from "react-native";
import DateTimePicker, {
DateTimePickerEvent,
} from "@react-native-community/datetimepicker";
import { useThemeStore } from "../stores/ThemeStore";
import { THEMES } from "../Themes";
type DateTimePickerButtonProps = {
mode: "date" | "time";
className?: string;
label?: string;
value: Date;
onChange: (date: Date) => 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 (
<View className={className}>
{label && (
<Text style={{ color: theme.textSecondary }} className="text-sm mb-1">
{label}
</Text>
)}
<Pressable
onPress={() => setShowPicker(true)}
className="w-full rounded-lg px-3 py-2 border"
style={{
backgroundColor: theme.messageBorderBg,
borderColor: theme.borderPrimary,
}}
>
<Text style={{ color: theme.textPrimary }} className="text-base">
{formattedValue}
</Text>
</Pressable>
{Platform.OS === "ios" ? (
<Modal
visible={showPicker}
transparent
animationType="fade"
onRequestClose={() => setShowPicker(false)}
>
<Pressable
className="flex-1 justify-end"
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
onPress={() => setShowPicker(false)}
>
<View
style={{ backgroundColor: theme.secondaryBg }}
className="rounded-t-2xl"
>
<View className="flex-row justify-end p-2">
<Pressable onPress={() => setShowPicker(false)} className="p-2">
<Text
style={{ color: theme.chatBot }}
className="text-lg font-semibold"
>
Fertig
</Text>
</Pressable>
</View>
<DateTimePicker
value={value}
mode={mode}
display="spinner"
onChange={handleChange}
locale="de-DE"
is24Hour={mode === "time"}
accentColor={theme.chatBot}
textColor={theme.textPrimary}
themeVariant={isDark ? "dark" : "light"}
/>
</View>
</Pressable>
</Modal>
) : (
showPicker && (
<DateTimePicker
value={value}
mode={mode}
display="default"
onChange={handleChange}
is24Hour={mode === "time"}
accentColor={theme.chatBot}
textColor={theme.textPrimary}
themeVariant={isDark ? "dark" : "light"}
/>
)
)}
</View>
);
};
// Convenience wrappers for simpler usage
export const DatePickerButton = (
props: Omit<DateTimePickerButtonProps, "mode">
) => <DateTimePickerButton {...props} mode="date" />;
export const TimePickerButton = (
props: Omit<DateTimePickerButtonProps, "mode">
) => <DateTimePickerButton {...props} mode="time" />;
export default DateTimePickerButton;

View File

@@ -3,6 +3,12 @@ import { Feather } from "@expo/vector-icons";
import { ReactNode } from "react"; import { ReactNode } from "react";
import { useThemeStore } from "../stores/ThemeStore"; import { useThemeStore } from "../stores/ThemeStore";
import { CardBase } from "./CardBase"; import { CardBase } from "./CardBase";
import {
isMultiDayEvent,
formatDateWithWeekday,
formatDateWithWeekdayShort,
formatTime,
} from "@calchat/shared";
type EventCardBaseProps = { type EventCardBaseProps = {
className?: string; className?: string;
@@ -14,24 +20,6 @@ type EventCardBaseProps = {
children?: ReactNode; 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 { function formatDuration(start: Date, end: Date): string {
const startDate = new Date(start); const startDate = new Date(start);
const endDate = new Date(end); const endDate = new Date(end);
@@ -62,6 +50,7 @@ export const EventCardBase = ({
children, children,
}: EventCardBaseProps) => { }: EventCardBaseProps) => {
const { theme } = useThemeStore(); const { theme } = useThemeStore();
const multiDay = isMultiDayEvent(startTime, endTime);
return ( return (
<CardBase title={title} className={className} borderWidth={2}> <CardBase title={title} className={className} borderWidth={2}>
@@ -73,9 +62,16 @@ export const EventCardBase = ({
color={theme.textPrimary} color={theme.textPrimary}
style={{ marginRight: 8 }} style={{ marginRight: 8 }}
/> />
{multiDay ? (
<Text style={{ color: theme.textPrimary }}> <Text style={{ color: theme.textPrimary }}>
{formatDate(startTime)} {formatDateWithWeekdayShort(startTime)} {" "}
{formatDateWithWeekday(endTime)}
</Text> </Text>
) : (
<Text style={{ color: theme.textPrimary }}>
{formatDateWithWeekday(startTime)}
</Text>
)}
</View> </View>
{/* Time with duration */} {/* Time with duration */}
@@ -86,10 +82,16 @@ export const EventCardBase = ({
color={theme.textPrimary} color={theme.textPrimary}
style={{ marginRight: 8 }} style={{ marginRight: 8 }}
/> />
{multiDay ? (
<Text style={{ color: theme.textPrimary }}>
{formatTime(startTime)} {formatTime(endTime)}
</Text>
) : (
<Text style={{ color: theme.textPrimary }}> <Text style={{ color: theme.textPrimary }}>
{formatTime(startTime)} - {formatTime(endTime)} ( {formatTime(startTime)} - {formatTime(endTime)} (
{formatDuration(startTime, endTime)}) {formatDuration(startTime, endTime)})
</Text> </Text>
)}
</View> </View>
{/* Recurring indicator */} {/* Recurring indicator */}

View File

@@ -1,6 +1,7 @@
import { View } from "react-native"; import { View, Text, Pressable } from "react-native";
import { useThemeStore } from "../stores/ThemeStore"; import { useThemeStore } from "../stores/ThemeStore";
import { ReactNode } from "react"; import { ComponentProps, ReactNode } from "react";
import { Ionicons } from "@expo/vector-icons";
type HeaderProps = { type HeaderProps = {
children?: ReactNode; children?: ReactNode;
@@ -37,4 +38,54 @@ const Header = (props: HeaderProps) => {
); );
}; };
type HeaderButton = {
className?: string;
iconName: ComponentProps<typeof Ionicons>["name"];
iconSize: number;
onPress?: () => void;
};
export const HeaderButton = (props: HeaderButton) => {
const { theme } = useThemeStore();
return (
<Pressable
onPress={props.onPress}
className={
"w-16 h-16 flex items-center justify-center mx-2 rounded-xl border border-solid absolute left-6 " +
props.className
}
style={{
backgroundColor: theme.chatBot,
borderColor: theme.primeFg,
// iOS shadow
shadowColor: theme.shadowColor,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
// Android shadow
elevation: 6,
}}
>
<Ionicons
name={props.iconName}
size={props.iconSize}
color={theme.buttonText}
/>
</Pressable>
);
};
type SimpleHeaderProps = {
text: string;
};
export const SimpleHeader = ({ text }: SimpleHeaderProps) => (
<Header>
<View className="h-full flex justify-center">
<Text className="text-center text-3xl font-bold">{text}</Text>
</View>
</Header>
);
export default Header; export default Header;

View File

@@ -9,6 +9,7 @@ type ModalBaseProps = {
title: string; title: string;
subtitle?: string; subtitle?: string;
children: ReactNode; children: ReactNode;
attachment?: ReactNode;
footer?: { footer?: {
label: string; label: string;
onPress: () => void; onPress: () => void;
@@ -23,6 +24,7 @@ export const ModalBase = ({
title, title,
subtitle, subtitle,
children, children,
attachment,
footer, footer,
scrollable, scrollable,
maxContentHeight, maxContentHeight,
@@ -55,6 +57,7 @@ export const ModalBase = ({
<CardBase <CardBase
title={title} title={title}
subtitle={subtitle} subtitle={subtitle}
attachment={attachment}
footer={footer} footer={footer}
scrollable={scrollable} scrollable={scrollable}
maxContentHeight={maxContentHeight} maxContentHeight={maxContentHeight}

View File

@@ -1,25 +1,31 @@
import { View, Text, Pressable } from "react-native"; import { View, Text, Pressable } from "react-native";
import { Feather } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import { ProposedEventChange, parseRRule, formatDate } from "@calchat/shared"; import { ProposedEventChange, formatDate } from "@calchat/shared";
import { rrulestr } from "rrule";
import { useThemeStore } from "../stores/ThemeStore"; import { useThemeStore } from "../stores/ThemeStore";
import { EventCardBase } from "./EventCardBase"; import { EventCardBase } from "./EventCardBase";
type ProposedEventCardProps = { type ProposedEventCardProps = {
proposedChange: ProposedEventChange; proposedChange: ProposedEventChange;
onConfirm: () => void; onConfirm: (proposal: ProposedEventChange) => void;
onReject: () => void; onReject: () => void;
onEdit?: (proposal: ProposedEventChange) => void;
}; };
const ConfirmRejectButtons = ({ const ActionButtons = ({
isDisabled, isDisabled,
respondedAction, respondedAction,
showEdit,
onConfirm, onConfirm,
onReject, onReject,
onEdit,
}: { }: {
isDisabled: boolean; isDisabled: boolean;
respondedAction?: "confirm" | "reject"; respondedAction?: "confirm" | "reject";
showEdit: boolean;
onConfirm: () => void; onConfirm: () => void;
onReject: () => void; onReject: () => void;
onEdit?: () => void;
}) => { }) => {
const { theme } = useThemeStore(); const { theme } = useThemeStore();
return ( return (
@@ -56,6 +62,19 @@ const ConfirmRejectButtons = ({
Ablehnen Ablehnen
</Text> </Text>
</Pressable> </Pressable>
{showEdit && onEdit && (
<Pressable
onPress={onEdit}
className="py-2 px-3 rounded-lg items-center"
style={{
backgroundColor: theme.secondaryBg,
borderWidth: 1,
borderColor: theme.borderPrimary,
}}
>
<Feather name="edit-2" size={18} color={theme.textPrimary} />
</Pressable>
)}
</View> </View>
); );
}; };
@@ -64,6 +83,7 @@ export const ProposedEventCard = ({
proposedChange, proposedChange,
onConfirm, onConfirm,
onReject, onReject,
onEdit,
}: ProposedEventCardProps) => { }: ProposedEventCardProps) => {
const { theme } = useThemeStore(); const { theme } = useThemeStore();
const event = proposedChange.event; const event = proposedChange.event;
@@ -79,7 +99,7 @@ export const ProposedEventCard = ({
const newUntilDate = const newUntilDate =
proposedChange.action === "update" && proposedChange.action === "update" &&
event?.recurrenceRule && event?.recurrenceRule &&
parseRRule(event.recurrenceRule)?.until; rrulestr(event.recurrenceRule).options.until;
if (!event) { if (!event) {
return null; return null;
@@ -93,7 +113,7 @@ export const ProposedEventCard = ({
startTime={event.startTime} startTime={event.startTime}
endTime={event.endTime} endTime={event.endTime}
description={event.description} description={event.description}
isRecurring={event.isRecurring} isRecurring={!!event.recurrenceRule}
> >
{/* Show new exception date for delete/single actions */} {/* Show new exception date for delete/single actions */}
{newExceptionDate && ( {newExceptionDate && (
@@ -123,11 +143,13 @@ export const ProposedEventCard = ({
</Text> </Text>
</View> </View>
)} )}
<ConfirmRejectButtons <ActionButtons
isDisabled={isDisabled} isDisabled={isDisabled}
respondedAction={proposedChange.respondedAction} respondedAction={proposedChange.respondedAction}
onConfirm={onConfirm} showEdit={proposedChange.action !== "delete" && !isDisabled}
onConfirm={() => onConfirm(proposedChange)}
onReject={onReject} onReject={onReject}
onEdit={onEdit ? () => onEdit(proposedChange) : undefined}
/> />
</EventCardBase> </EventCardBase>
</View> </View>

View File

@@ -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<T> = {
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 = <T,>({
visible,
onClose,
position,
data,
keyExtractor,
renderItem,
onSelect,
height = 200,
heightRatio,
initialScrollIndex = 0,
onEndReached,
onStartReached,
}: ScrollableDropdownProps<T>) => {
const { theme } = useThemeStore();
const { height: screenHeight } = useWindowDimensions();
const heightAnim = useRef(new Animated.Value(0)).current;
const listRef = useRef<React.ComponentRef<typeof FlashList<T>>>(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 (
<Modal
visible={visible}
transparent={true}
animationType="none"
onRequestClose={onClose}
>
<Pressable className="flex-1 rounded-lg" onPress={onClose}>
<Animated.View
className="absolute overflow-hidden"
style={{
top: topValue,
left: position.left,
width: position.width,
height: heightAnim,
backgroundColor: theme.primeBg,
borderWidth: 2,
borderColor: theme.borderPrimary,
borderRadius: 8,
}}
>
<FlashList
className="w-full"
style={{ borderRadius: 8 }}
ref={listRef}
keyExtractor={keyExtractor}
data={data}
initialScrollIndex={initialScrollIndex}
onEndReachedThreshold={0.5}
onEndReached={onEndReached}
onStartReachedThreshold={0.5}
onStartReached={onStartReached}
renderItem={({ item }) => (
<Pressable onPress={() => onSelect(item)}>
{renderItem(item, theme)}
</Pressable>
)}
/>
</Animated.View>
</Pressable>
</Modal>
);
};

View File

@@ -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<DropdownPosition>({
top: 0,
left: 0,
width: 0,
});
const ref = useRef<View>(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 };
};

View File

@@ -85,4 +85,15 @@ export const ChatService = {
return ApiClient.get<ChatMessage[]>(url); return ApiClient.get<ChatMessage[]>(url);
}, },
updateProposalEvent: async (
messageId: string,
proposalId: string,
event: CreateEventDTO,
): Promise<ChatMessage> => {
return ApiClient.put<ChatMessage>(`/chat/messages/${messageId}/proposal`, {
proposalId,
event,
});
},
}; };

View File

@@ -94,10 +94,6 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
type: "string", type: "string",
description: "Optional event description", description: "Optional event description",
}, },
isRecurring: {
type: "boolean",
description: "Whether this is a recurring event",
},
recurrenceRule: { recurrenceRule: {
type: "string", type: "string",
description: "RRULE format string for recurring events", description: "RRULE format string for recurring events",
@@ -133,10 +129,6 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
type: "string", type: "string",
description: "New description (optional). NEVER put RRULE here!", description: "New description (optional). NEVER put RRULE here!",
}, },
isRecurring: {
type: "boolean",
description: "Whether this is a recurring event (optional)",
},
recurrenceRule: { recurrenceRule: {
type: "string", type: "string",
description: description:

View File

@@ -57,7 +57,6 @@ export function executeToolCall(
startTime: new Date(args.startTime as string), startTime: new Date(args.startTime as string),
endTime: new Date(args.endTime as string), endTime: new Date(args.endTime as string),
description: args.description as string | undefined, description: args.description as string | undefined,
isRecurring: args.isRecurring as boolean | undefined,
recurrenceRule: args.recurrenceRule as string | undefined, recurrenceRule: args.recurrenceRule as string | undefined,
}; };
const dateStr = formatDate(event.startTime); const dateStr = formatDate(event.startTime);
@@ -88,8 +87,6 @@ export function executeToolCall(
updates.startTime = new Date(args.startTime as string); updates.startTime = new Date(args.startTime as string);
if (args.endTime) updates.endTime = new Date(args.endTime as string); if (args.endTime) updates.endTime = new Date(args.endTime as string);
if (args.description) updates.description = args.description; if (args.description) updates.description = args.description;
if (args.isRecurring !== undefined)
updates.isRecurring = args.isRecurring;
if (args.recurrenceRule) updates.recurrenceRule = args.recurrenceRule; if (args.recurrenceRule) updates.recurrenceRule = args.recurrenceRule;
// Build event object for display (merge existing with updates) // Build event object for display (merge existing with updates)
@@ -99,8 +96,6 @@ export function executeToolCall(
endTime: (updates.endTime as Date) || existingEvent.endTime, endTime: (updates.endTime as Date) || existingEvent.endTime,
description: description:
(updates.description as string) || existingEvent.description, (updates.description as string) || existingEvent.description,
isRecurring:
(updates.isRecurring as boolean) ?? existingEvent.isRecurring,
recurrenceRule: recurrenceRule:
(updates.recurrenceRule as string) || existingEvent.recurrenceRule, (updates.recurrenceRule as string) || existingEvent.recurrenceRule,
exceptionDates: existingEvent.exceptionDates, exceptionDates: existingEvent.exceptionDates,
@@ -131,7 +126,7 @@ export function executeToolCall(
// Build descriptive content based on delete mode // Build descriptive content based on delete mode
let modeDescription = ""; let modeDescription = "";
if (existingEvent.isRecurring) { if (existingEvent.recurrenceRule) {
switch (deleteMode) { switch (deleteMode) {
case "single": case "single":
modeDescription = " (nur dieses Vorkommen)"; modeDescription = " (nur dieses Vorkommen)";
@@ -155,12 +150,11 @@ export function executeToolCall(
startTime: existingEvent.startTime, startTime: existingEvent.startTime,
endTime: existingEvent.endTime, endTime: existingEvent.endTime,
description: existingEvent.description, description: existingEvent.description,
isRecurring: existingEvent.isRecurring,
recurrenceRule: existingEvent.recurrenceRule, recurrenceRule: existingEvent.recurrenceRule,
exceptionDates: existingEvent.exceptionDates, exceptionDates: existingEvent.exceptionDates,
}, },
deleteMode: existingEvent.isRecurring ? deleteMode : undefined, deleteMode: existingEvent.recurrenceRule ? deleteMode : undefined,
occurrenceDate: existingEvent.isRecurring occurrenceDate: existingEvent.recurrenceRule
? occurrenceDate ? occurrenceDate
: undefined, : undefined,
}, },

View File

@@ -150,4 +150,33 @@ export class ChatController {
} }
} }
} }
async updateProposalEvent(
req: AuthenticatedRequest,
res: Response,
): Promise<void> {
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" });
}
}
} }

View File

@@ -2,6 +2,7 @@ import {
ChatMessage, ChatMessage,
Conversation, Conversation,
CreateMessageDTO, CreateMessageDTO,
CreateEventDTO,
GetMessagesOptions, GetMessagesOptions,
UpdateMessageDTO, UpdateMessageDTO,
} from "@calchat/shared"; } from "@calchat/shared";
@@ -82,4 +83,17 @@ export class MongoChatRepository implements ChatRepository {
); );
return doc ? (doc.toJSON() as unknown as ChatMessage) : null; return doc ? (doc.toJSON() as unknown as ChatMessage) : null;
} }
async updateProposalEvent(
messageId: string,
proposalId: string,
event: CreateEventDTO,
): Promise<ChatMessage | null> {
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;
}
} }

View File

@@ -23,7 +23,6 @@ const EventSchema = new Schema<CreateEventDTO>(
startTime: { type: Date, required: true }, startTime: { type: Date, required: true },
endTime: { type: Date, required: true }, endTime: { type: Date, required: true },
note: { type: String }, note: { type: String },
isRecurring: { type: Boolean },
recurrenceRule: { type: String }, recurrenceRule: { type: String },
exceptionDates: { type: [String] }, exceptionDates: { type: [String] },
}, },
@@ -37,7 +36,6 @@ const UpdatesSchema = new Schema<UpdateEventDTO>(
startTime: { type: Date }, startTime: { type: Date },
endTime: { type: Date }, endTime: { type: Date },
note: { type: String }, note: { type: String },
isRecurring: { type: Boolean },
recurrenceRule: { type: String }, recurrenceRule: { type: String },
}, },
{ _id: false }, { _id: false },

View File

@@ -2,16 +2,22 @@ import mongoose, { Schema, Document, Model } from "mongoose";
import { CalendarEvent } from "@calchat/shared"; import { CalendarEvent } from "@calchat/shared";
import { IdVirtual } from "./types"; import { IdVirtual } from "./types";
export interface EventDocument extends Omit<CalendarEvent, "id">, Document { interface EventVirtuals extends IdVirtual {
isRecurring: boolean;
}
export interface EventDocument
extends Omit<CalendarEvent, "id" | "isRecurring">,
Document {
toJSON(): CalendarEvent; toJSON(): CalendarEvent;
} }
const EventSchema = new Schema< const EventSchema = new Schema<
EventDocument, EventDocument,
Model<EventDocument, {}, {}, IdVirtual>, Model<EventDocument, {}, {}, EventVirtuals>,
{}, {},
{}, {},
IdVirtual EventVirtuals
>( >(
{ {
userId: { userId: {
@@ -39,10 +45,6 @@ const EventSchema = new Schema<
note: { note: {
type: String, type: String,
}, },
isRecurring: {
type: Boolean,
default: false,
},
recurrenceRule: { recurrenceRule: {
type: String, type: String,
}, },
@@ -59,6 +61,11 @@ const EventSchema = new Schema<
return this._id.toString(); return this._id.toString();
}, },
}, },
isRecurring: {
get() {
return !!this.recurrenceRule;
},
},
}, },
toJSON: { toJSON: {
virtuals: true, virtuals: true,

View File

@@ -19,6 +19,9 @@ export function createChatRoutes(chatController: ChatController): Router {
router.get("/conversations/:id", (req, res) => router.get("/conversations/:id", (req, res) =>
chatController.getConversation(req, res), chatController.getConversation(req, res),
); );
router.put("/messages/:messageId/proposal", (req, res) =>
chatController.updateProposalEvent(req, res),
);
return router; return router;
} }

View File

@@ -41,7 +41,6 @@ const staticResponses: TestResponse[] = [
startTime: getDay("Wednesday", 1, 18, 0), startTime: getDay("Wednesday", 1, 18, 0),
endTime: getDay("Wednesday", 1, 19, 30), endTime: getDay("Wednesday", 1, 19, 30),
description: "Wöchentliches Training", description: "Wöchentliches Training",
isRecurring: true,
recurrenceRule: "FREQ=WEEKLY;BYDAY=WE", recurrenceRule: "FREQ=WEEKLY;BYDAY=WE",
}, },
}, },
@@ -158,7 +157,6 @@ const staticResponses: TestResponse[] = [
startTime: getDay("Monday", 1, 7, 0), startTime: getDay("Monday", 1, 7, 0),
endTime: getDay("Monday", 1, 8, 0), endTime: getDay("Monday", 1, 8, 0),
description: "Morgen-Yoga", description: "Morgen-Yoga",
isRecurring: true,
recurrenceRule: "FREQ=WEEKLY;BYDAY=MO,WE,FR", recurrenceRule: "FREQ=WEEKLY;BYDAY=MO,WE,FR",
}, },
}, },
@@ -170,7 +168,6 @@ const staticResponses: TestResponse[] = [
startTime: getDay("Tuesday", 1, 18, 0), startTime: getDay("Tuesday", 1, 18, 0),
endTime: getDay("Tuesday", 1, 19, 0), endTime: getDay("Tuesday", 1, 19, 0),
description: "Abendlauf im Park", description: "Abendlauf im Park",
isRecurring: true,
recurrenceRule: "FREQ=WEEKLY;BYDAY=TU,TH", recurrenceRule: "FREQ=WEEKLY;BYDAY=TU,TH",
}, },
}, },
@@ -215,7 +212,6 @@ const staticResponses: TestResponse[] = [
title: "Badezimmer putzen", title: "Badezimmer putzen",
startTime: getDay("Saturday", 1, 10, 0), startTime: getDay("Saturday", 1, 10, 0),
endTime: getDay("Saturday", 1, 11, 0), endTime: getDay("Saturday", 1, 11, 0),
isRecurring: true,
recurrenceRule: "FREQ=WEEKLY;BYDAY=SA", recurrenceRule: "FREQ=WEEKLY;BYDAY=SA",
}, },
}, },
@@ -255,7 +251,6 @@ const staticResponses: TestResponse[] = [
title: "Mamas Geburtstag", title: "Mamas Geburtstag",
startTime: getDay("Thursday", 2, 0, 0), startTime: getDay("Thursday", 2, 0, 0),
endTime: getDay("Thursday", 2, 23, 59), endTime: getDay("Thursday", 2, 23, 59),
isRecurring: true,
recurrenceRule: "FREQ=YEARLY", recurrenceRule: "FREQ=YEARLY",
}, },
}, },
@@ -273,7 +268,6 @@ const staticResponses: TestResponse[] = [
title: "Fitnessstudio Probetraining", title: "Fitnessstudio Probetraining",
startTime: getDay("Tuesday", 1, 18, 0), startTime: getDay("Tuesday", 1, 18, 0),
endTime: getDay("Tuesday", 1, 19, 30), endTime: getDay("Tuesday", 1, 19, 30),
isRecurring: true,
recurrenceRule: "FREQ=WEEKLY;BYDAY=TU;COUNT=8", recurrenceRule: "FREQ=WEEKLY;BYDAY=TU;COUNT=8",
}, },
}, },
@@ -327,7 +321,6 @@ const staticResponses: TestResponse[] = [
title: "Spanischkurs VHS", title: "Spanischkurs VHS",
startTime: getDay("Thursday", 1, 19, 0), startTime: getDay("Thursday", 1, 19, 0),
endTime: getDay("Thursday", 1, 20, 30), endTime: getDay("Thursday", 1, 20, 30),
isRecurring: true,
recurrenceRule: "FREQ=WEEKLY;BYDAY=TH,SA;COUNT=8", recurrenceRule: "FREQ=WEEKLY;BYDAY=TH,SA;COUNT=8",
}, },
}, },
@@ -373,7 +366,6 @@ async function getTestResponse(
exceptionDate.getTime() + 90 * 60 * 1000, exceptionDate.getTime() + 90 * 60 * 1000,
), // +90 min ), // +90 min
description: sportEvent.description, description: sportEvent.description,
isRecurring: sportEvent.isRecurring,
recurrenceRule: sportEvent.recurrenceRule, recurrenceRule: sportEvent.recurrenceRule,
exceptionDates: sportEvent.exceptionDates, exceptionDates: sportEvent.exceptionDates,
}, },
@@ -412,7 +404,6 @@ async function getTestResponse(
startTime: sportEvent.startTime, startTime: sportEvent.startTime,
endTime: sportEvent.endTime, endTime: sportEvent.endTime,
description: sportEvent.description, description: sportEvent.description,
isRecurring: sportEvent.isRecurring,
recurrenceRule: newRule, recurrenceRule: newRule,
exceptionDates: sportEvent.exceptionDates, exceptionDates: sportEvent.exceptionDates,
}, },
@@ -452,7 +443,6 @@ async function getTestResponse(
exceptionDate.getTime() + 90 * 60 * 1000, exceptionDate.getTime() + 90 * 60 * 1000,
), // +90 min ), // +90 min
description: sportEvent.description, description: sportEvent.description,
isRecurring: sportEvent.isRecurring,
recurrenceRule: sportEvent.recurrenceRule, recurrenceRule: sportEvent.recurrenceRule,
exceptionDates: sportEvent.exceptionDates, exceptionDates: sportEvent.exceptionDates,
}, },
@@ -488,7 +478,6 @@ async function getTestResponse(
startTime: jensEvent.startTime, startTime: jensEvent.startTime,
endTime: jensEvent.endTime, endTime: jensEvent.endTime,
description: jensEvent.description, description: jensEvent.description,
isRecurring: jensEvent.isRecurring,
}, },
}, },
], ],
@@ -718,4 +707,12 @@ export class ChatService {
return this.chatRepo.getMessages(conversationId, options); return this.chatRepo.getMessages(conversationId, options);
} }
async updateProposalEvent(
messageId: string,
proposalId: string,
event: CreateEventDTO,
): Promise<ChatMessage | null> {
return this.chatRepo.updateProposalEvent(messageId, proposalId, event);
}
} }

View File

@@ -2,6 +2,7 @@ import {
ChatMessage, ChatMessage,
Conversation, Conversation,
CreateMessageDTO, CreateMessageDTO,
CreateEventDTO,
GetMessagesOptions, GetMessagesOptions,
UpdateMessageDTO, UpdateMessageDTO,
} from "@calchat/shared"; } from "@calchat/shared";
@@ -32,4 +33,10 @@ export interface ChatRepository {
proposalId: string, proposalId: string,
respondedAction: "confirm" | "reject", respondedAction: "confirm" | "reject",
): Promise<ChatMessage | null>; ): Promise<ChatMessage | null>;
updateProposalEvent(
messageId: string,
proposalId: string,
event: CreateEventDTO,
): Promise<ChatMessage | null>;
} }

View File

@@ -44,9 +44,13 @@ export function expandRecurringEvents(
const endTime = new Date(event.endTime); const endTime = new Date(event.endTime);
const duration = endTime.getTime() - startTime.getTime(); 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) { if (!event.isRecurring || !event.recurrenceRule) {
// Non-recurring event: add as-is if within range // Non-recurring event: add if it overlaps with the range
if (startTime >= rangeStart && startTime <= rangeEnd) { if (endTime >= rangeStart && startTime <= rangeEnd) {
expanded.push({ expanded.push({
...event, ...event,
occurrenceStart: startTime, occurrenceStart: startTime,
@@ -64,9 +68,11 @@ export function expandRecurringEvents(
`DTSTART:${formatRRuleDateString(startTime)}\nRRULE:${ruleString}`, `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( const occurrences = rule.between(
toRRuleDate(rangeStart), toRRuleDate(adjustedRangeStart),
toRRuleDate(rangeEnd), toRRuleDate(rangeEnd),
true, // inclusive true, // inclusive
); );
@@ -78,6 +84,11 @@ export function expandRecurringEvents(
const occurrenceStart = fromRRuleDate(occurrence); const occurrenceStart = fromRRuleDate(occurrence);
const occurrenceEnd = new Date(occurrenceStart.getTime() + duration); 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 // Skip if this occurrence is in the exception dates
const dateKey = formatDateKey(occurrenceStart); const dateKey = formatDateKey(occurrenceStart);
if (exceptionSet.has(dateKey)) { if (exceptionSet.has(dateKey)) {

27
package-lock.json generated
View File

@@ -23,6 +23,7 @@
"dependencies": { "dependencies": {
"@calchat/shared": "*", "@calchat/shared": "*",
"@expo/vector-icons": "^15.0.3", "@expo/vector-icons": "^15.0.3",
"@react-native-community/datetimepicker": "8.4.4",
"@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3", "@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8", "@react-navigation/native": "^7.1.8",
@@ -52,6 +53,7 @@
"react-native-screens": "~4.16.0", "react-native-screens": "~4.16.0",
"react-native-web": "~0.21.0", "react-native-web": "~0.21.0",
"react-native-worklets": "0.5.1", "react-native-worklets": "0.5.1",
"rrule": "^2.8.1",
"zustand": "^5.0.9" "zustand": "^5.0.9"
}, },
"devDependencies": { "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": { "node_modules/@react-native/assets-registry": {
"version": "0.81.5", "version": "0.81.5",
"license": "MIT", "license": "MIT",
@@ -10537,6 +10562,8 @@
}, },
"node_modules/rrule": { "node_modules/rrule": {
"version": "2.8.1", "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", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"tslib": "^2.4.0" "tslib": "^2.4.0"

View File

@@ -26,7 +26,6 @@ export interface CreateEventDTO {
startTime: Date; startTime: Date;
endTime: Date; endTime: Date;
note?: string; note?: string;
isRecurring?: boolean;
recurrenceRule?: string; recurrenceRule?: string;
exceptionDates?: string[]; // For display in proposals exceptionDates?: string[]; // For display in proposals
} }
@@ -37,7 +36,6 @@ export interface UpdateEventDTO {
startTime?: Date; startTime?: Date;
endTime?: Date; endTime?: Date;
note?: string; note?: string;
isRecurring?: boolean;
recurrenceRule?: string; recurrenceRule?: string;
exceptionDates?: string[]; exceptionDates?: string[];
} }

View File

@@ -34,3 +34,15 @@ export function getDay(
result.setHours(hour, minute, 0, 0); result.setHours(hour, minute, 0, 0);
return result; 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();
}

View File

@@ -46,3 +46,26 @@ export function formatDateWithWeekday(date: Date): string {
year: "numeric", 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",
});
}

View File

@@ -1,3 +1,3 @@
export * from "./dateHelpers"; export * from "./dateHelpers";
export * from "./rruleHelpers";
export * from "./formatters"; export * from "./formatters";
export * from "./rruleHelpers";

View File

@@ -1,49 +1,29 @@
import { rrulestr, Frequency } from "rrule"; /**
* RRULE building and parsing helpers.
*/
export interface ParsedRRule { export type RepeatType = "Tag" | "Woche" | "Monat" | "Jahr";
freq: string; // "YEARLY", "MONTHLY", "WEEKLY", "DAILY", etc.
until?: Date;
count?: number;
interval?: number;
byDay?: string[]; // ["MO", "WE", "FR"]
}
const FREQ_NAMES: Record<Frequency, string> = { const REPEAT_TYPE_TO_FREQ: Record<RepeatType, string> = {
[Frequency.YEARLY]: "YEARLY", Tag: "DAILY",
[Frequency.MONTHLY]: "MONTHLY", Woche: "WEEKLY",
[Frequency.WEEKLY]: "WEEKLY", Monat: "MONTHLY",
[Frequency.DAILY]: "DAILY", Jahr: "YEARLY",
[Frequency.HOURLY]: "HOURLY",
[Frequency.MINUTELY]: "MINUTELY",
[Frequency.SECONDLY]: "SECONDLY",
}; };
/** /**
* Parses an RRULE string and extracts the relevant fields. * Build an RRULE string from repeat count and type.
* Handles both with and without "RRULE:" prefix. *
* @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 { export function buildRRule(repeatType: RepeatType, interval: number = 1): string {
if (!ruleString) { const freq = REPEAT_TYPE_TO_FREQ[repeatType];
return null;
if (interval <= 1) {
return `FREQ=${freq}`;
} }
try { return `FREQ=${freq};INTERVAL=${interval}`;
// 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;
}
} }