Compare commits
3 Commits
4575483940
...
387bb2d1ee
| Author | SHA1 | Date | |
|---|---|---|---|
| 387bb2d1ee | |||
| 6f0d172bf2 | |||
| 617543a603 |
56
CLAUDE.md
56
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)
|
||||
|
||||
@@ -309,7 +315,9 @@ src/
|
||||
│ # DAY_TO_GERMAN, DAY_TO_GERMAN_SHORT, MONTH_TO_GERMAN
|
||||
└── utils/
|
||||
├── index.ts
|
||||
└── dateHelpers.ts # getDay() - get date for specific weekday relative to today
|
||||
├── dateHelpers.ts # getDay() - get date for specific weekday relative to today
|
||||
├── formatters.ts # formatDate(), formatTime(), formatDateTime(), formatDateWithWeekday() - German locale
|
||||
└── rruleHelpers.ts # parseRRule() - parse RRULE strings to extract freq, until, count, interval, byDay
|
||||
```
|
||||
|
||||
**Key Types:**
|
||||
@@ -326,7 +334,7 @@ src/
|
||||
- `Conversation`: id, userId, createdAt?, updatedAt? (messages loaded separately via lazy loading)
|
||||
- `CreateUserDTO`: email, userName, password (for registration)
|
||||
- `LoginDTO`: identifier (email OR userName), password
|
||||
- `CreateEventDTO`: Used for creating events AND for AI-proposed events
|
||||
- `CreateEventDTO`: Used for creating events AND for AI-proposed events, includes optional `exceptionDates` for proposals
|
||||
- `GetMessagesOptions`: Cursor-based pagination with `before?: string` and `limit?: number`
|
||||
- `ConversationSummary`: id, lastMessage?, createdAt? (for conversation list)
|
||||
- `UpdateMessageDTO`: proposalId?, respondedAction? (for marking individual proposals as confirmed/rejected)
|
||||
@@ -463,11 +471,12 @@ 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
|
||||
- `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/toolDefinitions`: proposeUpdateEvent supports `isRecurring` and `recurrenceRule` parameters for adding UNTIL or modifying recurrence
|
||||
- `utils/recurrenceExpander`: Handles RRULE parsing, strips `RRULE:` prefix if present (AI may include it), filters out exceptionDates
|
||||
- `logging/`: Structured logging with pino, pino-http middleware, @Logged decorator
|
||||
- All repositories and GPTAdapter decorated with @Logged for automatic method logging
|
||||
@@ -477,7 +486,11 @@ NODE_ENV=development # development = pretty logs, production = JSON
|
||||
- `AuthService`: refreshToken()
|
||||
- JWT authentication (currently using simple X-User-Id header)
|
||||
|
||||
**Shared:** Types, DTOs, constants (Day, Month with German translations), ExpandedEvent type, and date utilities defined and exported.
|
||||
**Shared:**
|
||||
- Types, DTOs, constants (Day, Month with German translations), ExpandedEvent type defined and exported
|
||||
- `rruleHelpers.ts`: `parseRRule()` parses RRULE strings using rrule library, returns `ParsedRRule` with freq, until, count, interval, byDay
|
||||
- `formatters.ts`: German date/time formatters (`formatDate`, `formatTime`, `formatDateTime`, `formatDateWithWeekday`) used by both client and server
|
||||
- rrule library added as dependency for RRULE parsing
|
||||
|
||||
**Frontend:**
|
||||
- **Authentication fully implemented:**
|
||||
@@ -520,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 (supports create/update/delete actions with deleteMode display)
|
||||
- `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.)
|
||||
@@ -535,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
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<Date | null>(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<string, ExpandedEvent[]>();
|
||||
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}
|
||||
/>
|
||||
<EventOverlay
|
||||
visible={selectedDate !== null && !deleteModalVisible}
|
||||
visible={overlayVisible && !deleteModalVisible}
|
||||
date={selectedDate}
|
||||
events={selectedDateEvents}
|
||||
onClose={handleCloseOverlay}
|
||||
onEditEvent={handleEditEvent}
|
||||
onDeleteEvent={handleDeleteEvent}
|
||||
onCreateEvent={handleCreateEvent}
|
||||
/>
|
||||
<DeleteEventModal
|
||||
visible={deleteModalVisible}
|
||||
@@ -245,8 +273,9 @@ type EventOverlayProps = {
|
||||
date: Date | null;
|
||||
events: ExpandedEvent[];
|
||||
onClose: () => 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 = (
|
||||
<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 (
|
||||
<ModalBase
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
title={dateString}
|
||||
subtitle={subtitle}
|
||||
attachment={addEventAttachment}
|
||||
footer={{ label: "Schliessen", onPress: onClose }}
|
||||
scrollable={true}
|
||||
maxContentHeight={400}
|
||||
@@ -299,6 +345,8 @@ type MonthSelectorProps = {
|
||||
onSelectMonth: (year: number, monthIndex: number) => 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<React.ComponentRef<typeof FlashList<MonthItem>>>(null);
|
||||
const INITIAL_RANGE = 12; // 12 months before and after current
|
||||
|
||||
const [monthSelectorData, setMonthSelectorData] = useState<MonthItem[]>([]);
|
||||
|
||||
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 }) => (
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
onSelectMonth(item.year, item.monthIndex);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<View
|
||||
className="w-full flex justify-center items-center py-2"
|
||||
style={{
|
||||
backgroundColor:
|
||||
item.monthIndex % 2 === 0 ? theme.primeBg : theme.secondaryBg,
|
||||
}}
|
||||
>
|
||||
<Text className="text-xl" style={{ color: theme.primeFg }}>
|
||||
{item.label}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
const handleSelect = useCallback(
|
||||
(item: MonthItem) => {
|
||||
onSelectMonth(item.year, item.monthIndex);
|
||||
onClose();
|
||||
},
|
||||
[onSelectMonth, onClose],
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
<ScrollableDropdown
|
||||
visible={modalVisible}
|
||||
transparent={true}
|
||||
animationType="none"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<Pressable className="flex-1 rounded-lg" onPress={onClose}>
|
||||
<Animated.View
|
||||
className="absolute overflow-hidden"
|
||||
onClose={onClose}
|
||||
position={position}
|
||||
data={monthSelectorData}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={(item, theme) => (
|
||||
<View
|
||||
className="w-full flex justify-center items-center py-2"
|
||||
style={{
|
||||
top: position.top,
|
||||
left: position.left,
|
||||
width: position.width,
|
||||
height: heightAnim,
|
||||
backgroundColor: theme.primeBg,
|
||||
borderWidth: 2,
|
||||
borderColor: theme.borderPrimary,
|
||||
borderRadius: 8,
|
||||
backgroundColor:
|
||||
item.monthIndex % 2 === 0 ? theme.primeBg : theme.secondaryBg,
|
||||
}}
|
||||
>
|
||||
<FlashList
|
||||
className="w-full"
|
||||
style={{ borderRadius: 8 }}
|
||||
ref={listRef}
|
||||
keyExtractor={(item) => item.id}
|
||||
data={monthSelectorData}
|
||||
initialScrollIndex={INITIAL_RANGE}
|
||||
onEndReachedThreshold={0.5}
|
||||
onEndReached={() => appendMonths("end", 12)}
|
||||
onStartReachedThreshold={0.5}
|
||||
onStartReached={() => appendMonths("start", 12)}
|
||||
renderItem={renderItem}
|
||||
/>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
<Text className="text-xl" style={{ color: theme.primeFg }}>
|
||||
{item.label}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
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<View>(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 (
|
||||
<Header className="flex flex-row items-center justify-between">
|
||||
<ChangeMonthButton onPress={prevMonth} icon="chevron-back" />
|
||||
<View
|
||||
ref={containerRef}
|
||||
ref={dropdown.ref}
|
||||
className="relative flex flex-row items-center justify-around"
|
||||
>
|
||||
<Text className="text-4xl px-1" style={{ color: theme.textPrimary }}>
|
||||
@@ -486,15 +488,15 @@ const CalendarHeader = (props: CalendarHeaderProps) => {
|
||||
// Android shadow
|
||||
elevation: 6,
|
||||
}}
|
||||
onPress={measureAndOpen}
|
||||
onPress={dropdown.open}
|
||||
>
|
||||
<Ionicons name="chevron-down" size={28} color={theme.primeFg} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<MonthSelector
|
||||
modalVisible={modalVisible}
|
||||
onClose={() => 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<string, ExpandedEvent[]>;
|
||||
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)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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 = {
|
||||
@@ -173,6 +194,7 @@ const Chat = () => {
|
||||
// Show typing indicator after delay
|
||||
typingTimeoutRef.current = setTimeout(() => {
|
||||
setWaitingForResponse(true);
|
||||
scrollToEnd();
|
||||
}, TYPING_INDICATOR_DELAY_MS);
|
||||
|
||||
try {
|
||||
@@ -238,6 +260,14 @@ const Chat = () => {
|
||||
proposalId,
|
||||
)
|
||||
}
|
||||
onEdit={(proposalId, proposal) =>
|
||||
handleEditProposal(
|
||||
item.id,
|
||||
item.conversationId!,
|
||||
proposalId,
|
||||
proposal,
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
keyExtractor={(item) => item.id}
|
||||
@@ -334,6 +364,7 @@ const ChatMessage = ({
|
||||
proposedChanges,
|
||||
onConfirm,
|
||||
onReject,
|
||||
onEdit,
|
||||
}: ChatMessageProps) => {
|
||||
const { theme } = useThemeStore();
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
@@ -361,7 +392,7 @@ const ChatMessage = ({
|
||||
{content}
|
||||
</Text>
|
||||
|
||||
{hasProposals && currentProposal && onConfirm && onReject && (
|
||||
{hasProposals && currentProposal && onConfirm && onReject && onEdit && (
|
||||
<View>
|
||||
{/* Event card with optional navigation arrows */}
|
||||
<View className="flex-row items-center">
|
||||
@@ -381,8 +412,9 @@ const ChatMessage = ({
|
||||
<View className="flex-1">
|
||||
<ProposedEventCard
|
||||
proposedChange={currentProposal}
|
||||
onConfirm={() => onConfirm(currentProposal.id, currentProposal)}
|
||||
onConfirm={(proposal) => onConfirm(proposal.id, proposal)}
|
||||
onReject={() => onReject(currentProposal.id)}
|
||||
onEdit={(proposal) => onEdit(proposal.id, proposal)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<BaseBackground>
|
||||
<Header>
|
||||
<View className="h-full flex justify-center">
|
||||
<Text className="text-center text-3xl font-bold">Settings</Text>
|
||||
</View>
|
||||
</Header>
|
||||
<SimpleHeader text="Settings" />
|
||||
<View className="flex items-center mt-4">
|
||||
<BaseButton onPress={handleLogout} solid={true}>
|
||||
<Ionicons name="log-out-outline" size={24} color={theme.primeFg} />{" "}
|
||||
|
||||
@@ -7,8 +7,9 @@ export default function RootLayout() {
|
||||
<Stack.Screen name="(tabs)" />
|
||||
<Stack.Screen name="login" />
|
||||
<Stack.Screen name="register" />
|
||||
<Stack.Screen name="event/[id]" />
|
||||
<Stack.Screen name="note/[id]" />
|
||||
<Stack.Screen name="editEvent" />
|
||||
{/* <Stack.Screen name="event/[id]" /> */}
|
||||
{/* <Stack.Screen name="note/[id]" /> */}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
597
apps/client/src/app/editEvent.tsx
Normal file
597
apps/client/src/app/editEvent.tsx
Normal 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;
|
||||
@@ -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 && (
|
||||
<Pressable
|
||||
|
||||
136
apps/client/src/components/DateTimePicker.tsx
Normal file
136
apps/client/src/components/DateTimePicker.tsx
Normal 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;
|
||||
@@ -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 (
|
||||
<CardBase title={title} className={className} borderWidth={2}>
|
||||
@@ -73,9 +62,16 @@ export const EventCardBase = ({
|
||||
color={theme.textPrimary}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<Text style={{ color: theme.textPrimary }}>
|
||||
{formatDate(startTime)}
|
||||
</Text>
|
||||
{multiDay ? (
|
||||
<Text style={{ color: theme.textPrimary }}>
|
||||
{formatDateWithWeekdayShort(startTime)} →{" "}
|
||||
{formatDateWithWeekday(endTime)}
|
||||
</Text>
|
||||
) : (
|
||||
<Text style={{ color: theme.textPrimary }}>
|
||||
{formatDateWithWeekday(startTime)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Time with duration */}
|
||||
@@ -86,10 +82,16 @@ export const EventCardBase = ({
|
||||
color={theme.textPrimary}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<Text style={{ color: theme.textPrimary }}>
|
||||
{formatTime(startTime)} - {formatTime(endTime)} (
|
||||
{formatDuration(startTime, endTime)})
|
||||
</Text>
|
||||
{multiDay ? (
|
||||
<Text style={{ color: theme.textPrimary }}>
|
||||
{formatTime(startTime)} → {formatTime(endTime)}
|
||||
</Text>
|
||||
) : (
|
||||
<Text style={{ color: theme.textPrimary }}>
|
||||
{formatTime(startTime)} - {formatTime(endTime)} (
|
||||
{formatDuration(startTime, endTime)})
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Recurring indicator */}
|
||||
|
||||
@@ -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<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;
|
||||
|
||||
@@ -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 = ({
|
||||
<CardBase
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
attachment={attachment}
|
||||
footer={footer}
|
||||
scrollable={scrollable}
|
||||
maxContentHeight={maxContentHeight}
|
||||
|
||||
@@ -1,44 +1,31 @@
|
||||
import { View, Text, Pressable } from "react-native";
|
||||
import { ProposedEventChange, RecurringDeleteMode } from "@calchat/shared";
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { ProposedEventChange, formatDate } from "@calchat/shared";
|
||||
import { rrulestr } from "rrule";
|
||||
import { useThemeStore } from "../stores/ThemeStore";
|
||||
import { EventCardBase } from "./EventCardBase";
|
||||
|
||||
const DELETE_MODE_LABELS: Record<RecurringDeleteMode, string> = {
|
||||
single: "Nur dieses Vorkommen",
|
||||
future: "Dieses & zukuenftige",
|
||||
all: "Alle Vorkommen",
|
||||
};
|
||||
|
||||
type ProposedEventCardProps = {
|
||||
proposedChange: ProposedEventChange;
|
||||
onConfirm: () => void;
|
||||
onConfirm: (proposal: ProposedEventChange) => void;
|
||||
onReject: () => void;
|
||||
onEdit?: (proposal: ProposedEventChange) => void;
|
||||
};
|
||||
|
||||
const DeleteModeBadge = ({ mode }: { mode: RecurringDeleteMode }) => {
|
||||
const { theme } = useThemeStore();
|
||||
return (
|
||||
<View
|
||||
className="self-start px-2 py-1 rounded-md mb-2"
|
||||
style={{ backgroundColor: theme.rejectButton }}
|
||||
>
|
||||
<Text style={{ color: theme.buttonText }} className="text-xs font-medium">
|
||||
{DELETE_MODE_LABELS[mode]}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const ConfirmRejectButtons = ({
|
||||
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 (
|
||||
@@ -75,6 +62,19 @@ const ConfirmRejectButtons = ({
|
||||
Ablehnen
|
||||
</Text>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -83,16 +83,23 @@ export const ProposedEventCard = ({
|
||||
proposedChange,
|
||||
onConfirm,
|
||||
onReject,
|
||||
onEdit,
|
||||
}: ProposedEventCardProps) => {
|
||||
const { theme } = useThemeStore();
|
||||
const event = proposedChange.event;
|
||||
// respondedAction is now part of the proposedChange
|
||||
const isDisabled = !!proposedChange.respondedAction;
|
||||
|
||||
// Show delete mode badge for delete actions on recurring events
|
||||
const showDeleteModeBadge =
|
||||
// For delete/single action, the occurrenceDate becomes a new exception
|
||||
const newExceptionDate =
|
||||
proposedChange.action === "delete" &&
|
||||
event?.isRecurring &&
|
||||
proposedChange.deleteMode;
|
||||
proposedChange.deleteMode === "single" &&
|
||||
proposedChange.occurrenceDate;
|
||||
|
||||
// For update actions, check if a new UNTIL date is being set
|
||||
const newUntilDate =
|
||||
proposedChange.action === "update" &&
|
||||
event?.recurrenceRule &&
|
||||
rrulestr(event.recurrenceRule).options.until;
|
||||
|
||||
if (!event) {
|
||||
return null;
|
||||
@@ -106,16 +113,43 @@ export const ProposedEventCard = ({
|
||||
startTime={event.startTime}
|
||||
endTime={event.endTime}
|
||||
description={event.description}
|
||||
isRecurring={event.isRecurring}
|
||||
isRecurring={!!event.recurrenceRule}
|
||||
>
|
||||
{showDeleteModeBadge && (
|
||||
<DeleteModeBadge mode={proposedChange.deleteMode!} />
|
||||
{/* Show new exception date for delete/single actions */}
|
||||
{newExceptionDate && (
|
||||
<View className="flex-row items-center mb-2">
|
||||
<Feather
|
||||
name="plus-circle"
|
||||
size={16}
|
||||
color={theme.confirmButton}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<Text style={{ color: theme.confirmButton }} className="font-medium">
|
||||
Neue Ausnahme: {formatDate(new Date(proposedChange.occurrenceDate!))}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<ConfirmRejectButtons
|
||||
{/* Show new UNTIL date for update actions */}
|
||||
{newUntilDate && (
|
||||
<View className="flex-row items-center mb-2">
|
||||
<Feather
|
||||
name="plus-circle"
|
||||
size={16}
|
||||
color={theme.confirmButton}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<Text style={{ color: theme.confirmButton }} className="font-medium">
|
||||
Neues Ende: {formatDate(newUntilDate)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<ActionButtons
|
||||
isDisabled={isDisabled}
|
||||
respondedAction={proposedChange.respondedAction}
|
||||
onConfirm={onConfirm}
|
||||
showEdit={proposedChange.action !== "delete" && !isDisabled}
|
||||
onConfirm={() => onConfirm(proposedChange)}
|
||||
onReject={onReject}
|
||||
onEdit={onEdit ? () => onEdit(proposedChange) : undefined}
|
||||
/>
|
||||
</EventCardBase>
|
||||
</View>
|
||||
|
||||
107
apps/client/src/components/ScrollableDropdown.tsx
Normal file
107
apps/client/src/components/ScrollableDropdown.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
37
apps/client/src/hooks/useDropdownPosition.ts
Normal file
37
apps/client/src/hooks/useDropdownPosition.ts
Normal 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 };
|
||||
};
|
||||
@@ -85,4 +85,15 @@ export const ChatService = {
|
||||
|
||||
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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { CalendarEvent } from "@calchat/shared";
|
||||
import {
|
||||
CalendarEvent,
|
||||
formatDate,
|
||||
formatTime,
|
||||
formatDateTime,
|
||||
} from "@calchat/shared";
|
||||
|
||||
// German date/time formatting helpers
|
||||
export const formatDate = (d: Date) => d.toLocaleDateString("de-DE");
|
||||
export const formatTime = (d: Date) =>
|
||||
d.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
|
||||
export const formatDateTime = (d: Date) =>
|
||||
`${formatDate(d)} ${d.toLocaleTimeString("de-DE")}`;
|
||||
// Re-export for backwards compatibility
|
||||
export { formatDate, formatTime, formatDateTime };
|
||||
|
||||
/**
|
||||
* Format a list of events for display in the system prompt.
|
||||
|
||||
@@ -46,13 +46,20 @@ WICHTIG - Wiederkehrende Termine (RRULE):
|
||||
2. "Arbeit" Fr 9:00-13:00 (RRULE mit BYDAY=FR)
|
||||
- Nutze NIEMALS BYHOUR/BYMINUTE in RRULE - diese überschreiben die Startzeit nicht wie erwartet!
|
||||
- Gültige RRULE-Optionen: FREQ (DAILY/WEEKLY/MONTHLY/YEARLY), BYDAY (MO,TU,WE,TH,FR,SA,SU), INTERVAL, COUNT, UNTIL
|
||||
- UNTIL Format: YYYYMMDDTHHMMSSZ (UTC) z.B. UNTIL=20260310T000000Z
|
||||
- WICHTIG: Schreibe die RRULE NIEMALS in das description-Feld! Nutze IMMER das recurrenceRule-Feld!
|
||||
|
||||
WICHTIG - Antwortformat:
|
||||
- Halte deine Textantworten SEHR KURZ (1-2 Sätze maximal)
|
||||
- Die Event-Details (Titel, Datum, Uhrzeit, Beschreibung) werden dem Benutzer automatisch in separaten Karten angezeigt
|
||||
- Wiederhole NIEMALS die Event-Details im Text! Der Benutzer sieht sie bereits in den Karten
|
||||
- Gute Beispiele: "Alles klar!" oder "Hier sind deine Termine:"
|
||||
- Schlechte Beispiele: Lange Listen mit allen Terminen und ihren Details im Text
|
||||
- Verwende kontextbezogene Antworten in der GEGENWARTSFORM je nach Aktion:
|
||||
- Bei Termin-Erstellung: "Ich schlage folgenden Termin vor:" oder "Neuer Termin:"
|
||||
- Bei Termin-Änderung: "Ich schlage folgende Änderung vor:" oder "Änderung:"
|
||||
- Bei Termin-Löschung: "Ich schlage vor, diesen Termin zu löschen:" oder "Löschung:"
|
||||
- Bei Übersichten: "Hier sind deine Termine:"
|
||||
- WICHTIG: Verwende NIEMALS Vergangenheitsform wie "Ich habe ... vorgeschlagen" - immer Gegenwartsform!
|
||||
- Schlechte Beispiele: "Alles klar!" (zu unspezifisch), lange Listen mit Termin-Details im Text
|
||||
- Bei Rückfragen oder wenn keine Termine erstellt werden, kannst du ausführlicher antworten
|
||||
|
||||
Existierende Termine des Benutzers:
|
||||
|
||||
@@ -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",
|
||||
@@ -131,7 +127,12 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
||||
},
|
||||
description: {
|
||||
type: "string",
|
||||
description: "New description (optional)",
|
||||
description: "New description (optional). NEVER put RRULE here!",
|
||||
},
|
||||
recurrenceRule: {
|
||||
type: "string",
|
||||
description:
|
||||
"RRULE format string (optional). Use to add UNTIL or modify recurrence. Format: FREQ=DAILY;UNTIL=20260310T000000Z",
|
||||
},
|
||||
},
|
||||
required: ["eventId"],
|
||||
|
||||
@@ -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,6 +87,7 @@ 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.recurrenceRule) updates.recurrenceRule = args.recurrenceRule;
|
||||
|
||||
// Build event object for display (merge existing with updates)
|
||||
const displayEvent = {
|
||||
@@ -96,7 +96,9 @@ export function executeToolCall(
|
||||
endTime: (updates.endTime as Date) || existingEvent.endTime,
|
||||
description:
|
||||
(updates.description as string) || existingEvent.description,
|
||||
isRecurring: existingEvent.isRecurring,
|
||||
recurrenceRule:
|
||||
(updates.recurrenceRule as string) || existingEvent.recurrenceRule,
|
||||
exceptionDates: existingEvent.exceptionDates,
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -124,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)";
|
||||
@@ -148,10 +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,
|
||||
},
|
||||
|
||||
@@ -35,6 +35,10 @@ export class ChatController {
|
||||
try {
|
||||
const userId = req.user!.userId;
|
||||
const { conversationId, messageId } = req.params;
|
||||
|
||||
// DEBUG: Log incoming request body to trace deleteMode issue
|
||||
log.debug({ body: req.body }, "confirmEvent request body");
|
||||
|
||||
const {
|
||||
proposalId,
|
||||
action,
|
||||
@@ -146,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" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,8 +23,8 @@ const EventSchema = new Schema<CreateEventDTO>(
|
||||
startTime: { type: Date, required: true },
|
||||
endTime: { type: Date, required: true },
|
||||
note: { type: String },
|
||||
isRecurring: { type: Boolean },
|
||||
recurrenceRule: { type: String },
|
||||
exceptionDates: { type: [String] },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
@@ -36,7 +36,6 @@ const UpdatesSchema = new Schema<UpdateEventDTO>(
|
||||
startTime: { type: Date },
|
||||
endTime: { type: Date },
|
||||
note: { type: String },
|
||||
isRecurring: { type: Boolean },
|
||||
recurrenceRule: { type: String },
|
||||
},
|
||||
{ _id: false },
|
||||
@@ -57,6 +56,11 @@ const ProposedChangeSchema = new Schema<ProposedEventChange>(
|
||||
type: String,
|
||||
enum: ["confirm", "reject"],
|
||||
},
|
||||
deleteMode: {
|
||||
type: String,
|
||||
enum: ["single", "future", "all"],
|
||||
},
|
||||
occurrenceDate: { type: String },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
@@ -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<CalendarEvent, "id">, Document {
|
||||
interface EventVirtuals extends IdVirtual {
|
||||
isRecurring: boolean;
|
||||
}
|
||||
|
||||
export interface EventDocument
|
||||
extends Omit<CalendarEvent, "id" | "isRecurring">,
|
||||
Document {
|
||||
toJSON(): CalendarEvent;
|
||||
}
|
||||
|
||||
const EventSchema = new Schema<
|
||||
EventDocument,
|
||||
Model<EventDocument, {}, {}, IdVirtual>,
|
||||
Model<EventDocument, {}, {}, EventVirtuals>,
|
||||
{},
|
||||
{},
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -27,8 +27,33 @@ let responseIndex = 0;
|
||||
// Static test responses (event proposals)
|
||||
const staticResponses: TestResponse[] = [
|
||||
// {{{
|
||||
// === SPORT TEST SCENARIO (3 steps) ===
|
||||
// Response 0: Wiederkehrendes Event - jeden Mittwoch Sport
|
||||
{
|
||||
content:
|
||||
"Super! Ich erstelle dir einen wiederkehrenden Termin für Sport jeden Mittwoch:",
|
||||
proposedChanges: [
|
||||
{
|
||||
id: "sport-create",
|
||||
action: "create",
|
||||
event: {
|
||||
title: "Sport",
|
||||
startTime: getDay("Wednesday", 1, 18, 0),
|
||||
endTime: getDay("Wednesday", 1, 19, 30),
|
||||
description: "Wöchentliches Training",
|
||||
recurrenceRule: "FREQ=WEEKLY;BYDAY=WE",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
// Response 1: Ausnahme hinzufügen (2 Wochen später) - DYNAMIC placeholder
|
||||
{ content: "" },
|
||||
// Response 2: UNTIL hinzufügen (nach 6 Wochen) - DYNAMIC placeholder
|
||||
{ content: "" },
|
||||
// Response 3: Weitere Ausnahme in 2 Wochen - DYNAMIC placeholder
|
||||
{ content: "" },
|
||||
// === MULTI-EVENT TEST RESPONSES ===
|
||||
// Response 0: 3 Meetings an verschiedenen Tagen
|
||||
// Response 3: 3 Meetings an verschiedenen Tagen
|
||||
{
|
||||
content: "Alles klar! Ich erstelle dir 3 Team-Meetings für diese Woche:",
|
||||
proposedChanges: [
|
||||
@@ -132,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",
|
||||
},
|
||||
},
|
||||
@@ -144,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",
|
||||
},
|
||||
},
|
||||
@@ -189,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",
|
||||
},
|
||||
},
|
||||
@@ -229,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",
|
||||
},
|
||||
},
|
||||
@@ -247,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",
|
||||
},
|
||||
},
|
||||
@@ -301,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",
|
||||
},
|
||||
},
|
||||
@@ -319,12 +338,130 @@ async function getTestResponse(
|
||||
): Promise<TestResponse> {
|
||||
const responseIdx = index % staticResponses.length;
|
||||
|
||||
// Dynamic responses: fetch events from DB and format
|
||||
// === SPORT TEST SCENARIO (Dynamic responses) ===
|
||||
// Response 1: Add exception to "Sport" (2 weeks later)
|
||||
if (responseIdx === 1) {
|
||||
const events = await eventRepo.findByUserId(userId);
|
||||
const sportEvent = events.find((e) => e.title === "Sport");
|
||||
if (sportEvent) {
|
||||
// Calculate date 2 weeks from the first occurrence
|
||||
const exceptionDate = new Date(sportEvent.startTime);
|
||||
exceptionDate.setDate(exceptionDate.getDate() + 14);
|
||||
const exceptionDateStr = exceptionDate.toISOString().split("T")[0];
|
||||
|
||||
return {
|
||||
content:
|
||||
"Verstanden! Ich füge eine Ausnahme für den Sport-Termin in 2 Wochen hinzu:",
|
||||
proposedChanges: [
|
||||
{
|
||||
id: "sport-exception",
|
||||
action: "delete",
|
||||
eventId: sportEvent.id,
|
||||
deleteMode: "single",
|
||||
occurrenceDate: exceptionDateStr,
|
||||
event: {
|
||||
title: sportEvent.title,
|
||||
startTime: exceptionDate,
|
||||
endTime: new Date(
|
||||
exceptionDate.getTime() + 90 * 60 * 1000,
|
||||
), // +90 min
|
||||
description: sportEvent.description,
|
||||
recurrenceRule: sportEvent.recurrenceRule,
|
||||
exceptionDates: sportEvent.exceptionDates,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: "Ich konnte keinen Termin 'Sport' finden. Bitte erstelle ihn zuerst.",
|
||||
};
|
||||
}
|
||||
|
||||
// Response 2: Add UNTIL to "Sport" (after 6 weeks total)
|
||||
if (responseIdx === 2) {
|
||||
const events = await eventRepo.findByUserId(userId);
|
||||
const sportEvent = events.find((e) => e.title === "Sport");
|
||||
if (sportEvent) {
|
||||
// Calculate UNTIL date: 6 weeks from start
|
||||
const untilDate = new Date(sportEvent.startTime);
|
||||
untilDate.setDate(untilDate.getDate() + 42); // 6 weeks
|
||||
const untilStr = untilDate.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z";
|
||||
|
||||
const newRule = `FREQ=WEEKLY;BYDAY=WE;UNTIL=${untilStr}`;
|
||||
|
||||
return {
|
||||
content:
|
||||
"Alles klar! Ich beende die Sport-Serie nach 6 Wochen:",
|
||||
proposedChanges: [
|
||||
{
|
||||
id: "sport-until",
|
||||
action: "update",
|
||||
eventId: sportEvent.id,
|
||||
updates: { recurrenceRule: newRule },
|
||||
event: {
|
||||
title: sportEvent.title,
|
||||
startTime: sportEvent.startTime,
|
||||
endTime: sportEvent.endTime,
|
||||
description: sportEvent.description,
|
||||
recurrenceRule: newRule,
|
||||
exceptionDates: sportEvent.exceptionDates,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: "Ich konnte keinen Termin 'Sport' finden. Bitte erstelle ihn zuerst.",
|
||||
};
|
||||
}
|
||||
|
||||
// Response 3: Add another exception to "Sport" (2 weeks after the first exception)
|
||||
if (responseIdx === 3) {
|
||||
const events = await eventRepo.findByUserId(userId);
|
||||
const sportEvent = events.find((e) => e.title === "Sport");
|
||||
if (sportEvent) {
|
||||
// Calculate date 4 weeks from the first occurrence (2 weeks after the first exception)
|
||||
const exceptionDate = new Date(sportEvent.startTime);
|
||||
exceptionDate.setDate(exceptionDate.getDate() + 28); // 4 weeks
|
||||
const exceptionDateStr = exceptionDate.toISOString().split("T")[0];
|
||||
|
||||
return {
|
||||
content:
|
||||
"Alles klar! Ich füge eine weitere Ausnahme für den Sport-Termin hinzu:",
|
||||
proposedChanges: [
|
||||
{
|
||||
id: "sport-exception-2",
|
||||
action: "delete",
|
||||
eventId: sportEvent.id,
|
||||
deleteMode: "single",
|
||||
occurrenceDate: exceptionDateStr,
|
||||
event: {
|
||||
title: sportEvent.title,
|
||||
startTime: exceptionDate,
|
||||
endTime: new Date(
|
||||
exceptionDate.getTime() + 90 * 60 * 1000,
|
||||
), // +90 min
|
||||
description: sportEvent.description,
|
||||
recurrenceRule: sportEvent.recurrenceRule,
|
||||
exceptionDates: sportEvent.exceptionDates,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: "Ich konnte keinen Termin 'Sport' finden. Bitte erstelle ihn zuerst.",
|
||||
};
|
||||
}
|
||||
|
||||
// Dynamic responses: fetch events from DB and format
|
||||
// (Note: indices shifted by +3 due to new sport responses)
|
||||
if (responseIdx === 6) {
|
||||
return { content: await getWeeksOverview(eventRepo, userId, 2) };
|
||||
}
|
||||
|
||||
if (responseIdx === 4) {
|
||||
if (responseIdx === 7) {
|
||||
// Delete "Meeting mit Jens"
|
||||
const events = await eventRepo.findByUserId(userId);
|
||||
const jensEvent = events.find((e) => e.title === "Meeting mit Jens");
|
||||
@@ -341,7 +478,6 @@ async function getTestResponse(
|
||||
startTime: jensEvent.startTime,
|
||||
endTime: jensEvent.endTime,
|
||||
description: jensEvent.description,
|
||||
isRecurring: jensEvent.isRecurring,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -350,11 +486,11 @@ async function getTestResponse(
|
||||
return { content: "Ich konnte keinen Termin 'Meeting mit Jens' finden." };
|
||||
}
|
||||
|
||||
if (responseIdx === 8) {
|
||||
if (responseIdx === 11) {
|
||||
return { content: await getWeeksOverview(eventRepo, userId, 1) };
|
||||
}
|
||||
|
||||
if (responseIdx === 10) {
|
||||
if (responseIdx === 13) {
|
||||
// Update "Telefonat mit Mama" +3 days and change time to 13:00
|
||||
const events = await eventRepo.findByUserId(userId);
|
||||
const mamaEvent = events.find((e) => e.title === "Telefonat mit Mama");
|
||||
@@ -387,7 +523,7 @@ async function getTestResponse(
|
||||
return { content: "Ich konnte keinen Termin 'Telefonat mit Mama' finden." };
|
||||
}
|
||||
|
||||
if (responseIdx === 13) {
|
||||
if (responseIdx === 16) {
|
||||
const now = new Date();
|
||||
return {
|
||||
content: await getMonthOverview(
|
||||
@@ -571,4 +707,12 @@ export class ChatService {
|
||||
|
||||
return this.chatRepo.getMessages(conversationId, options);
|
||||
}
|
||||
|
||||
async updateProposalEvent(
|
||||
messageId: string,
|
||||
proposalId: string,
|
||||
event: CreateEventDTO,
|
||||
): Promise<ChatMessage | null> {
|
||||
return this.chatRepo.updateProposalEvent(messageId, proposalId, event);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ChatMessage | null>;
|
||||
|
||||
updateProposalEvent(
|
||||
messageId: string,
|
||||
proposalId: string,
|
||||
event: CreateEventDTO,
|
||||
): Promise<ChatMessage | null>;
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
32
package-lock.json
generated
32
package-lock.json
generated
@@ -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"
|
||||
@@ -12535,7 +12562,10 @@
|
||||
},
|
||||
"packages/shared": {
|
||||
"name": "@calchat/shared",
|
||||
"version": "1.0.0"
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"rrule": "^2.8.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,5 +7,8 @@
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./*": "./src/*"
|
||||
},
|
||||
"dependencies": {
|
||||
"rrule": "^2.8.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,8 +26,8 @@ export interface CreateEventDTO {
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
note?: string;
|
||||
isRecurring?: boolean;
|
||||
recurrenceRule?: string;
|
||||
exceptionDates?: string[]; // For display in proposals
|
||||
}
|
||||
|
||||
export interface UpdateEventDTO {
|
||||
@@ -36,7 +36,6 @@ export interface UpdateEventDTO {
|
||||
startTime?: Date;
|
||||
endTime?: Date;
|
||||
note?: string;
|
||||
isRecurring?: boolean;
|
||||
recurrenceRule?: string;
|
||||
exceptionDates?: string[];
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
71
packages/shared/src/utils/formatters.ts
Normal file
71
packages/shared/src/utils/formatters.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* German date/time formatting helpers.
|
||||
* Used across client and server.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Format date as DD.MM.YYYY
|
||||
*/
|
||||
export function formatDate(date: Date): string {
|
||||
const d = new Date(date);
|
||||
return d.toLocaleDateString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time as HH:MM
|
||||
*/
|
||||
export function formatTime(date: Date): string {
|
||||
const d = new Date(date);
|
||||
return d.toLocaleTimeString("de-DE", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date and time as DD.MM.YYYY HH:MM:SS
|
||||
*/
|
||||
export function formatDateTime(date: Date): string {
|
||||
const d = new Date(date);
|
||||
return `${formatDate(d)} ${d.toLocaleTimeString("de-DE")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date with weekday as "Mo., DD.MM.YYYY"
|
||||
*/
|
||||
export function formatDateWithWeekday(date: Date): string {
|
||||
const d = new Date(date);
|
||||
return d.toLocaleDateString("de-DE", {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
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",
|
||||
});
|
||||
}
|
||||
@@ -1 +1,3 @@
|
||||
export * from "./dateHelpers";
|
||||
export * from "./formatters";
|
||||
export * from "./rruleHelpers";
|
||||
|
||||
29
packages/shared/src/utils/rruleHelpers.ts
Normal file
29
packages/shared/src/utils/rruleHelpers.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* RRULE building and parsing helpers.
|
||||
*/
|
||||
|
||||
export type RepeatType = "Tag" | "Woche" | "Monat" | "Jahr";
|
||||
|
||||
const REPEAT_TYPE_TO_FREQ: Record<RepeatType, string> = {
|
||||
Tag: "DAILY",
|
||||
Woche: "WEEKLY",
|
||||
Monat: "MONTHLY",
|
||||
Jahr: "YEARLY",
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 buildRRule(repeatType: RepeatType, interval: number = 1): string {
|
||||
const freq = REPEAT_TYPE_TO_FREQ[repeatType];
|
||||
|
||||
if (interval <= 1) {
|
||||
return `FREQ=${freq}`;
|
||||
}
|
||||
|
||||
return `FREQ=${freq};INTERVAL=${interval}`;
|
||||
}
|
||||
Reference in New Issue
Block a user