Compare commits

..

37 Commits

Author SHA1 Message Date
d7902deeb4 pipeline
Some checks failed
continuous-integration/drone/push Build was killed
continuous-integration/drone/tag Build is passing
2026-02-25 20:09:52 +01:00
7fefb9a153 pino shoudn't be a dev dependency; hopefully fixed pipeline
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/tag Build is passing
2026-02-25 19:08:32 +01:00
565cb0a044 sanitize tag-names for kubernetes
Some checks failed
continuous-integration/drone/push Build was killed
continuous-integration/drone/tag Build is failing
2026-02-25 18:39:00 +01:00
6463100fbd feat: restore CI pipelines and add k3s deployment
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/tag Build is failing
Re-enable build/test/format pipelines, rename deploy_server to
deploy_latest, add upload_tag (tag-triggered k3s deploy) and
upload_commit (promote-triggered k3s deploy). Update CLAUDE.md.
2026-02-25 17:58:03 +01:00
b088e380a4 typo
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone Build is passing
2026-02-24 18:11:56 +01:00
54936f1b96 added tsconfig.json in Dockerfile
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-24 18:08:59 +01:00
e732305d99 feat: add deploy pipeline and switch Dockerfile to COPY-based build
Some checks failed
continuous-integration/drone/push Build is failing
Add deploy_server Drone pipeline that builds and pushes the Docker image
to Gitea Container Registry, then deploys to VPS via SSH. Switch
Dockerfile from git clone to COPY-based build for CI compatibility and
better layer caching. Change exposed port to 3001.
2026-02-24 17:52:48 +01:00
93a0928928 hopefully final pipeline fix for now
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-24 12:57:35 +01:00
68a49712bc another pipeline fix
Some checks failed
continuous-integration/drone/push Build encountered an error
2026-02-24 12:52:09 +01:00
602e4e1413 added types in pipeline
Some checks failed
continuous-integration/drone/push Build encountered an error
2026-02-24 12:47:14 +01:00
bf8bb3cfb8 feat: add Drone CI pipelines, Jest unit tests, and Prettier check
Some checks failed
continuous-integration/drone/push Build encountered an error
Add Drone CI with server build/test and format check pipelines.
Add unit tests for password utils and recurrenceExpander.
Add check_format script, fix Jest config to ignore dist/,
remove dead CaldavService.test.ts, apply Prettier formatting.
2026-02-24 12:43:31 +01:00
16848bfdf0 refactor: clone repo from Gitea in Dockerfile instead of COPY
Replace local COPY with git clone --depth 1 so the image can be built
without a local source context. Adds BRANCH build arg (default: main).
2026-02-18 20:12:05 +01:00
a3e7f0288e feat: add Docker support and compile shared package to dist
- Add multi-stage Dockerfile for server containerization
- Add .dockerignore to exclude unnecessary files from build context
- Switch shared package from source to compiled CommonJS output (dist/)
- Server dev/build scripts now build shared package first
- Fix deep imports to use @calchat/shared barrel export
- Update CLAUDE.md with Docker and shared package documentation
2026-02-18 19:37:27 +01:00
0c157da817 update README 2026-02-10 01:10:44 +01:00
e5cd64367d feat: add sync and logout toolbar to calendar screen
- Add CalendarToolbar component between header and weekdays in calendar.tsx
- Sync button with CalDAV sync, spinner during sync, green checkmark on success, red X on error (3s feedback)
- Sync button disabled/greyed out when no CalDAV config present
- Logout button with redirect to login screen
- Buttons styled with border and shadow
- Update CLAUDE.md with CalendarToolbar documentation
2026-02-09 23:51:43 +01:00
b9ffc6c908 refactor: reduce CalDAV sync to login and manual sync button only
- Remove auto-login sync in AuthGuard
- Remove 10s interval sync and syncAndReload in calendar tab
- Remove lazy syncOnce pattern in ChatService AI callbacks
- Remove CaldavService dependency from ChatService constructor
2026-02-09 23:32:04 +01:00
5a9485acfc fix: use pino err key for proper Error serialization in controllers
Error objects logged as { error } were serialized as {} because pino
only applies its error serializer to the err key.
2026-02-09 22:41:46 +01:00
189c38dc2b docs: clean up frontend class diagram layout
Comment out service methods for consistency with stores and switch to
left-to-right direction for a more vertical package arrangement.
2026-02-09 22:00:13 +01:00
73e768a0ad refactor: remove all JWT-related code and references
JWT was never used - auth uses X-User-Id header. Removes jwt.ts utility,
jsonwebtoken dependency, stubbed refresh/logout endpoints, and updates
all docs (PUML diagrams, api-routes, tex, CLAUDE.md) accordingly.
2026-02-09 20:02:05 +01:00
cb32bd23ca docs: add .env.example files for client and server 2026-02-09 19:57:55 +01:00
cbf123ddd6 feat: add visual feedback for CalDAV save & sync actions
- Show spinner + loading text while request is in progress
- Display success (green) or error (red) message, auto-clears after 3s
- Save and Sync have independent feedback rows (both visible at once)
- Fix CaldavTextInput theming and add secureTextEntry for password
- Reset CustomTextInput cursor to start when unfocused
2026-02-09 19:53:51 +01:00
3ad4a77951 fix: chat starts scrolled to bottom instead of visibly scrolling down
- Use onContentSizeChange to scroll after FlashList renders content
- Scroll without animation on initial load via needsInitialScroll ref
- Remove unreliable 100ms timeout scrollToEnd from message loading
2026-02-09 19:23:45 +01:00
aabce1a5b0 refactor: use CustomTextInput in login and register screens
- Replace raw TextInput with CustomTextInput in login and register
  for consistent focus border effect across the app
- Add placeholder, secureTextEntry, autoCapitalize, keyboardType
  props to CustomTextInput
- Remove hardcoded default padding (px-3 py-2) and h-11/12 from
  CustomTextInput, callers now set padding via className
- Add explicit px-3 py-2 to existing callers (settings, editEvent)
- Update CLAUDE.md with new CustomTextInput usage and props
2026-02-09 19:15:41 +01:00
868e1ba68d perf: preload events and CalDAV config to avoid empty screens
Add CaldavConfigStore and preloadAppData() to load events (current month)
and CalDAV config into stores before dismissing the auth loading spinner.
This prevents the brief empty flash when first navigating to Calendar or
Settings tabs. Also applies Prettier formatting across codebase.
2026-02-09 18:59:03 +01:00
0e406e4dca perf: load calendar events instantly, sync CalDAV in background
Split loadEvents into two functions: loadEvents (instant DB read) and
syncAndReload (background CalDAV sync + reload). Events now appear
immediately when switching to the Calendar tab instead of waiting for
the CalDAV sync to complete.
2026-02-09 18:37:14 +01:00
b94b5f5ed8 Merge branch 'main' of https://gitea.gilmour109.de/Gilmour109/calchat 2026-02-09 18:18:25 +01:00
0a2aef2098 fix: recurring event display and AI query improvements
- Use occurrenceStart instead of startTime in getEventsInRange so
  recurring events show their actual occurrence date to the AI
- Add lazy CalDAV sync in ChatService (syncOnce before first DB access)
- Add CaldavService.sync() with internal config check (silent no-op)
- Show German recurrence description (e.g. "Jede Woche") instead of
  generic "Wiederkehrend" in EventCardBase via formatRecurrenceRule()
- Move RepeatType and REPEAT_TYPE_LABELS from editEvent to shared
- Separate calendar overlay useFocusEffect from event loading
2026-02-09 18:17:39 +01:00
325246826a feat: add CalDAV synchronization with automatic sync
- Add CaldavService with tsdav/ical.js for CalDAV server communication
- Add CaldavController, CaldavRepository, and caldav routes
- Add client-side CaldavConfigService with sync(), config CRUD
- Add CalDAV settings UI with config load/save in settings screen
- Sync on login, auto-login (AuthGuard), periodic timer (calendar), and sync button
- Push single events to CalDAV on server-side create/update/delete
- Push all events to CalDAV after chat event confirmation
- Refactor ChatService to use EventService instead of direct EventRepository
- Rename CalDav/calDav to Caldav/caldav for consistent naming
- Add Radicale Docker setup for local CalDAV testing
- Update PlantUML diagrams and CLAUDE.md with CalDAV architecture
2026-02-08 19:24:59 +01:00
81221d8b70 refactor: remove redundant isRecurring property, use recurrenceRule instead
isRecurring was redundant since recurrenceRule as truthy/falsy check suffices.
Removed from shared CalendarEvent type, Mongoose virtual, and all usages.
2026-02-07 16:16:35 +01:00
be9d1c5b6d updated eas.json 2026-02-02 22:49:25 +01:00
1092ff2648 refactor: improve AI event handling and conflict display in chat
- AI fetches events on-demand via callbacks for better efficiency
- Add conflict detection with warning display when proposing overlapping events
- Improve event search and display in chat interface
- Load full chat history for display while limiting AI context
2026-02-02 22:44:08 +01:00
387bb2d1ee fix: auto-scroll to typing indicator in chat 2026-01-31 18:51:02 +01:00
6f0d172bf2 feat: add EditEventScreen with calendar and chat mode support
Add a unified event editor that works in two modes:
- Calendar mode: Create/edit events directly via EventService API
- Chat mode: Edit AI-proposed events before confirming them

The chat mode allows users to modify proposed events (title, time,
recurrence) and persists changes both locally and to the server.

New components: DateTimePicker, ScrollableDropdown, useDropdownPosition
New API: PUT /api/chat/messages/:messageId/proposal
2026-01-31 18:46:31 +01:00
617543a603 feat: add RRULE parsing to shared package and improve ProposedEventCard UI
- Add rrule library to shared package for RRULE string parsing
- Add rruleHelpers.ts with parseRRule() returning freq, until, count, interval, byDay
- Add formatters.ts with German date/time formatters for client and server
- Extend CreateEventDTO with exceptionDates field for proposals
- Extend ChatModel schema with exceptionDates, deleteMode, occurrenceDate
- Update proposeUpdateEvent tool to support isRecurring and recurrenceRule params
- ProposedEventCard now shows green "Neue Ausnahme" and "Neues Ende" text
- Add Sport test scenario with dynamic exception and UNTIL responses
- Update CLAUDE.md documentation
2026-01-27 21:15:19 +01:00
4575483940 fix: improve modal behavior on web and Android scrolling
- Restructure ModalBase to use absolute-positioned backdrop behind card
  content, fixing modal stacking issues on web (React Native Web portals)
- Hide EventOverlay when DeleteEventModal is open to prevent z-index
  conflicts on web
- Add nestedScrollEnabled to CardBase ScrollView for Android
- Use TouchableOpacity with delayPressIn in EventCard for scroll-friendly
  touch handling
- Keep eventToDelete state stable during modal fade-out to prevent
  content flash between recurring/single variants
- Fix German umlauts in DeleteEventModal
2026-01-25 22:38:37 +01:00
726334c155 refactor: add CardBase and ModalBase components
- Add CardBase: reusable card with header, content, footer
  - Configurable via props: padding, border, text size, background
- Add ModalBase: modal wrapper using CardBase internally
  - Provides backdrop, click-outside-to-close, Android back button
- Refactor EventCardBase to use CardBase
- Refactor DeleteEventModal to use ModalBase
- Refactor EventOverlay (calendar.tsx) to use ModalBase
- Update CLAUDE.md with component documentation
2026-01-25 21:50:19 +01:00
2b999d9b0f feat: add recurring event deletion with three modes
Implement three deletion modes for recurring events:
- single: exclude specific occurrence via EXDATE mechanism
- future: set RRULE UNTIL to stop future occurrences
- all: delete entire event series

Changes include:
- Add exceptionDates field to CalendarEvent model
- Add RecurringDeleteMode type and DeleteRecurringEventDTO
- EventService.deleteRecurring() with mode-based logic using rrule library
- EventController DELETE endpoint accepts mode/occurrenceDate query params
- recurrenceExpander filters out exception dates during expansion
- AI tools support deleteMode and occurrenceDate for proposed deletions
- ChatService.confirmEvent() handles recurring delete modes
- New DeleteEventModal component for unified delete confirmation UI
- Calendar screen integrates modal for both recurring and non-recurring events
2026-01-25 15:19:31 +01:00
103 changed files with 11198 additions and 1061 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
node_modules
*/node_modules
*/*/node_modules
**/dist
apps/client
.git
.env
*.md

171
.drone.yml Normal file
View File

@@ -0,0 +1,171 @@
kind: pipeline
type: docker
name: server_build_and_test
trigger:
branch:
- main
event:
- push
steps:
- name: build_server
image: node
commands:
- npm ci -w @calchat/shared
- npm ci -w @calchat/server
- npm run build -w @calchat/server
- name: jest_server
image: node
commands:
- npm run test -w @calchat/server
---
kind: pipeline
type: docker
name: check_for_formatting
trigger:
branch:
- main
event:
- push
steps:
- name: format_check
image: node
commands:
- npm ci
- npm run check_format
---
kind: pipeline
type: docker
name: deploy_latest
trigger:
branch:
- main
event:
- push
steps:
- name: upload_latest
image: plugins/docker
settings:
registry: gitea.gilmour109.de
repo: gitea.gilmour109.de/gilmour109/calchat-server
dockerfile: apps/server/docker/Dockerfile
tags:
- latest
username:
from_secret: gitea_username
password:
from_secret: gitea_password
- name: deploy_to_vps
image: appleboy/drone-ssh
settings:
host:
- 10.0.0.1
username: root
password:
from_secret: vps_ssh_password
envs:
- gitea_username
- gitea_password
port: 22
command_timeout: 2m
script:
- docker login -u $GITEA_USERNAME -p $GITEA_PASSWORD gitea.gilmour109.de
- docker pull gitea.gilmour109.de/gilmour109/calchat-server:latest
- docker compose -f /root/calchat-mongo/docker-compose.yml up -d
depends_on:
- server_build_and_test
- check_for_formatting
---
kind: pipeline
type: docker
name: upload_tag
trigger:
event:
- tag
steps:
- name: upload_tag
image: plugins/docker
settings:
registry: gitea.gilmour109.de
repo: gitea.gilmour109.de/gilmour109/calchat-server
dockerfile: apps/server/docker/Dockerfile
tags:
- ${DRONE_TAG}
username:
from_secret: gitea_username
password:
from_secret: gitea_password
- name: deploy_to_k3s
image: appleboy/drone-ssh
settings:
host:
- 192.168.178.201
username: debian
password:
from_secret: k3s_ssh_password
envs:
- drone_tag
port: 22
command_timeout: 2m
script:
- export TAG=$DRONE_TAG
- export NAME=$(echo $DRONE_TAG | tr -d '.')
- envsubst < /home/debian/manifest.yml | sudo kubectl apply -f -
---
kind: pipeline
type: docker
name: upload_commit
trigger:
event:
- promote
steps:
- name: upload_commit
image: plugins/docker
settings:
registry: gitea.gilmour109.de
repo: gitea.gilmour109.de/gilmour109/calchat-server
dockerfile: apps/server/docker/Dockerfile
tags:
- ${DRONE_COMMIT_SHA:0:8}
username:
from_secret: gitea_username
password:
from_secret: gitea_password
- name: deploy_to_k3s
image: appleboy/drone-ssh
settings:
host:
- 192.168.178.201
username: debian
password:
from_secret: k3s_ssh_password
envs:
- drone_commit_sha
port: 22
command_timeout: 2m
script:
- export TAG=$(echo $DRONE_COMMIT_SHA | cut -c1-8)
- export NAME=$TAG
- envsubst < /home/debian/manifest.yml | sudo kubectl apply -f -

2
.gitignore vendored
View File

@@ -3,3 +3,5 @@ node_modules
docs/praesi_2_context.md docs/praesi_2_context.md
docs/*.png docs/*.png
.env .env
apps/server/docker/radicale/config/
apps/server/docker/radicale/data/

357
CLAUDE.md
View File

@@ -14,6 +14,7 @@ This is a fullstack TypeScript monorepo with npm workspaces.
```bash ```bash
npm install # Install all dependencies for all workspaces npm install # Install all dependencies for all workspaces
npm run format # Format all TypeScript files with Prettier npm run format # Format all TypeScript files with Prettier
npm run check_format # Check formatting without modifying files (used in CI)
``` ```
### Client (apps/client) - Expo React Native app ### Client (apps/client) - Expo React Native app
@@ -26,11 +27,17 @@ npm run lint -w @calchat/client # Run ESLint
npm run build:apk -w @calchat/client # Build APK locally with EAS npm run build:apk -w @calchat/client # Build APK locally with EAS
``` ```
### Shared (packages/shared)
```bash
npm run build -w @calchat/shared # Compile shared types to dist/
```
### Server (apps/server) - Express.js backend ### Server (apps/server) - Express.js backend
```bash ```bash
npm run dev -w @calchat/server # Start dev server with hot reload (tsx watch) npm run dev -w @calchat/server # Build shared + start dev server with hot reload (tsx watch)
npm run build -w @calchat/server # Compile TypeScript npm run build -w @calchat/server # Build shared + compile TypeScript
npm run start -w @calchat/server # Run compiled server (port 3000) npm run start -w @calchat/server # Run compiled server (port 3000)
npm run test -w @calchat/server # Run Jest unit tests
``` ```
## Technology Stack ## Technology Stack
@@ -48,9 +55,14 @@ npm run start -w @calchat/server # Run compiled server (port 3000)
| | MongoDB | Database | | | MongoDB | Database |
| | Mongoose | ODM | | | Mongoose | ODM |
| | GPT (OpenAI) | AI/LLM for chat | | | GPT (OpenAI) | AI/LLM for chat |
| | X-User-Id Header | Authentication (simple, no JWT yet) | | | X-User-Id Header | Authentication |
| | pino / pino-http | Structured logging | | | pino / pino-http | Structured logging |
| | react-native-logs | Client-side logging | | | react-native-logs | Client-side logging |
| | tsdav | CalDAV client library |
| | ical.js | iCalendar parsing/generation |
| Testing | Jest / ts-jest | Server unit tests |
| Deployment | Docker | Server containerization (multi-stage build) |
| | Drone CI | CI/CD pipelines (build, test, format check, deploy) |
| Planned | iCalendar | Event export/import | | Planned | iCalendar | Event export/import |
## Architecture ## Architecture
@@ -74,24 +86,31 @@ src/
│ ├── (tabs)/ # Tab navigation group │ ├── (tabs)/ # Tab navigation group
│ │ ├── _layout.tsx # Tab bar configuration (themed) │ │ ├── _layout.tsx # Tab bar configuration (themed)
│ │ ├── chat.tsx # Chat screen (AI conversation) │ │ ├── chat.tsx # Chat screen (AI conversation)
│ │ ├── calendar.tsx # Calendar overview │ │ ├── calendar.tsx # Calendar overview (with CalendarToolbar: sync + logout)
│ │ └── settings.tsx # Settings screen (theme switcher, logout) │ │ └── settings.tsx # Settings screen (theme switcher, logout, CalDAV config with feedback)
│ ├── editEvent.tsx # Event edit screen (dual-mode: calendar/chat)
│ ├── event/ │ ├── event/
│ │ └── [id].tsx # Event detail screen (dynamic route) │ │ └── [id].tsx # Event detail screen (dynamic route)
│ └── note/ │ └── note/
│ └── [id].tsx # Note editor for event (dynamic route) │ └── [id].tsx # Note editor for event (dynamic route)
├── components/ ├── components/
│ ├── AuthGuard.tsx # Auth wrapper: loads user, shows loading, redirects if unauthenticated │ ├── AuthGuard.tsx # Auth wrapper: loads user, preloads app data (events + CalDAV config), CalDAV sync, shows loading, redirects if unauthenticated. Exports preloadAppData()
│ ├── BaseBackground.tsx # Common screen wrapper (themed) │ ├── BaseBackground.tsx # Common screen wrapper (themed)
│ ├── BaseButton.tsx # Reusable button component (themed, supports children) │ ├── BaseButton.tsx # Reusable button component (themed, supports children)
│ ├── Header.tsx # Header component (themed) │ ├── Header.tsx # Header component (themed)
│ ├── AuthButton.tsx # Reusable button for auth screens (themed, with shadow) │ ├── AuthButton.tsx # Reusable button for auth screens (themed, with shadow)
│ ├── CardBase.tsx # Reusable card component (header + content + optional footer)
│ ├── ModalBase.tsx # Reusable modal with backdrop (uses CardBase, click-outside-to-close)
│ ├── ChatBubble.tsx # Reusable chat bubble component (used by ChatMessage & TypingIndicator) │ ├── ChatBubble.tsx # Reusable chat bubble component (used by ChatMessage & TypingIndicator)
│ ├── TypingIndicator.tsx # Animated typing indicator (. .. ...) shown while waiting for AI response │ ├── TypingIndicator.tsx # Animated typing indicator (. .. ...) shown while waiting for AI response
│ ├── EventCardBase.tsx # Shared event card layout with icons (used by EventCard & ProposedEventCard) │ ├── EventCardBase.tsx # Event card layout with icons (uses CardBase)
│ ├── EventCard.tsx # Calendar event card (uses EventCardBase + edit/delete buttons) │ ├── EventCard.tsx # Calendar event card (uses EventCardBase + edit/delete buttons)
│ ├── EventConfirmDialog.tsx # AI-proposed event confirmation modal (skeleton) │ ├── EventConfirmDialog.tsx # AI-proposed event confirmation modal (skeleton)
── ProposedEventCard.tsx # Chat event proposal (uses EventCardBase + confirm/reject buttons) ── ProposedEventCard.tsx # Chat event proposal (uses EventCardBase + confirm/reject/edit buttons)
│ ├── DeleteEventModal.tsx # Delete confirmation modal (uses ModalBase)
│ ├── CustomTextInput.tsx # Themed text input with focus border (used in login, register, CaldavSettings, editEvent)
│ ├── DateTimePicker.tsx # Date and time picker components
│ └── ScrollableDropdown.tsx # Scrollable dropdown component
├── Themes.tsx # Theme definitions: THEMES object with defaultLight/defaultDark, Theme type ├── Themes.tsx # Theme definitions: THEMES object with defaultLight/defaultDark, Theme type
├── logging/ ├── logging/
│ ├── index.ts # Re-exports │ ├── index.ts # Re-exports
@@ -100,15 +119,19 @@ src/
│ ├── index.ts # Re-exports all services │ ├── index.ts # Re-exports all services
│ ├── ApiClient.ts # HTTP client with X-User-Id header injection, request logging, handles empty responses (204) │ ├── ApiClient.ts # HTTP client with X-User-Id header injection, request logging, handles empty responses (204)
│ ├── AuthService.ts # login(), register(), logout() - calls API and updates AuthStore │ ├── AuthService.ts # login(), register(), logout() - calls API and updates AuthStore
│ ├── EventService.ts # getAll(), getById(), getByDateRange(), create(), update(), delete() │ ├── EventService.ts # getAll(), getById(), getByDateRange(), create(), update(), delete(mode, occurrenceDate)
── ChatService.ts # sendMessage(), confirmEvent(), rejectEvent(), getConversations(), getConversation() ── ChatService.ts # sendMessage(), confirmEvent(deleteMode, occurrenceDate), rejectEvent(), getConversations(), getConversation(), updateProposalEvent()
└── stores/ # Zustand state management │ └── CaldavConfigService.ts # saveConfig(), getConfig(), deleteConfig(), pull(), pushAll(), sync()
├── index.ts # Re-exports all stores ├── stores/ # Zustand state management
├── AuthStore.ts # user, isAuthenticated, isLoading, login(), logout(), loadStoredUser() ├── index.ts # Re-exports all stores
# Uses expo-secure-store (native) / localStorage (web) │ ├── AuthStore.ts # user, isAuthenticated, isLoading, login(), logout(), loadStoredUser()
├── ChatStore.ts # messages[], isWaitingForResponse, addMessage(), addMessages(), updateMessage(), clearMessages(), setWaitingForResponse(), chatMessageToMessageData() │ # Uses expo-secure-store (native) / localStorage (web)
├── EventsStore.ts # events[], setEvents(), addEvent(), updateEvent(), deleteEvent() ├── ChatStore.ts # messages[], isWaitingForResponse, addMessage(), addMessages(), updateMessage(), clearMessages(), setWaitingForResponse(), chatMessageToMessageData()
── ThemeStore.ts # theme, setTheme() - reactive theme switching with Zustand ── EventsStore.ts # events[], setEvents(), addEvent(), updateEvent(), deleteEvent()
│ ├── CaldavConfigStore.ts # config (CaldavConfig | null), setConfig() - cached CalDAV config
│ └── ThemeStore.ts # theme, setTheme() - reactive theme switching with Zustand
└── hooks/
└── useDropdownPosition.ts # Hook for positioning dropdowns relative to trigger element
``` ```
**Routing:** Tab-based navigation with Chat, Calendar, and Settings as main screens. Auth screens (login, register) outside tabs. Dynamic routes for event detail and note editing. **Routing:** Tab-based navigation with Chat, Calendar, and Settings as main screens. Auth screens (login, register) outside tabs. Dynamic routes for event detail and note editing.
@@ -116,9 +139,12 @@ src/
**Authentication Flow:** **Authentication Flow:**
- `AuthGuard` component wraps the tab layout in `(tabs)/_layout.tsx` - `AuthGuard` component wraps the tab layout in `(tabs)/_layout.tsx`
- On app start, `AuthGuard` calls `loadStoredUser()` and shows loading indicator - On app start, `AuthGuard` calls `loadStoredUser()` and shows loading indicator
- After auth, `preloadAppData()` loads events (current month) + CalDAV config into stores before dismissing spinner
- If not authenticated, redirects to `/login` - If not authenticated, redirects to `/login`
- `login.tsx` also calls `preloadAppData()` after successful login (spinner stays visible during preload)
- `index.tsx` simply redirects to `/(tabs)/chat` - AuthGuard handles the rest - `index.tsx` simply redirects to `/(tabs)/chat` - AuthGuard handles the rest
- This pattern handles Expo Router's navigation state caching (avoids race conditions) - This pattern handles Expo Router's navigation state caching (avoids race conditions)
- Preloading prevents empty screens when navigating to Calendar or Settings tabs for the first time
### Theme System ### Theme System
@@ -156,15 +182,73 @@ setTheme("defaultDark"); // or "defaultLight"
**Note:** `shadowColor` only works on iOS. Android uses `elevation` with system-defined shadow colors. **Note:** `shadowColor` only works on iOS. Android uses `elevation` with system-defined shadow colors.
### Base Components (CardBase & ModalBase)
Reusable base components for cards and modals with consistent styling.
**CardBase** - Card structure with header, content, and optional footer:
```typescript
<CardBase
title="Title"
subtitle="Optional subtitle"
footer={{ label: "Button", onPress: () => {} }}
// Styling props (all optional):
headerPadding={4} // p-{n}, default: px-3 py-2
contentPadding={4} // p-{n}, default: px-3 py-2
headerTextSize="text-lg" // "text-sm" | "text-base" | "text-lg" | "text-xl"
borderWidth={2} // outer border, default: 2
headerBorderWidth={3} // header bottom border, default: borderWidth
contentBg={theme.primeBg} // content background color, default: theme.secondaryBg
scrollable={true} // wrap content in ScrollView
maxContentHeight={400} // for scrollable content
>
{children}
</CardBase>
```
**ModalBase** - Modal with backdrop using CardBase internally:
```typescript
<ModalBase
visible={isVisible}
onClose={() => setVisible(false)}
title="Modal Title"
subtitle="Optional"
footer={{ label: "Close", onPress: onClose }}
scrollable={true}
maxContentHeight={400}
>
{children}
</ModalBase>
```
ModalBase provides: transparent Modal + backdrop (click-outside-to-close) + Android back button support.
**ModalBase Architecture Note:** Uses absolute-positioned backdrop behind the card content (not nested Pressables). This approach:
- Fixes modal stacking issues on web (React Native Web renders modals as DOM portals)
- Allows proper scrolling on Android (no touch event conflicts)
- Card naturally blocks touches from reaching backdrop due to z-order
**Component Hierarchy:**
```
CardBase
├── ModalBase (uses CardBase)
│ ├── DeleteEventModal
│ └── EventOverlay (in calendar.tsx)
└── EventCardBase (uses CardBase)
├── EventCard
└── ProposedEventCard
```
### Backend Architecture (apps/server) ### Backend Architecture (apps/server)
``` ```
src/ src/
├── app.ts # Entry point, DI setup, Express config ├── app.ts # Entry point, DI setup, Express config
├── controllers/ # Request handlers + middleware (per architecture diagram) ├── controllers/ # Request handlers + middleware (per architecture diagram)
│ ├── AuthController.ts # login(), register(), refresh(), logout() │ ├── AuthController.ts # login(), register()
│ ├── ChatController.ts # sendMessage(), confirmEvent(), rejectEvent(), getConversations(), getConversation() │ ├── ChatController.ts # sendMessage(), confirmEvent() + CalDAV push, rejectEvent(), getConversations(), getConversation(), updateProposalEvent()
│ ├── EventController.ts # create(), getById(), getAll(), getByDateRange(), update(), delete() │ ├── EventController.ts # create(), getById(), getAll(), getByDateRange(), update(), delete() - pushes/deletes to CalDAV on mutations
│ ├── CaldavController.ts # saveConfig(), loadConfig(), deleteConfig(), pullEvents(), pushEvents(), pushEvent()
│ ├── AuthMiddleware.ts # authenticate() - X-User-Id header validation │ ├── AuthMiddleware.ts # authenticate() - X-User-Id header validation
│ └── LoggingMiddleware.ts # httpLogger - pino-http request logging │ └── LoggingMiddleware.ts # httpLogger - pino-http request logging
├── logging/ ├── logging/
@@ -175,16 +259,19 @@ src/
│ ├── index.ts # Combines all routes under /api │ ├── index.ts # Combines all routes under /api
│ ├── auth.routes.ts # /api/auth/* │ ├── auth.routes.ts # /api/auth/*
│ ├── chat.routes.ts # /api/chat/* (protected) │ ├── chat.routes.ts # /api/chat/* (protected)
── event.routes.ts # /api/events/* (protected) ── event.routes.ts # /api/events/* (protected)
│ └── caldav.routes.ts # /api/caldav/* (protected)
├── services/ # Business logic ├── services/ # Business logic
│ ├── interfaces/ # DB-agnostic interfaces (for dependency injection) │ ├── interfaces/ # DB-agnostic interfaces (for dependency injection)
│ │ ├── AIProvider.ts # processMessage() │ │ ├── AIProvider.ts # processMessage()
│ │ ├── UserRepository.ts # findById, findByEmail, findByUserName, create + CreateUserData │ │ ├── UserRepository.ts # findById, findByEmail, findByUserName, create + CreateUserData
│ │ ├── EventRepository.ts │ │ ├── EventRepository.ts
│ │ ── ChatRepository.ts │ │ ── ChatRepository.ts
│ │ └── CaldavRepository.ts
│ ├── AuthService.ts │ ├── AuthService.ts
│ ├── ChatService.ts │ ├── ChatService.ts
── EventService.ts ── EventService.ts
│ └── CaldavService.ts # connect(), pullEvents(), pushEvent(), pushAll(), deleteEvent(), sync logic
├── repositories/ # Data access (DB-specific implementations) ├── repositories/ # Data access (DB-specific implementations)
│ ├── index.ts # Re-exports from ./mongo │ ├── index.ts # Re-exports from ./mongo
│ └── mongo/ # MongoDB implementation │ └── mongo/ # MongoDB implementation
@@ -192,21 +279,22 @@ src/
│ │ ├── types.ts # Shared types (IdVirtual interface) │ │ ├── types.ts # Shared types (IdVirtual interface)
│ │ ├── UserModel.ts │ │ ├── UserModel.ts
│ │ ├── EventModel.ts │ │ ├── EventModel.ts
│ │ ── ChatModel.ts │ │ ── ChatModel.ts
│ │ └── CaldavConfigModel.ts
│ ├── MongoUserRepository.ts # findById, findByEmail, findByUserName, create │ ├── MongoUserRepository.ts # findById, findByEmail, findByUserName, create
│ ├── MongoEventRepository.ts │ ├── MongoEventRepository.ts
── MongoChatRepository.ts ── MongoChatRepository.ts
│ └── MongoCaldavRepository.ts
├── ai/ ├── ai/
│ ├── GPTAdapter.ts # Implements AIProvider using OpenAI GPT │ ├── GPTAdapter.ts # Implements AIProvider using OpenAI GPT
│ ├── index.ts # Re-exports GPTAdapter │ ├── index.ts # Re-exports GPTAdapter
│ └── utils/ # Shared AI utilities (provider-agnostic) │ └── utils/ # Shared AI utilities (provider-agnostic)
│ ├── index.ts # Re-exports │ ├── index.ts # Re-exports
│ ├── eventFormatter.ts # formatExistingEvents() for system prompt │ ├── eventFormatter.ts # Re-exports formatDate/Time/DateTime from shared
│ ├── systemPrompt.ts # buildSystemPrompt() - German calendar assistant prompt │ ├── systemPrompt.ts # buildSystemPrompt() - German calendar assistant prompt
│ ├── toolDefinitions.ts # TOOL_DEFINITIONS - provider-agnostic tool specs │ ├── toolDefinitions.ts # TOOL_DEFINITIONS - provider-agnostic tool specs
│ └── toolExecutor.ts # executeToolCall() - handles getDay, proposeCreate/Update/Delete, searchEvents │ └── toolExecutor.ts # executeToolCall() - handles getDay, proposeCreate/Update/Delete, searchEvents, getEventsInRange
├── utils/ ├── utils/
│ ├── jwt.ts # signToken(), verifyToken() - NOT USED YET (no JWT)
│ ├── password.ts # hash(), compare() using bcrypt │ ├── password.ts # hash(), compare() using bcrypt
│ ├── eventFormatters.ts # getWeeksOverview(), getMonthOverview() - formatted event listings │ ├── eventFormatters.ts # getWeeksOverview(), getMonthOverview() - formatted event listings
│ └── recurrenceExpander.ts # expandRecurringEvents() - expand recurring events into occurrences │ └── recurrenceExpander.ts # expandRecurringEvents() - expand recurring events into occurrences
@@ -217,31 +305,39 @@ src/
**API Endpoints:** **API Endpoints:**
- `POST /api/auth/login` - User login - `POST /api/auth/login` - User login
- `POST /api/auth/register` - User registration - `POST /api/auth/register` - User registration
- `POST /api/auth/refresh` - Refresh JWT token
- `POST /api/auth/logout` - User logout
- `GET /api/events` - Get all events (protected) - `GET /api/events` - Get all events (protected)
- `GET /api/events/range` - Get events by date range (protected) - `GET /api/events/range` - Get events by date range (protected)
- `GET /api/events/:id` - Get single event (protected) - `GET /api/events/:id` - Get single event (protected)
- `POST /api/events` - Create event (protected) - `POST /api/events` - Create event (protected)
- `PUT /api/events/:id` - Update event (protected) - `PUT /api/events/:id` - Update event (protected)
- `DELETE /api/events/:id` - Delete event (protected) - `DELETE /api/events/:id` - Delete event (protected, query params: mode, occurrenceDate for recurring)
- `POST /api/chat/message` - Send message to AI (protected) - `POST /api/chat/message` - Send message to AI (protected)
- `POST /api/chat/confirm/:conversationId/:messageId` - Confirm proposed event (protected) - `POST /api/chat/confirm/:conversationId/:messageId` - Confirm proposed event (protected)
- `POST /api/chat/reject/:conversationId/:messageId` - Reject proposed event (protected) - `POST /api/chat/reject/:conversationId/:messageId` - Reject proposed event (protected)
- `GET /api/chat/conversations` - Get all conversations (protected) - `GET /api/chat/conversations` - Get all conversations (protected)
- `GET /api/chat/conversations/:id` - Get messages of a conversation with cursor-based pagination (protected) - `GET /api/chat/conversations/:id` - Get messages of a conversation with cursor-based pagination (protected)
- `PUT /api/chat/messages/:messageId/proposal` - Update proposal event data before confirming (protected)
- `PUT /api/caldav/config` - Save CalDAV config (protected)
- `GET /api/caldav/config` - Load CalDAV config (protected)
- `DELETE /api/caldav/config` - Delete CalDAV config (protected)
- `POST /api/caldav/pull` - Pull events from CalDAV server (protected)
- `POST /api/caldav/pushAll` - Push all unsynced events (protected)
- `POST /api/caldav/push/:caldavUUID` - Push single event (protected)
- `GET /health` - Health check - `GET /health` - Health check
- `POST /api/ai/test` - AI test endpoint (development only) - `POST /api/ai/test` - AI test endpoint (development only)
### Shared Package (packages/shared) ### Shared Package (packages/shared)
The shared package is compiled to `dist/` (CommonJS). All imports must use `@calchat/shared` (NOT `@calchat/shared/src/...`). Server `dev` and `build` scripts automatically build shared first.
``` ```
src/ src/
├── index.ts ├── index.ts
├── models/ ├── models/
│ ├── index.ts │ ├── index.ts
│ ├── User.ts # User, CreateUserDTO, LoginDTO, AuthResponse │ ├── User.ts # User, CreateUserDTO, LoginDTO, AuthResponse
│ ├── CalendarEvent.ts # CalendarEvent, CreateEventDTO, UpdateEventDTO, ExpandedEvent │ ├── CalendarEvent.ts # CalendarEvent, CreateEventDTO, UpdateEventDTO, ExpandedEvent, CaldavSyncStatus
│ ├── CaldavConfig.ts # CaldavConfig
│ ├── ChatMessage.ts # ChatMessage, Conversation, SendMessageDTO, CreateMessageDTO, │ ├── ChatMessage.ts # ChatMessage, Conversation, SendMessageDTO, CreateMessageDTO,
│ │ # GetMessagesOptions, ChatResponse, ConversationSummary, │ │ # GetMessagesOptions, ChatResponse, ConversationSummary,
│ │ # ProposedEventChange, EventAction, RespondedAction, UpdateMessageDTO │ │ # ProposedEventChange, EventAction, RespondedAction, UpdateMessageDTO
@@ -249,21 +345,30 @@ src/
│ # DAY_TO_GERMAN, DAY_TO_GERMAN_SHORT, MONTH_TO_GERMAN │ # DAY_TO_GERMAN, DAY_TO_GERMAN_SHORT, MONTH_TO_GERMAN
└── utils/ └── utils/
├── index.ts ├── 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(), buildRRule(), formatRecurrenceRule() - RRULE parsing, building, and German formatting
``` ```
**Key Types:** **Key Types:**
- `User`: id, email, userName, passwordHash?, createdAt?, updatedAt? - `User`: id, email, userName, passwordHash?, createdAt?, updatedAt?
- `CalendarEvent`: id, userId, title, description?, startTime, endTime, note?, isRecurring?, recurrenceRule? - `CalendarEvent`: id, userId, caldavUUID?, etag?, title, description?, startTime, endTime, note?, recurrenceRule?, exceptionDates?, caldavSyncStatus?
- `CaldavConfig`: userId, serverUrl, username, password, syncIntervalSeconds?
- `CaldavSyncStatus`: 'synced' | 'error'
- `ExpandedEvent`: Extends CalendarEvent with occurrenceStart, occurrenceEnd (for recurring event instances) - `ExpandedEvent`: Extends CalendarEvent with occurrenceStart, occurrenceEnd (for recurring event instances)
- `ChatMessage`: id, conversationId, sender ('user' | 'assistant'), content, proposedChanges? - `ChatMessage`: id, conversationId, sender ('user' | 'assistant'), content, proposedChanges?
- `ProposedEventChange`: id, action ('create' | 'update' | 'delete'), eventId?, event?, updates?, respondedAction? - `ProposedEventChange`: id, action ('create' | 'update' | 'delete'), eventId?, event?, updates?, respondedAction?, deleteMode?, occurrenceDate?, conflictingEvents?
- Each proposal has unique `id` (e.g., "proposal-0") for individual confirm/reject - Each proposal has unique `id` (e.g., "proposal-0") for individual confirm/reject
- `respondedAction` tracks user response per proposal (not per message) - `respondedAction` tracks user response per proposal (not per message)
- `deleteMode` ('single' | 'future' | 'all') and `occurrenceDate` for recurring event deletion
- `conflictingEvents` contains events that overlap with the proposed time (for conflict warnings)
- `ConflictingEvent`: title, startTime, endTime - simplified event info for conflict display
- `RecurringDeleteMode`: 'single' | 'future' | 'all' - delete modes for recurring events
- `DeleteRecurringEventDTO`: mode, occurrenceDate? - DTO for recurring event deletion
- `Conversation`: id, userId, createdAt?, updatedAt? (messages loaded separately via lazy loading) - `Conversation`: id, userId, createdAt?, updatedAt? (messages loaded separately via lazy loading)
- `CreateUserDTO`: email, userName, password (for registration) - `CreateUserDTO`: email, userName, password (for registration)
- `LoginDTO`: identifier (email OR userName), password - `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` - `GetMessagesOptions`: Cursor-based pagination with `before?: string` and `limit?: number`
- `ConversationSummary`: id, lastMessage?, createdAt? (for conversation list) - `ConversationSummary`: id, lastMessage?, createdAt? (for conversation list)
- `UpdateMessageDTO`: proposalId?, respondedAction? (for marking individual proposals as confirmed/rejected) - `UpdateMessageDTO`: proposalId?, respondedAction? (for marking individual proposals as confirmed/rejected)
@@ -271,6 +376,69 @@ src/
- `Day`: "Monday" | "Tuesday" | ... | "Sunday" - `Day`: "Monday" | "Tuesday" | ... | "Sunday"
- `Month`: "January" | "February" | ... | "December" - `Month`: "January" | "February" | ... | "December"
### AI Context Architecture
The AI assistant fetches calendar data on-demand rather than receiving pre-loaded events. This reduces token usage significantly.
**AIContext Interface:**
```typescript
interface AIContext {
userId: string;
conversationHistory: ChatMessage[]; // Last 20 messages for context
currentDate: Date;
// Callbacks for on-demand data fetching:
fetchEventsInRange: (start: Date, end: Date) => Promise<ExpandedEvent[]>;
searchEvents: (query: string) => Promise<CalendarEvent[]>;
fetchEventById: (eventId: string) => Promise<CalendarEvent | null>;
}
```
**Available AI Tools:**
- `getDay` - Calculate relative dates (e.g., "next Friday")
- `getCurrentDateTime` - Get current timestamp
- `proposeCreateEvent` - Propose new event (includes automatic conflict detection)
- `proposeUpdateEvent` - Propose event modification
- `proposeDeleteEvent` - Propose event deletion (supports recurring delete modes)
- `searchEvents` - Search events by title (returns IDs for update/delete)
- `getEventsInRange` - Load events for a date range (for "what's today?" queries)
**Conflict Detection:**
When creating events, `toolExecutor` automatically:
1. Fetches events for the target day via `fetchEventsInRange`
2. Checks for time overlaps using `occurrenceStart/occurrenceEnd` (important for recurring events)
3. Returns `conflictingEvents` array in the proposal for UI display
4. Adds ⚠️ warning to tool result so AI can inform user
### CalDAV Synchronization
CalDAV sync with external calendar servers (e.g., Radicale) using `tsdav` and `ical.js`.
**Naming Convention:** All CalDAV-related identifiers use `Caldav` (PascalCase) / `caldav` (camelCase), NOT `CalDav`. The only exception is the protocol name "CalDAV" in comments and log messages.
**Sync Triggers (client-side via `CaldavConfigService.sync()`):**
- **Login** (`login.tsx`): After successful authentication
- **Auto-login** (`AuthGuard.tsx`): After `loadStoredUser()` if authenticated
- **Calendar timer** (`calendar.tsx`): Events load instantly from DB on focus (`loadEvents`), CalDAV sync runs in background (`syncAndReload`) and reloads events after. Repeats every 10s via `setInterval`
- **Sync button** (`settings.tsx`): Manual trigger in CaldavSettings
**Lazy sync (server-side in ChatService):**
- AI data access callbacks (`fetchEventsInRange`, `searchEvents`, `fetchEventById`) trigger `syncOnce()` before the first DB query
- Uses `CaldavService.sync()` which checks config internally (silent no-op without config)
**Single-event sync (server-side in controllers):**
- `EventController`: `pushToCaldav()` after create/update, `deleteFromCaldav()` after delete
- `ChatController`: `pushAll()` after confirming an event proposal
**Sync Flow:**
1. `sync()` calls `pushAll` (push unsynced local events) then `pull` (fetch remote events)
2. `pullEvents`: Compares etags to skip unchanged events, creates/updates local events, deletes locally if removed remotely
3. `pushEvent`: Creates or updates remote event, fetches new etag after push
**Architecture:**
- `CaldavService` depends on `CaldavRepository` (config storage) and `EventService` (event CRUD)
- `ChatService` depends on `EventService` and `CaldavService` (lazy CalDAV sync on AI data access)
- `EventController` and `ChatController` both receive `CaldavService` for CalDAV push on mutations
### Database Abstraction ### Database Abstraction
The repository pattern allows swapping databases: The repository pattern allows swapping databases:
@@ -331,7 +499,6 @@ The decorator uses a Proxy to intercept method calls lazily, preserves sync/asyn
**Log Summarization:** **Log Summarization:**
The `@Logged` decorator automatically summarizes large arguments to keep logs readable: The `@Logged` decorator automatically summarizes large arguments to keep logs readable:
- `conversationHistory``"[5 messages]"` - `conversationHistory``"[5 messages]"`
- `existingEvents``"[3 events]"`
- `proposedChanges` → logged in full (for debugging AI issues) - `proposedChanges` → logged in full (for debugging AI issues)
- Long strings (>100 chars) → truncated - Long strings (>100 chars) → truncated
- Arrays → `"[Array(n)]"` - Arrays → `"[Array(n)]"`
@@ -355,7 +522,7 @@ The `@Logged` decorator automatically summarizes large arguments to keep logs re
### Nice-to-Have ### Nice-to-Have
- iCalendar import/export - iCalendar import/export
- Multiple calendars - Multiple calendars
- CalDAV synchronization with external services - ~~CalDAV synchronization with external services~~ (implemented)
## Development Environment ## Development Environment
@@ -368,11 +535,24 @@ docker compose down # Stop services
- MongoDB: `localhost:27017` (root/mongoose) - MongoDB: `localhost:27017` (root/mongoose)
- Mongo Express UI: `localhost:8083` (admin/admin) - Mongo Express UI: `localhost:8083` (admin/admin)
### Radicale CalDAV Server (Docker)
```bash
cd apps/server/docker/radicale
docker compose up -d # Start Radicale CalDAV server
```
- Radicale: `localhost:5232`
### Server Docker Image
```bash
# Build (requires local build context):
docker build -f apps/server/docker/Dockerfile -t calchat-server .
docker run -p 3001:3001 --env-file apps/server/.env calchat-server
```
Multi-stage COPY-based build: copies `package.json` files first for layer caching, then source code. Compiles shared + server, then copies only `dist/` and production dependencies to the runtime stage. Exposes port 3001. In CI, the `plugins/docker` Drone plugin builds and pushes the image automatically.
### Environment Variables ### Environment Variables
Server requires `.env` file in `apps/server/`: Server requires `.env` file in `apps/server/`:
``` ```
JWT_SECRET=your-secret-key
JWT_EXPIRES_IN=1h
MONGODB_URI=mongodb://root:mongoose@localhost:27017/calchat?authSource=admin MONGODB_URI=mongodb://root:mongoose@localhost:27017/calchat?authSource=admin
OPENAI_API_KEY=sk-proj-... OPENAI_API_KEY=sk-proj-...
USE_TEST_RESPONSES=false # true = static test responses, false = real GPT AI USE_TEST_RESPONSES=false # true = static test responses, false = real GPT AI
@@ -393,37 +573,48 @@ NODE_ENV=development # development = pretty logs, production = JSON
- `dotenv` integration for environment variables - `dotenv` integration for environment variables
- `ChatController`: sendMessage(), confirmEvent(), rejectEvent() - `ChatController`: sendMessage(), confirmEvent(), rejectEvent()
- `ChatService`: processMessage() with test responses (create, update, delete actions), confirmEvent() handles all CRUD actions - `ChatService`: processMessage() with test responses (create, update, delete actions), confirmEvent() handles all CRUD actions
- `MongoEventRepository`: Full CRUD implemented (findById, findByUserId, findByDateRange, create, update, delete) - `MongoEventRepository`: Full CRUD implemented (findById, findByUserId, findByDateRange, create, update, delete, addExceptionDate)
- `EventController`: Full CRUD (create, getById, getAll, getByDateRange, update, delete) - `EventController`: Full CRUD (create, getById, getAll, getByDateRange, update, delete)
- `EventService`: Full CRUD with recurring event expansion via recurrenceExpander - `EventService`: Full CRUD with recurring event expansion via recurrenceExpander, deleteRecurring() with three modes (single/future/all)
- `utils/eventFormatters`: getWeeksOverview(), getMonthOverview() with German localization - `utils/eventFormatters`: getWeeksOverview(), getMonthOverview() with German localization
- `utils/recurrenceExpander`: expandRecurringEvents() using rrule library for RRULE parsing - `utils/recurrenceExpander`: expandRecurringEvents() using rrule library for RRULE parsing
- `ChatController`: getConversations(), getConversation() with cursor-based pagination support - `ChatController`: getConversations(), getConversation() with cursor-based pagination support
- `ChatService`: getConversations(), getConversation(), processMessage() uses real AI or test responses (via USE_TEST_RESPONSES), confirmEvent()/rejectEvent() update respondedAction and persist response messages - `ChatService`: getConversations(), getConversation(), processMessage() uses real AI or test responses (via USE_TEST_RESPONSES), confirmEvent()/rejectEvent() update respondedAction and persist response messages
- `MongoChatRepository`: Full CRUD implemented (getConversationsByUser, createConversation, getMessages with cursor pagination, createMessage, updateMessage, updateProposalResponse) - `MongoChatRepository`: Full CRUD implemented (getConversationsByUser, createConversation, getMessages with cursor pagination, createMessage, updateMessage, updateProposalResponse, updateProposalEvent)
- `ChatRepository` interface: updateMessage() and updateProposalResponse() for per-proposal respondedAction tracking - `ChatRepository` interface: updateMessage(), updateProposalResponse(), updateProposalEvent() for per-proposal tracking
- `GPTAdapter`: Full implementation with OpenAI GPT (gpt-4o-mini model), function calling for calendar operations, collects multiple proposals per response - `GPTAdapter`: Full implementation with OpenAI GPT (gpt-4o-mini model), function calling for calendar operations, collects multiple proposals per response
- `ai/utils/`: Provider-agnostic shared utilities (systemPrompt, toolDefinitions, toolExecutor, eventFormatter) - `ai/utils/`: Provider-agnostic shared utilities (systemPrompt, toolDefinitions, toolExecutor)
- `ai/utils/systemPrompt`: Includes RRULE documentation - AI knows to create separate events when times differ by day - `ai/utils/systemPrompt`: AI fetches events on-demand (no pre-loaded context), includes RRULE documentation, warns AI not to put RRULE in description field, instructs AI not to show event IDs to users
- `utils/recurrenceExpander`: Handles RRULE parsing, strips `RRULE:` prefix if present (AI may include it) - `ai/utils/toolDefinitions`: proposeUpdateEvent supports `recurrenceRule` parameter, getEventsInRange tool for on-demand event loading
- `ai/utils/toolExecutor`: Async execution, conflict detection uses `occurrenceStart/occurrenceEnd` for recurring events, returns `conflictingEvents` in proposals
- `MongoEventRepository`: Includes `searchByTitle()` for case-insensitive title search
- `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 - `logging/`: Structured logging with pino, pino-http middleware, @Logged decorator
- All repositories and GPTAdapter decorated with @Logged for automatic method logging - All repositories and GPTAdapter decorated with @Logged for automatic method logging
- `CaldavService`: Full CalDAV sync (connect, pullEvents, pushEvent, pushAll, deleteEvent, sync, getConfig, saveConfig, deleteConfig). `sync()` checks config internally and is a silent no-op without config.
- `CaldavController`: REST endpoints for config CRUD, pull, push
- `MongoCaldavRepository`: Config persistence with createOrUpdate, findByUserId, deleteByUserId
- `EventController`: CalDAV push on create/update, CalDAV delete on delete (via pushToCaldav/deleteFromCaldav helpers)
- `ChatController`: CalDAV pushAll after confirmEvent (ensures chat-created events sync)
- `ChatService`: Uses EventService + CaldavService (lazy sync on AI data access via syncOnce pattern)
- `EventService`: Extended with searchByTitle(), findByCaldavUUID()
- `utils/eventFormatters`: Refactored to use EventService instead of EventRepository
- CORS configured to allow X-User-Id header - CORS configured to allow X-User-Id header
- **Stubbed (TODO):**
- `AuthController`: refresh(), logout()
- `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, CaldavConfig, CaldavSyncStatus defined and exported
- `rruleHelpers.ts`: `parseRRule()` parses RRULE strings using rrule library, returns `ParsedRRule` with freq, until, count, interval, byDay. `buildRRule()` builds RRULE from RepeatType + interval. `formatRecurrenceRule()` formats RRULE into German description (e.g., "Jede Woche", "Alle 2 Monate"). Exports `REPEAT_TYPE_LABELS` and `RepeatType`.
- `formatters.ts`: German date/time formatters (`formatDate`, `formatTime`, `formatDateTime`, `formatDateWithWeekday`, `formatDateKey`) used by both client and server
- rrule library added as dependency for RRULE parsing
**Frontend:** **Frontend:**
- **Authentication fully implemented:** - **Authentication fully implemented:**
- `AuthStore`: Manages user state with expo-secure-store (native) / localStorage (web) - `AuthStore`: Manages user state with expo-secure-store (native) / localStorage (web)
- `AuthService`: login(), register(), logout() - calls backend API - `AuthService`: login(), register(), logout() - calls backend API
- `ApiClient`: Automatically injects X-User-Id header for authenticated requests, handles empty responses (204) - `ApiClient`: Automatically injects X-User-Id header for authenticated requests, handles empty responses (204)
- `AuthGuard`: Reusable component that wraps protected routes - loads user, shows loading, redirects if unauthenticated - `AuthGuard`: Reusable component that wraps protected routes - loads user, preloads app data (events + CalDAV config) into stores before dismissing spinner, triggers CalDAV sync, shows loading, redirects if unauthenticated. Exports `preloadAppData()` (also called by `login.tsx`)
- Login screen: Supports email OR userName login - Login screen: Supports email OR userName login, uses CustomTextInput with focus border, preloads app data + triggers CalDAV sync after successful login
- Register screen: Email validation, checks for existing email/userName - Register screen: Email validation, checks for existing email/userName, uses CustomTextInput with focus border
- `AuthButton`: Reusable button component with themed shadow - `AuthButton`: Reusable button component with themed shadow
- `Header`: Themed header component (logout moved to Settings) - `Header`: Themed header component (logout moved to Settings)
- `(tabs)/_layout.tsx`: Wraps tabs with AuthGuard for protected access - `(tabs)/_layout.tsx`: Wraps tabs with AuthGuard for protected access
@@ -432,7 +623,7 @@ NODE_ENV=development # development = pretty logs, production = JSON
- `ThemeStore`: Zustand store with theme state and setTheme() - `ThemeStore`: Zustand store with theme state and setTheme()
- `Themes.tsx`: THEMES object with defaultLight/defaultDark variants - `Themes.tsx`: THEMES object with defaultLight/defaultDark variants
- All components use `useThemeStore()` for reactive theme colors - All components use `useThemeStore()` for reactive theme colors
- Settings screen with theme switcher (light/dark) - Settings screen with theme switcher (light/dark) and CalDAV configuration (url, username, password with save/sync buttons, loads existing config on mount). Save/Sync buttons show independent feedback via `FeedbackRow` component: spinner + loading text during request, then success (green) or error (red) message that auto-clears after 3s. Both feedbacks can be visible simultaneously.
- `BaseButton`: Reusable themed button component - `BaseButton`: Reusable themed button component
- Tab navigation (Chat, Calendar, Settings) implemented with themed UI - Tab navigation (Chat, Calendar, Settings) implemented with themed UI
- Calendar screen fully functional: - Calendar screen fully functional:
@@ -442,7 +633,9 @@ NODE_ENV=development # development = pretty logs, production = JSON
- Orange dot indicator for days with events - Orange dot indicator for days with events
- Tap-to-open modal overlay showing EventCards for selected day - Tap-to-open modal overlay showing EventCards for selected day
- Supports events from adjacent months visible in grid - Supports events from adjacent months visible in grid
- Uses `useFocusEffect` for automatic reload on tab focus - Events load instantly from local DB on tab focus, CalDAV sync runs non-blocking in background (`syncAndReload`) with 10s interval
- DeleteEventModal integration for recurring event deletion with three modes
- EventOverlay hides when DeleteEventModal is open (fixes modal stacking on web)
- Chat screen fully functional with FlashList, message sending, and event confirm/reject - Chat screen fully functional with FlashList, message sending, and event confirm/reject
- **Multiple event proposals**: AI can propose multiple events in one response - **Multiple event proposals**: AI can propose multiple events in one response
- Arrow navigation between proposals with "Event X von Y" counter - Arrow navigation between proposals with "Event X von Y" counter
@@ -452,20 +645,33 @@ NODE_ENV=development # development = pretty logs, production = JSON
- Tracks conversationId for message continuity across sessions - Tracks conversationId for message continuity across sessions
- ChatStore with addMessages() for bulk loading, chatMessageToMessageData() helper - ChatStore with addMessages() for bulk loading, chatMessageToMessageData() helper
- KeyboardAvoidingView for proper keyboard handling (iOS padding, Android height) - KeyboardAvoidingView for proper keyboard handling (iOS padding, Android height)
- Auto-scroll to end on new messages and keyboard show - Auto-scroll to end on new messages and keyboard show; initial load uses `onContentSizeChange` with `animated: false` to start at bottom without visible scrolling
- keyboardDismissMode="interactive" and keyboardShouldPersistTaps="handled" - keyboardDismissMode="interactive" and keyboardShouldPersistTaps="handled"
- `EventService`: getAll(), getById(), getByDateRange(), create(), update(), delete() - fully implemented - `EventService`: getAll(), getById(), getByDateRange(), create(), update(), delete(mode, occurrenceDate) - fully implemented with recurring delete modes
- `ChatService`: sendMessage(), confirmEvent(), rejectEvent(), getConversations(), getConversation() - fully implemented with cursor pagination - `ChatService`: sendMessage(), confirmEvent(deleteMode, occurrenceDate), rejectEvent(), getConversations(), getConversation(), updateProposalEvent() - fully implemented with cursor pagination, recurring delete support, and proposal editing
- `EventCardBase`: Shared base component with event layout (header, date/time/recurring icons, description) - used by both EventCard and ProposedEventCard - `CaldavConfigService`: saveConfig(), getConfig(), deleteConfig(), pull(), pushAll(), sync() - CalDAV config management and sync trigger
- `EventCard`: Uses EventCardBase + edit/delete buttons for calendar display - `CustomTextInput`: Themed text input with focus border highlight. Props: `text`, `onValueChange`, `placeholder`, `placeholderTextColor`, `secureTextEntry`, `autoCapitalize`, `keyboardType`, `className`, `multiline`. No default padding — callers must set padding via `className` (e.g., `px-3 py-2` or `p-4`). When not focused, cursor is reset to start (`selection={{ start: 0 }}`) to avoid text appearing scrolled to the end.
- `ProposedEventCard`: Uses EventCardBase + confirm/reject buttons for chat proposals (supports create/update/delete actions) - `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. Accepts `recurrenceRule` string (not boolean) and displays German-formatted recurrence via `formatRecurrenceRule()`
- `EventCard`: Uses EventCardBase + edit/delete buttons (TouchableOpacity with delayPressIn for scroll-friendly touch handling)
- `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), shows yellow conflict warnings when proposed time overlaps with existing events. 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
- `CalendarToolbar` (in calendar.tsx): Toolbar between header and weekdays with Sync button (CalDAV sync with spinner/green checkmark/red X feedback, disabled without config) and Logout button
- `EventOverlay` (in calendar.tsx): Day events overlay using ModalBase - shows EventCards for selected day
- `Themes.tsx`: Theme definitions with THEMES object (defaultLight, defaultDark) including all color tokens (textPrimary, borderPrimary, eventIndicator, secondaryBg, shadowColor, etc.) - `Themes.tsx`: Theme definitions with THEMES object (defaultLight, defaultDark) including all color tokens (textPrimary, borderPrimary, eventIndicator, secondaryBg, shadowColor, etc.)
- `EventsStore`: Zustand store with setEvents(), addEvent(), updateEvent(), deleteEvent() - stores ExpandedEvent[] - `EventsStore`: Zustand store with setEvents(), addEvent(), updateEvent(), deleteEvent() - stores ExpandedEvent[], preloaded by AuthGuard
- `CaldavConfigStore`: Zustand store with config (CaldavConfig | null), setConfig() - cached CalDAV config, preloaded by AuthGuard, used by Settings to avoid API call on mount
- `ChatStore`: Zustand store with addMessage(), addMessages(), updateMessage(), clearMessages(), isWaitingForResponse/setWaitingForResponse() for typing indicator - loads from server on mount and persists across tab switches - `ChatStore`: Zustand store with addMessage(), addMessages(), updateMessage(), clearMessages(), isWaitingForResponse/setWaitingForResponse() for typing indicator - loads from server on mount and persists across tab switches
- `ThemeStore`: Zustand store with theme/setTheme() for reactive theme switching across all components - `ThemeStore`: Zustand store with theme/setTheme() for reactive theme switching across all components
- `ChatBubble`: Reusable chat bubble component with Tailwind styling, used by ChatMessage and TypingIndicator - `ChatBubble`: Reusable chat bubble component with Tailwind styling, used by ChatMessage and TypingIndicator
- `TypingIndicator`: Animated typing indicator component showing `. → .. → ...` loop while waiting for AI response - `TypingIndicator`: Animated typing indicator component showing `. → .. → ...` loop while waiting for AI response
- Event Detail and Note screens exist as skeletons - Event Detail and Note screens exist as skeletons
- `editEvent.tsx`: Dual-mode event editor screen
- **Calendar mode**: Edit existing events, create new events - calls EventService API
- **Chat mode**: Edit AI-proposed events before confirming - updates ChatStore locally and persists to server via ChatService.updateProposalEvent()
- Route params: `mode` ('calendar' | 'chat'), `id?`, `date?`, `eventData?` (JSON), `proposalContext?` (JSON with messageId, proposalId, conversationId)
- Supports recurring events with RRULE configuration (daily/weekly/monthly/yearly)
## Building ## Building
@@ -490,6 +696,29 @@ This uses the `preview` profile from `eas.json` which builds an APK with:
- Package name: `com.gilmour109.calchat` - Package name: `com.gilmour109.calchat`
- EAS Project ID: `b722dde6-7d89-48ff-9095-e007e7c7da87` - EAS Project ID: `b722dde6-7d89-48ff-9095-e007e7c7da87`
## CI/CD (Drone)
The project uses Drone CI (`.drone.yml`) with five pipelines:
**On push to main:**
1. **`server_build_and_test`**: Builds the server (`npm ci` + `npm run build`) and runs Jest tests (`npm run test`)
2. **`check_for_formatting`**: Checks Prettier formatting across all workspaces (`npm run check_format`)
3. **`deploy_latest`**: Builds Docker image, pushes to Gitea Container Registry (`gitea.gilmour109.de/gilmour109/calchat-server:latest`), then SSHs into VPS (`10.0.0.1`) to pull and restart via `docker compose`. Depends on both pipelines above passing first.
**On tag:**
4. **`upload_tag`**: Builds Docker image tagged with the git tag (`${DRONE_TAG}`), pushes to registry, then deploys to k3s cluster (`192.168.178.201`) via SSH using `envsubst` with a Kubernetes manifest template.
**On promote:**
5. **`upload_commit`**: Builds Docker image tagged with short commit SHA (first 8 chars), pushes to registry, then deploys to k3s cluster (`192.168.178.201`) via SSH using `envsubst` with a Kubernetes manifest template.
## Testing
Server uses Jest with ts-jest for unit testing. Config in `apps/server/jest.config.js` ignores `/node_modules/` and `/dist/`.
**Existing tests:**
- `src/utils/password.test.ts` - Tests for bcrypt hash() and compare()
- `src/utils/recurrenceExpander.test.ts` - Tests for expandRecurringEvents() (non-recurring, weekly/daily/UNTIL recurrence, EXDATE filtering, RRULE: prefix stripping, invalid RRULE fallback, multi-day events, sorting)
## Documentation ## Documentation
Detailed architecture diagrams are in `docs/`: Detailed architecture diagrams are in `docs/`:

145
README.md
View File

@@ -1,50 +1,141 @@
# Welcome to your Expo app 👋 # CalChat
This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app). Kalender-App mit KI-Chatbot. Termine lassen sich per Chat in natuerlicher Sprache erstellen, bearbeiten und loeschen.
## Get started ## Tech Stack
1. Install dependencies | Bereich | Technologie |
|---------|-------------|
| Frontend | React Native, Expo, Expo-Router, NativeWind, Zustand |
| Backend | Express.js, MongoDB, Mongoose, OpenAI GPT |
| Shared | TypeScript Monorepo mit npm Workspaces |
| Optional | CalDAV-Sync (z.B. Radicale) |
## Voraussetzungen
- Node.js (>= 20)
- npm
- Docker & Docker Compose (fuer MongoDB)
- OpenAI API Key (fuer KI-Chat)
- Android SDK + Java (nur fuer APK-Build)
## Projekt aufsetzen
### 1. Repository klonen
```bash
git clone <repo-url>
cd calchat
```
### 2. Dependencies installieren
```bash ```bash
npm install npm install
``` ```
2. Start the app Installiert alle Dependencies fuer Client, Server und Shared.
### 3. MongoDB starten
```bash ```bash
npx expo start cd apps/server/docker/mongo
docker compose up -d
``` ```
In the output, you'll find options to open the app in a - MongoDB: `localhost:27017` (root/mongoose)
- Mongo Express UI: `localhost:8083` (admin/admin)
- [development build](https://docs.expo.dev/develop/development-builds/introduction/) ### 4. Server konfigurieren
- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
## Get a fresh project
When you're ready, run:
```bash ```bash
npm run reset-project cp apps/server/.env.example apps/server/.env
``` ```
This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing. `apps/server/.env` bearbeiten:
## Learn more ```env
MONGODB_URI=mongodb://root:mongoose@localhost:27017/calchat?authSource=admin
OPENAI_API_KEY=sk-proj-... # Eigenen Key eintragen
USE_TEST_RESPONSES=false # true = statische Testantworten ohne GPT
LOG_LEVEL=debug
NODE_ENV=development
PORT=3000
```
To learn more about developing your project with Expo, look at the following resources: ### 5. Client konfigurieren
- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides). ```bash
- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web. cp apps/client/.env.example apps/client/.env
```
## Join the community `apps/client/.env` bearbeiten:
Join our community of developers creating universal apps. ```env
# Fuer Emulator/Web:
EXPO_PUBLIC_API_URL=http://localhost:3000/api
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute. # Fuer physisches Geraet im gleichen Netzwerk:
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions. EXPO_PUBLIC_API_URL=http://<DEINE-LOKALE-IP>:3000/api
```
### 6. Server starten
```bash
npm run dev -w @calchat/server
```
Startet den Server auf Port 3000 (mit `tsx watch` - startet bei Dateiänderungen automatisch neu (oder sollte es zumindest)).
### 7. Client starten
```bash
npm run start -w @calchat/client
```
Dann im Expo-Menue die gewuenschte Plattform waehlen:
- `a` - Android Emulator
- `i` - iOS Simulator
- `w` - Web Browser
Oder direkt:
```bash
npm run android -w @calchat/client
npm run ios -w @calchat/client
npm run web -w @calchat/client
```
## CalDAV (optional)
Fuer CalDAV-Synchronisation kann ein Radicale-Server gestartet werden:
```bash
cd apps/server/docker/radicale
docker compose up -d
```
Radicale ist dann unter `localhost:5232` erreichbar. Die CalDAV-Verbindung wird in der App unter Einstellungen konfiguriert.
## Weitere Befehle
```bash
npm run format # Prettier auf alle TS/TSX-Dateien
npm run lint -w @calchat/client # ESLint (Client)
npm run build -w @calchat/server # TypeScript kompilieren (Server)
npm run build:apk -w @calchat/client # APK lokal bauen (EAS)
```
## Projektstruktur
```
calchat/
├── apps/
│ ├── client/ # Expo React Native App
│ └── server/ # Express.js Backend
│ └── docker/
│ ├── mongo/ # MongoDB + Mongo Express
│ └── radicale/ # CalDAV Server
└── packages/
└── shared/ # Geteilte Types und Utilities
```

8
apps/client/.env.example Normal file
View File

@@ -0,0 +1,8 @@
# Base URL of the CalChat API server
# Must include the /api path suffix
# Use your local network IP for mobile device testing, or localhost for emulator/web
# Examples:
# http://192.168.178.22:3001/api (local network, for physical device)
# http://localhost:3001/api (emulator or web)
# https://calchat.example.com/api (production)
EXPO_PUBLIC_API_URL=http://localhost:3001/api

View File

@@ -15,7 +15,8 @@
"withoutCredentials": true "withoutCredentials": true
}, },
"env": { "env": {
"ORG_GRADLE_PROJECT_reactNativeArchitectures": "arm64-v8a" "ORG_GRADLE_PROJECT_reactNativeArchitectures": "arm64-v8a",
"EXPO_PUBLIC_API_URL": "https://calchat.gilmour109.de/api"
} }
}, },
"production": { "production": {

View File

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

View File

@@ -46,8 +46,8 @@ export const THEMES = {
messageBorderBg: "#3A3430", messageBorderBg: "#3A3430",
placeholderBg: "#4A4440", placeholderBg: "#4A4440",
calenderBg: "#3D2A1A", calenderBg: "#3D2A1A",
confirmButton: "#22c55e", confirmButton: "#136e34",
rejectButton: "#ef4444", rejectButton: "#bd1010",
disabledButton: "#555", disabledButton: "#555",
buttonText: "#FFFFFF", buttonText: "#FFFFFF",
textPrimary: "#FFFFFF", textPrimary: "#FFFFFF",
@@ -56,5 +56,5 @@ export const THEMES = {
eventIndicator: "#DE6C20", eventIndicator: "#DE6C20",
borderPrimary: "#FFFFFF", borderPrimary: "#FFFFFF",
shadowColor: "#FFFFFF", shadowColor: "#FFFFFF",
} },
} as const satisfies Record<string, Theme>; } as const satisfies Record<string, Theme>;

View File

@@ -1,29 +1,26 @@
import { ActivityIndicator, Pressable, Text, View } from "react-native";
import { import {
Animated, DAYS,
Modal, MONTHS,
Pressable, Month,
Text, ExpandedEvent,
View, RecurringDeleteMode,
ScrollView, } from "@calchat/shared";
Alert,
} from "react-native";
import { DAYS, MONTHS, Month, ExpandedEvent } from "@calchat/shared";
import Header from "../../components/Header"; import Header from "../../components/Header";
import { EventCard } from "../../components/EventCard"; import { EventCard } from "../../components/EventCard";
import React, { import { DeleteEventModal } from "../../components/DeleteEventModal";
useCallback, import { ModalBase } from "../../components/ModalBase";
useEffect, import { ScrollableDropdown } from "../../components/ScrollableDropdown";
useMemo, import React, { useCallback, useEffect, useMemo, useState } from "react";
useRef, import { router, useFocusEffect } from "expo-router";
useState,
} from "react";
import { useFocusEffect } from "expo-router";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { useThemeStore } from "../../stores/ThemeStore"; import { useThemeStore } from "../../stores/ThemeStore";
import BaseBackground from "../../components/BaseBackground"; import BaseBackground from "../../components/BaseBackground";
import { FlashList } from "@shopify/flash-list"; import { AuthService, EventService } from "../../services";
import { EventService } from "../../services";
import { useEventsStore } from "../../stores"; import { useEventsStore } from "../../stores";
import { useDropdownPosition } from "../../hooks/useDropdownPosition";
import { CaldavConfigService } from "../../services/CaldavConfigService";
import { useCaldavConfigStore } from "../../stores/CaldavConfigStore";
// MonthSelector types and helpers // MonthSelector types and helpers
type MonthItem = { type MonthItem = {
@@ -74,14 +71,18 @@ const Calendar = () => {
const [monthIndex, setMonthIndex] = useState(new Date().getMonth()); const [monthIndex, setMonthIndex] = useState(new Date().getMonth());
const [currentYear, setCurrentYear] = useState(new Date().getFullYear()); const [currentYear, setCurrentYear] = useState(new Date().getFullYear());
const [selectedDate, setSelectedDate] = useState<Date | null>(null); const [selectedDate, setSelectedDate] = useState<Date | null>(null);
const [overlayVisible, setOverlayVisible] = useState(false);
// State for delete modal
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const [eventToDelete, setEventToDelete] = useState<ExpandedEvent | null>(
null,
);
const { events, setEvents, deleteEvent } = useEventsStore(); const { events, setEvents, deleteEvent } = useEventsStore();
// Load events when tab gains focus or month/year changes // Load events from local DB (fast, no network sync)
// Include days from prev/next month that are visible in the grid const loadEvents = useCallback(async () => {
useFocusEffect(
useCallback(() => {
const loadEvents = async () => {
try { try {
// Calculate first visible day (up to 6 days before month start) // Calculate first visible day (up to 6 days before month start)
const firstOfMonth = new Date(currentYear, monthIndex, 1); const firstOfMonth = new Date(currentYear, monthIndex, 1);
@@ -106,19 +107,41 @@ const Calendar = () => {
} catch (error) { } catch (error) {
console.error("Failed to load events:", error); console.error("Failed to load events:", error);
} }
}; }, [monthIndex, currentYear, setEvents]);
// Load events from DB on focus
useFocusEffect(
useCallback(() => {
loadEvents(); loadEvents();
}, [monthIndex, currentYear, setEvents]), }, [loadEvents]),
);
// Re-open overlay after back navigation from editEvent
useFocusEffect(
useCallback(() => {
if (selectedDate) {
setOverlayVisible(true);
}
}, [selectedDate]),
); );
// Group events by date (YYYY-MM-DD format) // Group events by date (YYYY-MM-DD format)
// Multi-day events are added to all days they span
const eventsByDate = useMemo(() => { const eventsByDate = useMemo(() => {
const map = new Map<string, ExpandedEvent[]>(); const map = new Map<string, ExpandedEvent[]>();
events.forEach((e) => { events.forEach((e) => {
const date = new Date(e.occurrenceStart); const start = new Date(e.occurrenceStart);
const key = getDateKey(date); const end = new Date(e.occurrenceEnd);
// Iterate through each day the event spans
const current = new Date(start);
current.setHours(0, 0, 0, 0);
while (current <= end) {
const key = getDateKey(current);
if (!map.has(key)) map.set(key, []); if (!map.has(key)) map.set(key, []);
map.get(key)!.push(e); map.get(key)!.push(e);
current.setDate(current.getDate() + 1);
}
}); });
return map; return map;
}, [events]); }, [events]);
@@ -138,46 +161,68 @@ const Calendar = () => {
}); });
}; };
const handleDayPress = (date: Date, hasEvents: boolean) => { const handleDayPress = (date: Date) => {
if (hasEvents) {
setSelectedDate(date); setSelectedDate(date);
} setOverlayVisible(true);
}; };
const handleCloseOverlay = () => { const handleCloseOverlay = () => {
setSelectedDate(null); setSelectedDate(null);
setOverlayVisible(false);
}; };
const handleEditEvent = (event: ExpandedEvent) => { const handleCreateEvent = () => {
console.log("Edit event:", event.id); setOverlayVisible(false);
// TODO: Navigate to event edit screen router.push({
pathname: "/editEvent",
params: { date: selectedDate?.toISOString() },
});
}; };
const handleDeleteEvent = async (event: ExpandedEvent) => { const handleEditEvent = (event?: ExpandedEvent) => {
Alert.alert("Event löschen", `"${event.title}" wirklich löschen?`, [ router.push({
{ text: "Abbrechen", style: "cancel" }, pathname: "/editEvent",
{ params: {
text: "Löschen", mode: "calendar",
style: "destructive", id: event?.id,
onPress: async () => { },
});
};
const handleDeleteEvent = (event: ExpandedEvent) => {
// Show delete modal for both recurring and non-recurring events
setEventToDelete(event);
setDeleteModalVisible(true);
};
const handleDeleteConfirm = async (mode: RecurringDeleteMode) => {
if (!eventToDelete) return;
setDeleteModalVisible(false);
const event = eventToDelete;
const occurrenceDate = getDateKey(new Date(event.occurrenceStart));
try { try {
if (event.recurrenceRule) {
// Recurring event: use mode and occurrenceDate
await EventService.delete(event.id, mode, occurrenceDate);
// Reload events to reflect changes
await loadEvents();
} else {
// Non-recurring event: simple delete
await EventService.delete(event.id); await EventService.delete(event.id);
deleteEvent(event.id); deleteEvent(event.id);
// Close overlay if no more events for this date
if (selectedDate) {
const dateKey = getDateKey(selectedDate);
const remainingEvents = eventsByDate.get(dateKey) || [];
if (remainingEvents.length <= 1) {
setSelectedDate(null);
}
} }
} catch (error) { } catch (error) {
console.error("Failed to delete event:", error); console.error("Failed to delete event:", error);
Alert.alert("Fehler", "Event konnte nicht gelöscht werden");
} }
}, // Note: Don't clear eventToDelete here - it will be overwritten when opening a new modal.
}, // Clearing it during fade-out animation causes the modal content to flash from recurring to single.
]); };
const handleDeleteCancel = () => {
setDeleteModalVisible(false);
// Note: Don't clear eventToDelete - keeps modal content stable during fade-out animation
}; };
// Get events for selected date // Get events for selected date
@@ -196,6 +241,7 @@ const Calendar = () => {
setMonthIndex={setMonthIndex} setMonthIndex={setMonthIndex}
setYear={setCurrentYear} setYear={setCurrentYear}
/> />
<CalendarToolbar loadEvents={loadEvents} />
<WeekDaysLine /> <WeekDaysLine />
<CalendarGrid <CalendarGrid
month={MONTHS[monthIndex]} month={MONTHS[monthIndex]}
@@ -203,15 +249,21 @@ const Calendar = () => {
eventsByDate={eventsByDate} eventsByDate={eventsByDate}
onDayPress={handleDayPress} onDayPress={handleDayPress}
/> />
{/* Event Overlay Modal */}
<EventOverlay <EventOverlay
visible={selectedDate !== null} visible={overlayVisible && !deleteModalVisible}
date={selectedDate} date={selectedDate}
events={selectedDateEvents} events={selectedDateEvents}
onClose={handleCloseOverlay} onClose={handleCloseOverlay}
onEditEvent={handleEditEvent} onEditEvent={handleEditEvent}
onDeleteEvent={handleDeleteEvent} onDeleteEvent={handleDeleteEvent}
onCreateEvent={handleCreateEvent}
/>
<DeleteEventModal
visible={deleteModalVisible}
eventTitle={eventToDelete?.title || ""}
isRecurring={!!eventToDelete?.recurrenceRule}
onConfirm={handleDeleteConfirm}
onCancel={handleDeleteCancel}
/> />
</BaseBackground> </BaseBackground>
); );
@@ -222,8 +274,9 @@ type EventOverlayProps = {
date: Date | null; date: Date | null;
events: ExpandedEvent[]; events: ExpandedEvent[];
onClose: () => void; onClose: () => void;
onEditEvent: (event: ExpandedEvent) => void; onEditEvent: (event?: ExpandedEvent) => void;
onDeleteEvent: (event: ExpandedEvent) => void; onDeleteEvent: (event: ExpandedEvent) => void;
onCreateEvent: () => void;
}; };
const EventOverlay = ({ const EventOverlay = ({
@@ -233,8 +286,10 @@ const EventOverlay = ({
onClose, onClose,
onEditEvent, onEditEvent,
onDeleteEvent, onDeleteEvent,
onCreateEvent,
}: EventOverlayProps) => { }: EventOverlayProps) => {
const { theme } = useThemeStore(); const { theme } = useThemeStore();
if (!date) return null; if (!date) return null;
const dateString = date.toLocaleDateString("de-DE", { const dateString = date.toLocaleDateString("de-DE", {
@@ -244,44 +299,32 @@ const EventOverlay = ({
year: "numeric", year: "numeric",
}); });
return ( const subtitle = `${events.length} ${events.length === 1 ? "Termin" : "Termine"}`;
<Modal
visible={visible}
transparent={true}
animationType="fade"
onRequestClose={onClose}
>
<Pressable
className="flex-1 justify-center items-center"
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
onPress={onClose}
>
<Pressable
className="w-11/12 max-h-3/4 rounded-2xl overflow-hidden"
style={{
backgroundColor: theme.primeBg,
borderWidth: 4,
borderColor: theme.borderPrimary,
}}
onPress={(e) => e.stopPropagation()}
>
{/* Header */}
<View
className="px-4 py-3"
style={{
backgroundColor: theme.chatBot,
borderBottomWidth: 3,
borderBottomColor: theme.borderPrimary,
}}
>
<Text className="font-bold text-lg" style={{ color: theme.textPrimary }}>{dateString}</Text>
<Text style={{ color: theme.textPrimary }}>
{events.length} {events.length === 1 ? "Termin" : "Termine"}
</Text>
</View>
{/* Events List */} const addEventAttachment = (
<ScrollView className="p-4" style={{ maxHeight: 400 }}> <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}
>
{events.map((event, index) => ( {events.map((event, index) => (
<EventCard <EventCard
key={`${event.id}-${index}`} key={`${event.id}-${index}`}
@@ -290,24 +333,7 @@ const EventOverlay = ({
onDelete={() => onDeleteEvent(event)} onDelete={() => onDeleteEvent(event)}
/> />
))} ))}
</ScrollView> </ModalBase>
{/* Close button */}
<Pressable
onPress={onClose}
className="py-3 items-center"
style={{
borderTopWidth: 1,
borderTopColor: theme.placeholderBg,
}}
>
<Text style={{ color: theme.primeFg }} className="font-bold">
Schließen
</Text>
</Pressable>
</Pressable>
</Pressable>
</Modal>
); );
}; };
@@ -320,6 +346,8 @@ type MonthSelectorProps = {
onSelectMonth: (year: number, monthIndex: number) => void; onSelectMonth: (year: number, monthIndex: number) => void;
}; };
const INITIAL_RANGE = 12; // 12 months before and after current
const MonthSelector = ({ const MonthSelector = ({
modalVisible, modalVisible,
onClose, onClose,
@@ -328,14 +356,10 @@ const MonthSelector = ({
currentMonthIndex, currentMonthIndex,
onSelectMonth, onSelectMonth,
}: MonthSelectorProps) => { }: MonthSelectorProps) => {
const { theme } = useThemeStore();
const heightAnim = useRef(new Animated.Value(0)).current;
const listRef = useRef<React.ComponentRef<typeof FlashList<MonthItem>>>(null);
const INITIAL_RANGE = 12; // 12 months before and after current
const [monthSelectorData, setMonthSelectorData] = useState<MonthItem[]>([]); const [monthSelectorData, setMonthSelectorData] = useState<MonthItem[]>([]);
const appendMonths = (direction: "start" | "end", count: number) => { const appendMonths = useCallback(
(direction: "start" | "end", count: number) => {
setMonthSelectorData((prevData) => { setMonthSelectorData((prevData) => {
if (prevData.length === 0) return prevData; if (prevData.length === 0) return prevData;
@@ -375,86 +399,55 @@ const MonthSelector = ({
? [...newMonths, ...prevData] ? [...newMonths, ...prevData]
: [...prevData, ...newMonths]; : [...prevData, ...newMonths];
}); });
}; },
[],
);
// Generate fresh data when modal opens, clear when closes
useEffect(() => { useEffect(() => {
if (modalVisible) { if (modalVisible) {
// Generate fresh data centered on current month
setMonthSelectorData( setMonthSelectorData(
generateMonths(currentYear, currentMonthIndex, INITIAL_RANGE), generateMonths(currentYear, currentMonthIndex, INITIAL_RANGE),
); );
Animated.timing(heightAnim, {
toValue: 200,
duration: 200,
useNativeDriver: false,
}).start();
} else { } else {
heightAnim.setValue(0);
// Clear data when closing
setMonthSelectorData([]); setMonthSelectorData([]);
} }
}, [modalVisible, heightAnim, currentYear, currentMonthIndex]); }, [modalVisible, currentYear, currentMonthIndex]);
const renderItem = ({ item }: { item: MonthItem }) => ( const handleSelect = useCallback(
<Pressable (item: MonthItem) => {
onPress={() => {
onSelectMonth(item.year, item.monthIndex); onSelectMonth(item.year, item.monthIndex);
onClose(); onClose();
}} },
> [onSelectMonth, onClose],
);
return (
<ScrollableDropdown
visible={modalVisible}
onClose={onClose}
position={position}
data={monthSelectorData}
keyExtractor={(item) => item.id}
renderItem={(item, theme) => (
<View <View
className="w-full flex justify-center items-center py-2" className="w-full flex justify-center items-center py-2"
style={{ style={{
backgroundColor: backgroundColor:
item.monthIndex % 2 === 0 item.monthIndex % 2 === 0 ? theme.primeBg : theme.secondaryBg,
? theme.primeBg
: theme.secondaryBg,
}} }}
> >
<Text className="text-xl" style={{ color: theme.primeFg }}> <Text className="text-xl" style={{ color: theme.primeFg }}>
{item.label} {item.label}
</Text> </Text>
</View> </View>
</Pressable> )}
); onSelect={handleSelect}
height={200}
return (
<Modal
visible={modalVisible}
transparent={true}
animationType="none"
onRequestClose={onClose}
>
<Pressable className="flex-1 rounded-lg" onPress={onClose}>
<Animated.View
className="absolute overflow-hidden"
style={{
top: position.top,
left: position.left,
width: position.width,
height: heightAnim,
backgroundColor: theme.primeBg,
borderWidth: 2,
borderColor: theme.borderPrimary,
borderRadius: 8,
}}
>
<FlashList
className="w-full"
style={{ borderRadius: 8 }}
ref={listRef}
keyExtractor={(item) => item.id}
data={monthSelectorData}
initialScrollIndex={INITIAL_RANGE} initialScrollIndex={INITIAL_RANGE}
onEndReachedThreshold={0.5}
onEndReached={() => appendMonths("end", 12)} onEndReached={() => appendMonths("end", 12)}
onStartReachedThreshold={0.5}
onStartReached={() => appendMonths("start", 12)} onStartReached={() => appendMonths("start", 12)}
renderItem={renderItem}
/> />
</Animated.View>
</Pressable>
</Modal>
); );
}; };
@@ -468,29 +461,16 @@ type CalendarHeaderProps = {
const CalendarHeader = (props: CalendarHeaderProps) => { const CalendarHeader = (props: CalendarHeaderProps) => {
const { theme } = useThemeStore(); const { theme } = useThemeStore();
const [modalVisible, setModalVisible] = useState(false); const dropdown = useDropdownPosition();
const [dropdownPosition, setDropdownPosition] = useState({
top: 0,
left: 0,
width: 0,
});
const containerRef = useRef<View>(null);
const prevMonth = () => props.changeMonth(-1); const prevMonth = () => props.changeMonth(-1);
const nextMonth = () => props.changeMonth(1); const nextMonth = () => props.changeMonth(1);
const measureAndOpen = () => {
containerRef.current?.measureInWindow((x, y, width, height) => {
setDropdownPosition({ top: y + height, left: x, width });
setModalVisible(true);
});
};
return ( return (
<Header className="flex flex-row items-center justify-between"> <Header className="flex flex-row items-center justify-between">
<ChangeMonthButton onPress={prevMonth} icon="chevron-back" /> <ChangeMonthButton onPress={prevMonth} icon="chevron-back" />
<View <View
ref={containerRef} ref={dropdown.ref}
className="relative flex flex-row items-center justify-around" className="relative flex flex-row items-center justify-around"
> >
<Text className="text-4xl px-1" style={{ color: theme.textPrimary }}> <Text className="text-4xl px-1" style={{ color: theme.textPrimary }}>
@@ -509,19 +489,15 @@ const CalendarHeader = (props: CalendarHeaderProps) => {
// Android shadow // Android shadow
elevation: 6, elevation: 6,
}} }}
onPress={measureAndOpen} onPress={dropdown.open}
> >
<Ionicons <Ionicons name="chevron-down" size={28} color={theme.primeFg} />
name="chevron-down"
size={28}
color={theme.primeFg}
/>
</Pressable> </Pressable>
</View> </View>
<MonthSelector <MonthSelector
modalVisible={modalVisible} modalVisible={dropdown.visible}
onClose={() => setModalVisible(false)} onClose={dropdown.close}
position={dropdownPosition} position={dropdown.position}
currentYear={props.currentYear} currentYear={props.currentYear}
currentMonthIndex={props.monthIndex} currentMonthIndex={props.monthIndex}
onSelectMonth={(year, month) => { onSelectMonth={(year, month) => {
@@ -570,13 +546,140 @@ const ChangeMonthButton = (props: ChangeMonthButtonProps) => {
); );
}; };
type CalendarToolbarProps = {
loadEvents: () => Promise<void>;
};
const CalendarToolbar = ({ loadEvents }: CalendarToolbarProps) => {
const { theme } = useThemeStore();
const { config } = useCaldavConfigStore();
const [isSyncing, setIsSyncing] = useState(false);
const [syncResult, setSyncResult] = useState<"success" | "error" | null>(
null,
);
const handleSync = async () => {
if (!config || isSyncing) return;
setSyncResult(null);
setIsSyncing(true);
try {
await CaldavConfigService.sync();
await loadEvents();
setSyncResult("success");
} catch (error) {
console.error("CalDAV sync failed:", error);
setSyncResult("error");
} finally {
setIsSyncing(false);
}
};
useEffect(() => {
if (!syncResult) return;
const timer = setTimeout(() => setSyncResult(null), 3000);
return () => clearTimeout(timer);
}, [syncResult]);
const handleLogout = async () => {
await AuthService.logout();
router.replace("/login");
};
const syncIcon = () => {
if (isSyncing) {
return <ActivityIndicator size="small" color={theme.primeFg} />;
}
if (syncResult === "success") {
return (
<Ionicons
name="checkmark-circle"
size={20}
color={theme.confirmButton}
/>
);
}
if (syncResult === "error") {
return (
<Ionicons name="close-circle" size={20} color={theme.rejectButton} />
);
}
return (
<Ionicons
name="sync-outline"
size={20}
color={config ? theme.primeFg : theme.textMuted}
/>
);
};
const buttonStyle = {
backgroundColor: theme.chatBot,
borderColor: theme.borderPrimary,
borderWidth: 1,
shadowColor: theme.shadowColor,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 4,
elevation: 4,
};
const className = "flex flex-row items-center gap-2 px-3 py-1 rounded-lg";
return (
<View
className="flex flex-row items-center justify-around py-2"
style={{
backgroundColor: theme.primeBg,
borderBottomWidth: 0,
borderBottomColor: theme.borderPrimary,
}}
>
<Pressable
onPress={handleSync}
disabled={!config || isSyncing}
className={className}
style={{
...buttonStyle,
...(config
? {}
: {
backgroundColor: theme.disabledButton,
borderColor: theme.disabledButton,
}),
}}
>
{syncIcon()}
<Text
style={{ color: config ? theme.textPrimary : theme.textMuted }}
className="font-medium"
>
Sync
</Text>
</Pressable>
<Pressable
onPress={handleLogout}
className={className}
style={buttonStyle}
>
<Ionicons name="log-out-outline" size={20} color={theme.primeFg} />
<Text style={{ color: theme.textPrimary }} className="font-medium">
Logout
</Text>
</Pressable>
</View>
);
};
const WeekDaysLine = () => { const WeekDaysLine = () => {
const { theme } = useThemeStore(); const { theme } = useThemeStore();
return ( return (
<View className="flex flex-row items-center justify-around px-2 gap-2"> <View className="flex flex-row items-center justify-around px-2 gap-2">
{/* TODO: px and gap need fine tuning to perfectly align with the grid */} {/* TODO: px and gap need fine tuning to perfectly align with the grid */}
{DAYS.map((day, i) => ( {DAYS.map((day, i) => (
<Text key={i} style={{ color: theme.textPrimary }}>{day.substring(0, 2).toUpperCase()}</Text> <Text key={i} style={{ color: theme.textPrimary }}>
{day.substring(0, 2).toUpperCase()}
</Text>
))} ))}
</View> </View>
); );
@@ -586,7 +689,7 @@ type CalendarGridProps = {
month: Month; month: Month;
year: number; year: number;
eventsByDate: Map<string, ExpandedEvent[]>; eventsByDate: Map<string, ExpandedEvent[]>;
onDayPress: (date: Date, hasEvents: boolean) => void; onDayPress: (date: Date) => void;
}; };
const CalendarGrid = (props: CalendarGridProps) => { const CalendarGrid = (props: CalendarGridProps) => {
@@ -627,7 +730,7 @@ const CalendarGrid = (props: CalendarGridProps) => {
date={date} date={date}
month={props.month} month={props.month}
hasEvents={hasEvents} hasEvents={hasEvents}
onPress={() => props.onDayPress(date, hasEvents)} onPress={() => props.onDayPress(date)}
/> />
); );
})} })}

View File

@@ -9,7 +9,7 @@ import {
} from "react-native"; } from "react-native";
import { useThemeStore } from "../../stores/ThemeStore"; import { useThemeStore } from "../../stores/ThemeStore";
import React, { useState, useRef, useEffect, useCallback } from "react"; import React, { useState, useRef, useEffect, useCallback } from "react";
import { useFocusEffect } from "expo-router"; import { useFocusEffect, router } from "expo-router";
import Header from "../../components/Header"; import Header from "../../components/Header";
import BaseBackground from "../../components/BaseBackground"; import BaseBackground from "../../components/BaseBackground";
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
@@ -20,7 +20,7 @@ import {
chatMessageToMessageData, chatMessageToMessageData,
MessageData, MessageData,
} from "../../stores"; } from "../../stores";
import { ProposedEventChange } from "@calchat/shared"; import { ProposedEventChange, RespondedAction } from "@calchat/shared";
import { ProposedEventCard } from "../../components/ProposedEventCard"; import { ProposedEventCard } from "../../components/ProposedEventCard";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import TypingIndicator from "../../components/TypingIndicator"; import TypingIndicator from "../../components/TypingIndicator";
@@ -38,6 +38,7 @@ type ChatMessageProps = {
proposedChanges?: ProposedEventChange[]; proposedChanges?: ProposedEventChange[];
onConfirm?: (proposalId: string, proposal: ProposedEventChange) => void; onConfirm?: (proposalId: string, proposal: ProposedEventChange) => void;
onReject?: (proposalId: string) => void; onReject?: (proposalId: string) => void;
onEdit?: (proposalId: string, proposal: ProposedEventChange) => void;
}; };
type ChatInputProps = { type ChatInputProps = {
@@ -62,19 +63,21 @@ const Chat = () => {
const [currentConversationId, setCurrentConversationId] = useState< const [currentConversationId, setCurrentConversationId] = useState<
string | undefined string | undefined
>(); >();
const [hasLoadedMessages, setHasLoadedMessages] = useState(false);
const needsInitialScroll = useRef(false);
useEffect(() => { useEffect(() => {
const keyboardDidShow = Keyboard.addListener( const keyboardDidShow = Keyboard.addListener("keyboardDidShow", () =>
"keyboardDidShow", scrollToEnd(),
scrollToEnd,
); );
return () => keyboardDidShow.remove(); return () => keyboardDidShow.remove();
}, []); }, []);
// Load existing messages from database once authenticated and screen is focused // Load existing messages from database only once (on initial mount)
// Skip on subsequent focus events to preserve local edits (e.g., edited proposals)
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
if (isAuthLoading || !isAuthenticated) return; if (isAuthLoading || !isAuthenticated || hasLoadedMessages) return;
const fetchMessages = async () => { const fetchMessages = async () => {
try { try {
@@ -87,24 +90,26 @@ const Chat = () => {
await ChatService.getConversation(conversationId); await ChatService.getConversation(conversationId);
const clientMessages = serverMessages.map(chatMessageToMessageData); const clientMessages = serverMessages.map(chatMessageToMessageData);
addMessages(clientMessages); addMessages(clientMessages);
scrollToEnd(); needsInitialScroll.current = true;
} }
} catch (error) { } catch (error) {
console.error("Failed to load messages:", error); console.error("Failed to load messages:", error);
} finally {
setHasLoadedMessages(true);
} }
}; };
fetchMessages(); fetchMessages();
}, [isAuthLoading, isAuthenticated]), }, [isAuthLoading, isAuthenticated, hasLoadedMessages]),
); );
const scrollToEnd = () => { const scrollToEnd = (animated = true) => {
setTimeout(() => { setTimeout(() => {
listRef.current?.scrollToEnd({ animated: true }); listRef.current?.scrollToEnd({ animated });
}, 100); }, 100);
}; };
const handleEventResponse = async ( const handleEventResponse = async (
action: "confirm" | "reject", action: RespondedAction,
messageId: string, messageId: string,
conversationId: string, conversationId: string,
proposalId: string, proposalId: string,
@@ -114,7 +119,7 @@ const Chat = () => {
const message = messages.find((m) => m.id === messageId); const message = messages.find((m) => m.id === messageId);
if (message?.proposedChanges) { if (message?.proposedChanges) {
const updatedProposals = message.proposedChanges.map((p) => const updatedProposals = message.proposedChanges.map((p) =>
p.id === proposalId ? { ...p, respondedAction: action as "confirm" | "reject" } : p, p.id === proposalId ? { ...p, respondedAction: action } : p,
); );
updateMessage(messageId, { proposedChanges: updatedProposals }); updateMessage(messageId, { proposedChanges: updatedProposals });
} }
@@ -130,8 +135,14 @@ const Chat = () => {
proposedChange.event, proposedChange.event,
proposedChange.eventId, proposedChange.eventId,
proposedChange.updates, proposedChange.updates,
proposedChange.deleteMode,
proposedChange.occurrenceDate,
) )
: await ChatService.rejectEvent(conversationId, messageId, proposalId); : await ChatService.rejectEvent(
conversationId,
messageId,
proposalId,
);
const botMessage: MessageData = { const botMessage: MessageData = {
id: response.message.id, id: response.message.id,
@@ -153,6 +164,26 @@ const Chat = () => {
} }
}; };
const handleEditProposal = (
messageId: string,
conversationId: string,
proposalId: string,
proposal: ProposedEventChange,
) => {
router.push({
pathname: "/editEvent",
params: {
mode: "chat",
eventData: JSON.stringify(proposal.event),
proposalContext: JSON.stringify({
messageId,
proposalId,
conversationId,
}),
},
});
};
const handleSend = async (text: string) => { const handleSend = async (text: string) => {
// Show user message immediately // Show user message immediately
const userMessage: MessageData = { const userMessage: MessageData = {
@@ -167,6 +198,7 @@ const Chat = () => {
// Show typing indicator after delay // Show typing indicator after delay
typingTimeoutRef.current = setTimeout(() => { typingTimeoutRef.current = setTimeout(() => {
setWaitingForResponse(true); setWaitingForResponse(true);
scrollToEnd();
}, TYPING_INDICATOR_DELAY_MS); }, TYPING_INDICATOR_DELAY_MS);
try { try {
@@ -225,14 +257,35 @@ const Chat = () => {
) )
} }
onReject={(proposalId) => onReject={(proposalId) =>
handleEventResponse("reject", item.id, item.conversationId!, proposalId) handleEventResponse(
"reject",
item.id,
item.conversationId!,
proposalId,
)
}
onEdit={(proposalId, proposal) =>
handleEditProposal(
item.id,
item.conversationId!,
proposalId,
proposal,
)
} }
/> />
)} )}
keyExtractor={(item) => item.id} keyExtractor={(item) => item.id}
keyboardDismissMode="interactive" keyboardDismissMode="interactive"
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
ListFooterComponent={isWaitingForResponse ? <TypingIndicator /> : null} onContentSizeChange={() => {
if (needsInitialScroll.current) {
needsInitialScroll.current = false;
listRef.current?.scrollToEnd({ animated: false });
}
}}
ListFooterComponent={
isWaitingForResponse ? <TypingIndicator /> : null
}
/> />
<ChatInput onSend={handleSend} /> <ChatInput onSend={handleSend} />
</KeyboardAvoidingView> </KeyboardAvoidingView>
@@ -251,7 +304,9 @@ const ChatHeader = () => {
borderColor: theme.primeFg, borderColor: theme.primeFg,
}} }}
></View> ></View>
<Text className="text-lg pl-3" style={{ color: theme.textPrimary }}>CalChat</Text> <Text className="text-lg pl-3" style={{ color: theme.textPrimary }}>
CalChat
</Text>
<View <View
className="h-2 bg-black" className="h-2 bg-black"
style={{ style={{
@@ -319,6 +374,7 @@ const ChatMessage = ({
proposedChanges, proposedChanges,
onConfirm, onConfirm,
onReject, onReject,
onEdit,
}: ChatMessageProps) => { }: ChatMessageProps) => {
const { theme } = useThemeStore(); const { theme } = useThemeStore();
const [currentIndex, setCurrentIndex] = useState(0); const [currentIndex, setCurrentIndex] = useState(0);
@@ -329,9 +385,7 @@ const ChatMessage = ({
const goToPrev = () => setCurrentIndex((i) => Math.max(0, i - 1)); const goToPrev = () => setCurrentIndex((i) => Math.max(0, i - 1));
const goToNext = () => const goToNext = () =>
setCurrentIndex((i) => setCurrentIndex((i) => Math.min((proposedChanges?.length || 1) - 1, i + 1));
Math.min((proposedChanges?.length || 1) - 1, i + 1),
);
const canGoPrev = currentIndex > 0; const canGoPrev = currentIndex > 0;
const canGoNext = currentIndex < (proposedChanges?.length || 1) - 1; const canGoNext = currentIndex < (proposedChanges?.length || 1) - 1;
@@ -344,9 +398,11 @@ const ChatMessage = ({
minWidth: hasProposals ? "75%" : undefined, minWidth: hasProposals ? "75%" : undefined,
}} }}
> >
<Text className="p-2" style={{ color: theme.textPrimary }}>{content}</Text> <Text className="p-2" style={{ color: theme.textPrimary }}>
{content}
</Text>
{hasProposals && currentProposal && onConfirm && onReject && ( {hasProposals && currentProposal && onConfirm && onReject && onEdit && (
<View> <View>
{/* Event card with optional navigation arrows */} {/* Event card with optional navigation arrows */}
<View className="flex-row items-center"> <View className="flex-row items-center">
@@ -358,11 +414,7 @@ const ChatMessage = ({
className="p-1" className="p-1"
style={{ opacity: canGoPrev ? 1 : 0.3 }} style={{ opacity: canGoPrev ? 1 : 0.3 }}
> >
<Ionicons <Ionicons name="chevron-back" size={24} color={theme.primeFg} />
name="chevron-back"
size={24}
color={theme.primeFg}
/>
</Pressable> </Pressable>
)} )}
@@ -370,8 +422,9 @@ const ChatMessage = ({
<View className="flex-1"> <View className="flex-1">
<ProposedEventCard <ProposedEventCard
proposedChange={currentProposal} proposedChange={currentProposal}
onConfirm={() => onConfirm(currentProposal.id, currentProposal)} onConfirm={(proposal) => onConfirm(proposal.id, proposal)}
onReject={() => onReject(currentProposal.id)} onReject={() => onReject(currentProposal.id)}
onEdit={(proposal) => onEdit(proposal.id, proposal)}
/> />
</View> </View>

View File

@@ -1,33 +1,223 @@
import { Text, View } from "react-native"; import { ActivityIndicator, Text, View } from "react-native";
import BaseBackground from "../../components/BaseBackground"; import BaseBackground from "../../components/BaseBackground";
import BaseButton from "../../components/BaseButton"; import BaseButton, { BaseButtonProps } from "../../components/BaseButton";
import { useThemeStore } from "../../stores/ThemeStore"; import { useThemeStore } from "../../stores/ThemeStore";
import { AuthService } from "../../services/AuthService"; import { AuthService } from "../../services/AuthService";
import { router } from "expo-router"; import { router } from "expo-router";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import Header from "../../components/Header"; import { SimpleHeader } from "../../components/Header";
import { THEMES } from "../../Themes"; import { THEMES } from "../../Themes";
import CustomTextInput from "../../components/CustomTextInput";
import { useCallback, useRef, useState } from "react";
import { CaldavConfigService } from "../../services/CaldavConfigService";
import { useCaldavConfigStore } from "../../stores";
const handleLogout = async () => { const handleLogout = async () => {
await AuthService.logout(); await AuthService.logout();
router.replace("/login"); router.replace("/login");
}; };
const SettingsButton = (props: BaseButtonProps) => {
return (
<BaseButton
onPress={props.onPress}
solid={props.solid}
className={"w-11/12"}
>
{props.children}
</BaseButton>
);
};
type CaldavTextInputProps = {
title: string;
value: string;
onValueChange: (text: string) => void;
secureTextEntry?: boolean;
};
const CaldavTextInput = ({
title,
value,
onValueChange,
secureTextEntry,
}: CaldavTextInputProps) => {
const { theme } = useThemeStore();
return (
<View className="flex flex-row items-center py-1">
<Text className="ml-4 w-24" style={{ color: theme.textPrimary }}>
{title}:
</Text>
<CustomTextInput
className="flex-1 mr-4 px-3 py-2"
text={value}
onValueChange={onValueChange}
secureTextEntry={secureTextEntry}
/>
</View>
);
};
type Feedback = { text: string; isError: boolean; loading: boolean };
const FeedbackRow = ({ feedback }: { feedback: Feedback | null }) => {
const { theme } = useThemeStore();
if (!feedback) return null;
return (
<View className="flex flex-row items-center justify-center mt-2 mx-4 gap-2">
{feedback.loading && (
<ActivityIndicator size="small" color={theme.textMuted} />
)}
<Text
style={{
color: feedback.loading
? theme.textMuted
: feedback.isError
? theme.rejectButton
: theme.confirmButton,
}}
>
{feedback.text}
</Text>
</View>
);
};
const CaldavSettings = () => {
const { theme } = useThemeStore();
const { config, setConfig } = useCaldavConfigStore();
const [serverUrl, setServerUrl] = useState(config?.serverUrl ?? "");
const [username, setUsername] = useState(config?.username ?? "");
const [password, setPassword] = useState(config?.password ?? "");
const [saveFeedback, setSaveFeedback] = useState<Feedback | null>(null);
const [syncFeedback, setSyncFeedback] = useState<Feedback | null>(null);
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const syncTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const showFeedback = useCallback(
(
setter: typeof setSaveFeedback,
timer: typeof saveTimer,
text: string,
isError: boolean,
loading = false,
) => {
if (timer.current) clearTimeout(timer.current);
setter({ text, isError, loading });
if (!loading) {
timer.current = setTimeout(() => setter(null), 3000);
}
},
[],
);
const saveConfig = async () => {
showFeedback(
setSaveFeedback,
saveTimer,
"Speichere Konfiguration...",
false,
true,
);
try {
const saved = await CaldavConfigService.saveConfig(
serverUrl,
username,
password,
);
setConfig(saved);
showFeedback(
setSaveFeedback,
saveTimer,
"Konfiguration wurde gespeichert",
false,
);
} catch {
showFeedback(
setSaveFeedback,
saveTimer,
"Fehler beim Speichern der Konfiguration",
true,
);
}
};
const sync = async () => {
showFeedback(setSyncFeedback, syncTimer, "Synchronisiere...", false, true);
try {
await CaldavConfigService.sync();
showFeedback(
setSyncFeedback,
syncTimer,
"Synchronisierung erfolgreich",
false,
);
} catch {
showFeedback(
setSyncFeedback,
syncTimer,
"Fehler beim Synchronisieren",
true,
);
}
};
return (
<>
<View>
<Text
className="text-center text-2xl"
style={{ color: theme.textPrimary }}
>
Caldav Config
</Text>
</View>
<View>
<View className="pb-1">
<CaldavTextInput
title="url"
value={serverUrl}
onValueChange={setServerUrl}
/>
<CaldavTextInput
title="username"
value={username}
onValueChange={setUsername}
/>
<CaldavTextInput
title="password"
value={password}
onValueChange={setPassword}
secureTextEntry
/>
</View>
<View className="flex flex-row">
<BaseButton className="mx-4 w-1/5" solid={true} onPress={saveConfig}>
Save
</BaseButton>
<BaseButton className="w-1/5" solid={true} onPress={sync}>
Sync
</BaseButton>
</View>
<FeedbackRow feedback={saveFeedback} />
<FeedbackRow feedback={syncFeedback} />
</View>
</>
);
};
const Settings = () => { const Settings = () => {
const { theme, setTheme } = useThemeStore(); const { theme, setTheme } = useThemeStore();
return ( return (
<BaseBackground> <BaseBackground>
<Header> <SimpleHeader text="Settings" />
<View className="h-full flex justify-center">
<Text className="text-center text-3xl font-bold">Settings</Text>
</View>
</Header>
<View className="flex items-center mt-4"> <View className="flex items-center mt-4">
<BaseButton onPress={handleLogout} solid={true}> <SettingsButton onPress={handleLogout} solid={true}>
<Ionicons name="log-out-outline" size={24} color={theme.primeFg} />{" "} <Ionicons name="log-out-outline" size={24} color={theme.primeFg} />{" "}
Logout Logout
</BaseButton> </SettingsButton>
<View> <View>
<Text <Text
className="text-center text-2xl" className="text-center text-2xl"
@@ -36,23 +226,24 @@ const Settings = () => {
Select Theme Select Theme
</Text> </Text>
</View> </View>
<BaseButton <SettingsButton
solid={theme == THEMES.defaultLight} solid={theme == THEMES.defaultLight}
onPress={() => { onPress={() => {
setTheme("defaultLight"); setTheme("defaultLight");
}} }}
> >
Default Light Default Light
</BaseButton> </SettingsButton>
<BaseButton <SettingsButton
solid={theme == THEMES.defaultDark} solid={theme == THEMES.defaultDark}
onPress={() => { onPress={() => {
setTheme("defaultDark"); setTheme("defaultDark");
}} }}
> >
Default Dark Default Dark
</BaseButton> </SettingsButton>
</View> </View>
<CaldavSettings />
</BaseBackground> </BaseBackground>
); );
}; };

View File

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

View File

@@ -0,0 +1,573 @@
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,
REPEAT_TYPE_LABELS,
RepeatType,
} from "@calchat/shared";
import { useChatStore } from "../stores";
import CustomTextInput, {
CustomTextInputProps,
} from "../components/CustomTextInput";
type EditEventTextFieldProps = CustomTextInputProps & {
titel: string;
};
const EditEventTextField = (props: EditEventTextFieldProps) => {
const { theme } = useThemeStore();
return (
<View className={props.className}>
<Text className="text-xl" style={{ color: theme.textPrimary }}>
{props.titel}
</Text>
<CustomTextInput
className="flex-1 px-3 py-2"
text={props.text}
multiline={props.multiline}
onValueChange={props.onValueChange}
/>
</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 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 on server and sync response to local store
if (mode === "chat" && proposalContext) {
try {
const context = JSON.parse(proposalContext) as ProposalContext;
// Persist to server - returns updated message with recalculated conflictingEvents
const updatedMessage = await ChatService.updateProposalEvent(
context.messageId,
context.proposalId,
eventObject,
);
// Update local ChatStore with server response (includes updated conflicts)
if (updatedMessage?.proposedChanges) {
updateMessage(context.messageId, {
proposedChanges: updatedMessage.proposedChanges,
});
}
router.back();
} catch (error) {
console.error("Failed to update proposal:", error);
}
return;
}
// Calendar mode: call API
try {
if (id) {
await EventService.update(id, eventObject);
} else {
await EventService.create(eventObject);
}
router.back();
} catch (error) {
console.error("Creating/Updating event failed!", error);
}
};
const getButtonText = () => {
if (mode === "chat") {
return "Fertig";
}
return id ? "Aktualisiere Termin" : "Erstelle neuen Termin";
};
return (
<BaseBackground>
<EditEventHeader id={id} mode={mode} />
<View className="h-full flex items-center">
{/* Date and Time */}
<View className="w-11/12">
<EditEventTextField
className="h-16 mt-2"
titel="Titel"
text={title}
onValueChange={setTitle}
/>
<PickerRow
title="Von"
dateValue={startDate}
onDateChange={handleStartDateChange}
onTimeChange={handleStartTimeChange}
showLabels
/>
<PickerRow
title="Bis"
dateValue={endDate}
onDateChange={handleEndDateChange}
onTimeChange={handleEndTimeChange}
/>
{/* TODO: Reminder */}
{/* Notes */}
<EditEventTextField
className="h-64 mt-6"
titel="Notizen"
text={description}
onValueChange={setDescription}
multiline
/>
{/* Repeat Toggle Button */}
<Pressable
className="flex flex-row w-1/3 h-10 mt-4 rounded-lg items-center justify-evenly"
style={{
backgroundColor: repeatVisible
? theme.chatBot
: theme.secondaryBg,
borderWidth: 1,
borderColor: theme.borderPrimary,
}}
onPress={() => setRepeatVisible(!repeatVisible)}
>
<Ionicons
name="repeat"
size={24}
color={repeatVisible ? theme.buttonText : theme.textPrimary}
/>
<Text
style={{
color: repeatVisible ? theme.buttonText : theme.textPrimary,
}}
>
Wiederholen
</Text>
</Pressable>
{/* Repeat Selector (shown when toggle is active) */}
{repeatVisible && (
<RepeatSelector
repeatCount={repeatCount}
onRepeatCountChange={setRepeatCount}
repeatType={repeatType}
onRepeatTypeChange={setRepeatType}
/>
)}
</View>
</View>
{/* Send new or updated Event */}
<View className="absolute bottom-16 w-full h-16">
<Pressable
className="flex flex-row justify-center items-center py-3"
onPress={handleSave}
style={{
backgroundColor: theme.confirmButton,
}}
>
{mode !== "chat" && (
<Ionicons name="add-outline" size={24} color={theme.buttonText} />
)}
<Text
style={{ color: theme.buttonText }}
className="font-semibold ml-1"
>
{getButtonText()}
</Text>
</Pressable>
</View>
</BaseBackground>
);
};
export default EditEventScreen;

View File

@@ -19,27 +19,49 @@ const EventDetailScreen = () => {
return ( return (
<BaseBackground> <BaseBackground>
<View className="flex-1 p-4"> <View className="flex-1 p-4">
<Text className="text-2xl mb-4" style={{ color: theme.textPrimary }}>Event Detail</Text> <Text className="text-2xl mb-4" style={{ color: theme.textPrimary }}>
<Text className="mb-4" style={{ color: theme.textSecondary }}>ID: {id}</Text> Event Detail
</Text>
<Text className="mb-4" style={{ color: theme.textSecondary }}>
ID: {id}
</Text>
<TextInput <TextInput
placeholder="Title" placeholder="Title"
placeholderTextColor={theme.textMuted} placeholderTextColor={theme.textMuted}
className="w-full border rounded p-2 mb-4" className="w-full border rounded p-2 mb-4"
style={{ color: theme.textPrimary, borderColor: theme.borderPrimary, backgroundColor: theme.secondaryBg }} style={{
color: theme.textPrimary,
borderColor: theme.borderPrimary,
backgroundColor: theme.secondaryBg,
}}
/> />
<TextInput <TextInput
placeholder="Description" placeholder="Description"
placeholderTextColor={theme.textMuted} placeholderTextColor={theme.textMuted}
multiline multiline
className="w-full border rounded p-2 mb-4 h-24" className="w-full border rounded p-2 mb-4 h-24"
style={{ color: theme.textPrimary, borderColor: theme.borderPrimary, backgroundColor: theme.secondaryBg }} style={{
color: theme.textPrimary,
borderColor: theme.borderPrimary,
backgroundColor: theme.secondaryBg,
}}
/> />
<View className="flex-row gap-2"> <View className="flex-row gap-2">
<Pressable className="p-3 rounded flex-1" style={{ backgroundColor: theme.confirmButton }}> <Pressable
<Text className="text-center" style={{ color: theme.buttonText }}>Save</Text> className="p-3 rounded flex-1"
style={{ backgroundColor: theme.confirmButton }}
>
<Text className="text-center" style={{ color: theme.buttonText }}>
Save
</Text>
</Pressable> </Pressable>
<Pressable className="p-3 rounded flex-1" style={{ backgroundColor: theme.rejectButton }}> <Pressable
<Text className="text-center" style={{ color: theme.buttonText }}>Delete</Text> className="p-3 rounded flex-1"
style={{ backgroundColor: theme.rejectButton }}
>
<Text className="text-center" style={{ color: theme.buttonText }}>
Delete
</Text>
</Pressable> </Pressable>
</View> </View>
</View> </View>

View File

@@ -1,9 +1,12 @@
import { useState } from "react"; import { useState } from "react";
import { View, Text, TextInput, Pressable } from "react-native"; import { View, Text, Pressable } from "react-native";
import { Link, router } from "expo-router"; import { Link, router } from "expo-router";
import BaseBackground from "../components/BaseBackground"; import BaseBackground from "../components/BaseBackground";
import AuthButton from "../components/AuthButton"; import AuthButton from "../components/AuthButton";
import CustomTextInput from "../components/CustomTextInput";
import { AuthService } from "../services"; import { AuthService } from "../services";
import { CaldavConfigService } from "../services/CaldavConfigService";
import { preloadAppData } from "../components/AuthGuard";
import { useThemeStore } from "../stores/ThemeStore"; import { useThemeStore } from "../stores/ThemeStore";
const LoginScreen = () => { const LoginScreen = () => {
@@ -24,6 +27,12 @@ const LoginScreen = () => {
setIsLoading(true); setIsLoading(true);
try { try {
await AuthService.login({ identifier, password }); await AuthService.login({ identifier, password });
await preloadAppData();
try {
await CaldavConfigService.sync();
} catch {
// No CalDAV config or sync failed — not critical
}
router.replace("/(tabs)/chat"); router.replace("/(tabs)/chat");
} catch { } catch {
setError("Anmeldung fehlgeschlagen. Überprüfe deine Zugangsdaten."); setError("Anmeldung fehlgeschlagen. Überprüfe deine Zugangsdaten.");
@@ -43,39 +52,30 @@ const LoginScreen = () => {
</Text> </Text>
{error && ( {error && (
<Text className="mb-4 text-center" style={{ color: theme.rejectButton }}> <Text
className="mb-4 text-center"
style={{ color: theme.rejectButton }}
>
{error} {error}
</Text> </Text>
)} )}
<TextInput <CustomTextInput
placeholder="E-Mail oder Benutzername" placeholder="E-Mail oder Benutzername"
placeholderTextColor={theme.textMuted} placeholderTextColor={theme.textMuted}
value={identifier} text={identifier}
onChangeText={setIdentifier} onValueChange={setIdentifier}
autoCapitalize="none" autoCapitalize="none"
className="w-full rounded-lg p-4 mb-4" className="w-full rounded-lg p-4 mb-4"
style={{
backgroundColor: theme.secondaryBg,
color: theme.textPrimary,
borderWidth: 1,
borderColor: theme.borderPrimary,
}}
/> />
<TextInput <CustomTextInput
placeholder="Passwort" placeholder="Passwort"
placeholderTextColor={theme.textMuted} placeholderTextColor={theme.textMuted}
value={password} text={password}
onChangeText={setPassword} onValueChange={setPassword}
secureTextEntry secureTextEntry
className="w-full rounded-lg p-4 mb-6" className="w-full rounded-lg p-4 mb-6"
style={{
backgroundColor: theme.secondaryBg,
color: theme.textPrimary,
borderWidth: 1,
borderColor: theme.borderPrimary,
}}
/> />
<AuthButton <AuthButton

View File

@@ -17,18 +17,31 @@ const NoteScreen = () => {
return ( return (
<BaseBackground> <BaseBackground>
<View className="flex-1 p-4"> <View className="flex-1 p-4">
<Text className="text-2xl mb-4" style={{ color: theme.textPrimary }}>Note</Text> <Text className="text-2xl mb-4" style={{ color: theme.textPrimary }}>
<Text className="mb-4" style={{ color: theme.textSecondary }}>Event ID: {id}</Text> Note
</Text>
<Text className="mb-4" style={{ color: theme.textSecondary }}>
Event ID: {id}
</Text>
<TextInput <TextInput
placeholder="Write your note here..." placeholder="Write your note here..."
placeholderTextColor={theme.textMuted} placeholderTextColor={theme.textMuted}
multiline multiline
className="w-full border rounded p-2 flex-1 mb-4" className="w-full border rounded p-2 flex-1 mb-4"
textAlignVertical="top" textAlignVertical="top"
style={{ color: theme.textPrimary, borderColor: theme.borderPrimary, backgroundColor: theme.secondaryBg }} style={{
color: theme.textPrimary,
borderColor: theme.borderPrimary,
backgroundColor: theme.secondaryBg,
}}
/> />
<Pressable className="p-3 rounded" style={{ backgroundColor: theme.confirmButton }}> <Pressable
<Text className="text-center" style={{ color: theme.buttonText }}>Save Note</Text> className="p-3 rounded"
style={{ backgroundColor: theme.confirmButton }}
>
<Text className="text-center" style={{ color: theme.buttonText }}>
Save Note
</Text>
</Pressable> </Pressable>
</View> </View>
</BaseBackground> </BaseBackground>

View File

@@ -1,8 +1,9 @@
import { useState } from "react"; import { useState } from "react";
import { View, Text, TextInput, Pressable } from "react-native"; import { View, Text, Pressable } from "react-native";
import { Link, router } from "expo-router"; import { Link, router } from "expo-router";
import BaseBackground from "../components/BaseBackground"; import BaseBackground from "../components/BaseBackground";
import AuthButton from "../components/AuthButton"; import AuthButton from "../components/AuthButton";
import CustomTextInput from "../components/CustomTextInput";
import { AuthService } from "../services"; import { AuthService } from "../services";
import { useThemeStore } from "../stores/ThemeStore"; import { useThemeStore } from "../stores/ThemeStore";
@@ -51,55 +52,40 @@ const RegisterScreen = () => {
</Text> </Text>
{error && ( {error && (
<Text className="mb-4 text-center" style={{ color: theme.rejectButton }}> <Text
className="mb-4 text-center"
style={{ color: theme.rejectButton }}
>
{error} {error}
</Text> </Text>
)} )}
<TextInput <CustomTextInput
placeholder="E-Mail" placeholder="E-Mail"
placeholderTextColor={theme.textMuted} placeholderTextColor={theme.textMuted}
value={email} text={email}
onChangeText={setEmail} onValueChange={setEmail}
autoCapitalize="none" autoCapitalize="none"
keyboardType="email-address" keyboardType="email-address"
className="w-full rounded-lg p-4 mb-4" className="w-full rounded-lg p-4 mb-4"
style={{
backgroundColor: theme.secondaryBg,
color: theme.textPrimary,
borderWidth: 1,
borderColor: theme.borderPrimary,
}}
/> />
<TextInput <CustomTextInput
placeholder="Benutzername" placeholder="Benutzername"
placeholderTextColor={theme.textMuted} placeholderTextColor={theme.textMuted}
value={userName} text={userName}
onChangeText={setUserName} onValueChange={setUserName}
autoCapitalize="none" autoCapitalize="none"
className="w-full rounded-lg p-4 mb-4" className="w-full rounded-lg p-4 mb-4"
style={{
backgroundColor: theme.secondaryBg,
color: theme.textPrimary,
borderWidth: 1,
borderColor: theme.borderPrimary,
}}
/> />
<TextInput <CustomTextInput
placeholder="Passwort" placeholder="Passwort"
placeholderTextColor={theme.textMuted} placeholderTextColor={theme.textMuted}
value={password} text={password}
onChangeText={setPassword} onValueChange={setPassword}
secureTextEntry secureTextEntry
className="w-full rounded-lg p-4 mb-6" className="w-full rounded-lg p-4 mb-6"
style={{
backgroundColor: theme.secondaryBg,
color: theme.textPrimary,
borderWidth: 1,
borderColor: theme.borderPrimary,
}}
/> />
<AuthButton <AuthButton

View File

@@ -15,9 +15,7 @@ const AuthButton = ({ title, onPress, isLoading = false }: AuthButtonProps) => {
disabled={isLoading} disabled={isLoading}
className="w-full rounded-lg p-4 mb-4 border-4" className="w-full rounded-lg p-4 mb-4 border-4"
style={{ style={{
backgroundColor: isLoading backgroundColor: isLoading ? theme.disabledButton : theme.chatBot,
? theme.disabledButton
: theme.chatBot,
shadowColor: theme.shadowColor, shadowColor: theme.shadowColor,
shadowOffset: { width: 0, height: 2 }, shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25, shadowOpacity: 0.25,

View File

@@ -1,16 +1,52 @@
import { useEffect, ReactNode } from "react"; import { useEffect, useState, ReactNode } from "react";
import { View, ActivityIndicator } from "react-native"; import { View, ActivityIndicator } from "react-native";
import { Redirect } from "expo-router"; import { Redirect } from "expo-router";
import { useAuthStore } from "../stores"; import { useAuthStore } from "../stores";
import { useThemeStore } from "../stores/ThemeStore"; import { useThemeStore } from "../stores/ThemeStore";
import { useEventsStore } from "../stores/EventsStore";
import { useCaldavConfigStore } from "../stores/CaldavConfigStore";
import { EventService } from "../services";
import { CaldavConfigService } from "../services/CaldavConfigService";
type AuthGuardProps = { type AuthGuardProps = {
children: ReactNode; children: ReactNode;
}; };
/**
* Preloads app data (events + CalDAV config) into stores.
* Called before the loading spinner is dismissed so screens have data immediately.
*/
export const preloadAppData = async () => {
const now = new Date();
const firstOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const dayOfWeek = firstOfMonth.getDay();
const daysFromPrevMonth = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
const startDate = new Date(
now.getFullYear(),
now.getMonth(),
1 - daysFromPrevMonth,
);
const endDate = new Date(startDate);
endDate.setDate(startDate.getDate() + 41);
endDate.setHours(23, 59, 59);
const [eventsResult, configResult] = await Promise.allSettled([
EventService.getByDateRange(startDate, endDate),
CaldavConfigService.getConfig(),
]);
if (eventsResult.status === "fulfilled") {
useEventsStore.getState().setEvents(eventsResult.value);
}
if (configResult.status === "fulfilled") {
useCaldavConfigStore.getState().setConfig(configResult.value);
}
};
/** /**
* Wraps content that requires authentication. * Wraps content that requires authentication.
* - Loads stored user on mount * - Loads stored user on mount
* - Preloads app data (events, CalDAV config) before dismissing spinner
* - Shows loading indicator while checking auth state * - Shows loading indicator while checking auth state
* - Redirects to login if not authenticated * - Redirects to login if not authenticated
* - Renders children if authenticated * - Renders children if authenticated
@@ -18,12 +54,19 @@ type AuthGuardProps = {
export const AuthGuard = ({ children }: AuthGuardProps) => { export const AuthGuard = ({ children }: AuthGuardProps) => {
const { theme } = useThemeStore(); const { theme } = useThemeStore();
const { isAuthenticated, isLoading, loadStoredUser } = useAuthStore(); const { isAuthenticated, isLoading, loadStoredUser } = useAuthStore();
const [dataReady, setDataReady] = useState(false);
useEffect(() => { useEffect(() => {
loadStoredUser(); const init = async () => {
await loadStoredUser();
if (!useAuthStore.getState().isAuthenticated) return;
await preloadAppData();
setDataReady(true);
};
init();
}, [loadStoredUser]); }, [loadStoredUser]);
if (isLoading) { if (isLoading || (isAuthenticated && !dataReady)) {
return ( return (
<View <View
style={{ style={{

View File

@@ -2,23 +2,27 @@ import { Pressable, Text } from "react-native";
import { useThemeStore } from "../stores/ThemeStore"; import { useThemeStore } from "../stores/ThemeStore";
import { ReactNode } from "react"; import { ReactNode } from "react";
type BaseButtonProps = { export type BaseButtonProps = {
children?: ReactNode; children?: ReactNode;
className?: string;
onPress: () => void; onPress: () => void;
solid?: boolean; solid?: boolean;
}; };
const BaseButton = ({children, onPress, solid = false}: BaseButtonProps) => { const BaseButton = ({
className,
children,
onPress,
solid = false,
}: BaseButtonProps) => {
const { theme } = useThemeStore(); const { theme } = useThemeStore();
return ( return (
<Pressable <Pressable
className="w-11/12 rounded-lg p-4 mb-4 border-4" className={`rounded-lg p-4 mb-4 border-4 ${className}`}
onPress={onPress} onPress={onPress}
style={{ style={{
borderColor: theme.borderPrimary, borderColor: theme.borderPrimary,
backgroundColor: solid backgroundColor: solid ? theme.chatBot : theme.primeBg,
? theme.chatBot
: theme.primeBg,
shadowColor: theme.shadowColor, shadowColor: theme.shadowColor,
shadowOffset: { width: 0, height: 2 }, shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25, shadowOpacity: 0.25,

View File

@@ -0,0 +1,120 @@
import { View, Text, Pressable, ScrollView } from "react-native";
import { ReactNode } from "react";
import { useThemeStore } from "../stores/ThemeStore";
type TextSize = "text-sm" | "text-base" | "text-lg" | "text-xl";
type CardBaseProps = {
title: string;
subtitle?: string;
children: ReactNode;
attachment?: ReactNode; // renders between children and footer
footer?: {
label: string;
onPress: () => void;
};
className?: string;
scrollable?: boolean;
maxContentHeight?: number;
borderWidth?: number;
headerBorderWidth?: number;
headerPadding?: number;
contentPadding?: number;
headerTextSize?: TextSize;
contentBg?: string;
};
export const CardBase = ({
title,
subtitle,
children,
attachment,
footer,
className = "",
scrollable = false,
maxContentHeight,
borderWidth = 2,
headerBorderWidth,
headerPadding,
contentPadding,
headerTextSize = "text-base",
contentBg,
}: CardBaseProps) => {
const { theme } = useThemeStore();
const effectiveHeaderBorderWidth = headerBorderWidth ?? borderWidth;
const headerPaddingClass = headerPadding ? `p-${headerPadding}` : "px-3 py-2";
const contentPaddingClass = contentPadding
? `p-${contentPadding}`
: "px-3 py-2";
const contentElement = (
<View
className={contentPaddingClass}
style={{ backgroundColor: contentBg ?? theme.secondaryBg }}
>
{children}
</View>
);
return (
<View
className={`rounded-xl overflow-hidden ${className}`}
style={{ borderWidth, borderColor: theme.borderPrimary }}
>
{/* Header */}
<View
className={headerPaddingClass}
style={{
backgroundColor: theme.chatBot,
borderBottomWidth: effectiveHeaderBorderWidth,
borderBottomColor: theme.borderPrimary,
}}
>
<Text
className={`font-bold ${headerTextSize}`}
style={{ color: theme.textPrimary }}
>
{title}
</Text>
{subtitle && (
<Text style={{ color: theme.primeFg }} numberOfLines={1}>
{subtitle}
</Text>
)}
</View>
{/* Content */}
{scrollable ? (
<ScrollView
style={{ maxHeight: maxContentHeight }}
nestedScrollEnabled={true}
>
{contentElement}
</ScrollView>
) : (
contentElement
)}
{attachment}
{/* Footer (optional) */}
{footer && (
<Pressable
onPress={footer.onPress}
className="py-3 items-center"
style={{
borderTopWidth: 1,
borderTopColor: theme.placeholderBg,
}}
>
<Text style={{ color: theme.primeFg }} className="font-bold">
{footer.label}
</Text>
</Pressable>
)}
</View>
);
};
export default CardBase;

View File

@@ -10,7 +10,12 @@ type ChatBubbleProps = {
style?: ViewStyle; style?: ViewStyle;
}; };
export function ChatBubble({ side, children, className = "", style }: ChatBubbleProps) { export function ChatBubble({
side,
children,
className = "",
style,
}: ChatBubbleProps) {
const { theme } = useThemeStore(); const { theme } = useThemeStore();
const borderColor = side === "left" ? theme.chatBot : theme.primeFg; const borderColor = side === "left" ? theme.chatBot : theme.primeFg;
const sideClass = const sideClass =
@@ -21,7 +26,10 @@ export function ChatBubble({ side, children, className = "", style }: ChatBubble
return ( return (
<View <View
className={`border-2 border-solid rounded-xl my-2 ${sideClass} ${className}`} className={`border-2 border-solid rounded-xl my-2 ${sideClass} ${className}`}
style={[{ borderColor, elevation: 8, backgroundColor: theme.secondaryBg }, style]} style={[
{ borderColor, elevation: 8, backgroundColor: theme.secondaryBg },
style,
]}
> >
{children} {children}
</View> </View>

View File

@@ -0,0 +1,46 @@
import { TextInput, TextInputProps } from "react-native";
import { useThemeStore } from "../stores/ThemeStore";
import { useState } from "react";
export type CustomTextInputProps = {
text?: string;
focused?: boolean;
className?: string;
multiline?: boolean;
onValueChange?: (text: string) => void;
placeholder?: string;
placeholderTextColor?: string;
secureTextEntry?: boolean;
autoCapitalize?: TextInputProps["autoCapitalize"];
keyboardType?: TextInputProps["keyboardType"];
};
const CustomTextInput = (props: CustomTextInputProps) => {
const { theme } = useThemeStore();
const [focused, setFocused] = useState(props.focused ?? false);
return (
<TextInput
className={`border border-solid rounded-2xl ${props.className}`}
onChangeText={props.onValueChange}
value={props.text}
multiline={props.multiline}
placeholder={props.placeholder}
placeholderTextColor={props.placeholderTextColor}
secureTextEntry={props.secureTextEntry}
autoCapitalize={props.autoCapitalize}
keyboardType={props.keyboardType}
selection={!focused ? { start: 0 } : undefined}
style={{
backgroundColor: theme.messageBorderBg,
color: theme.textPrimary,
textAlignVertical: "top",
borderColor: focused ? theme.chatBot : theme.borderPrimary,
}}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
/>
);
};
export default CustomTextInput;

View File

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

View File

@@ -0,0 +1,100 @@
import { Pressable, Text, View } from "react-native";
import { RecurringDeleteMode } from "@calchat/shared";
import { useThemeStore } from "../stores/ThemeStore";
import { ModalBase } from "./ModalBase";
type DeleteEventModalProps = {
visible: boolean;
eventTitle: string;
isRecurring: boolean;
onConfirm: (mode: RecurringDeleteMode) => void;
onCancel: () => void;
};
type DeleteOption = {
mode: RecurringDeleteMode;
label: string;
};
const RECURRING_DELETE_OPTIONS: DeleteOption[] = [
{
mode: "single",
label: "Nur dieses Vorkommen",
},
{
mode: "future",
label: "Dieses und zukünftige",
},
{
mode: "all",
label: "Alle Vorkommen",
},
];
export const DeleteEventModal = ({
visible,
eventTitle,
isRecurring,
onConfirm,
onCancel,
}: DeleteEventModalProps) => {
const { theme } = useThemeStore();
const title = isRecurring
? "Wiederkehrenden Termin löschen"
: "Termin loeschen";
return (
<ModalBase
visible={visible}
onClose={onCancel}
title={title}
subtitle={eventTitle}
footer={{ label: "Abbrechen", onPress: onCancel }}
>
{isRecurring ? (
// Recurring event: show three options
RECURRING_DELETE_OPTIONS.map((option) => (
<Pressable
key={option.mode}
onPress={() => onConfirm(option.mode)}
className="py-3 px-4 rounded-lg mb-2"
style={{
backgroundColor: theme.secondaryBg,
borderWidth: 1,
borderColor: theme.borderPrimary,
}}
>
<Text
className="font-medium text-base"
style={{ color: theme.textPrimary }}
>
{option.label}
</Text>
</Pressable>
))
) : (
// Non-recurring event: simple confirmation
<View>
<Text className="text-base mb-4" style={{ color: theme.textPrimary }}>
Möchtest du diesen Termin wirklich löschen?
</Text>
<Pressable
onPress={() => onConfirm("all")}
className="py-3 px-4 rounded-lg"
style={{
backgroundColor: theme.rejectButton,
}}
>
<Text
className="font-medium text-base text-center"
style={{ color: theme.buttonText }}
>
Löschen
</Text>
</Pressable>
</View>
)}
</ModalBase>
);
};

View File

@@ -1,4 +1,4 @@
import { View, Pressable } from "react-native"; import { View, TouchableOpacity } from "react-native";
import { ExpandedEvent } from "@calchat/shared"; import { ExpandedEvent } from "@calchat/shared";
import { Feather } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import { useThemeStore } from "../stores/ThemeStore"; import { useThemeStore } from "../stores/ThemeStore";
@@ -19,12 +19,14 @@ export const EventCard = ({ event, onEdit, onDelete }: EventCardProps) => {
startTime={event.occurrenceStart} startTime={event.occurrenceStart}
endTime={event.occurrenceEnd} endTime={event.occurrenceEnd}
description={event.description} description={event.description}
isRecurring={event.isRecurring} recurrenceRule={event.recurrenceRule}
> >
{/* Action buttons */} {/* Action buttons - TouchableOpacity with delayPressIn allows ScrollView to detect scroll gestures */}
<View className="flex-row justify-end mt-3 gap-3"> <View className="flex-row justify-end mt-3 gap-3">
<Pressable <TouchableOpacity
onPress={onEdit} onPress={onEdit}
delayPressIn={100}
activeOpacity={0.7}
className="w-10 h-10 rounded-full items-center justify-center" className="w-10 h-10 rounded-full items-center justify-center"
style={{ style={{
borderWidth: 1, borderWidth: 1,
@@ -32,21 +34,19 @@ export const EventCard = ({ event, onEdit, onDelete }: EventCardProps) => {
}} }}
> >
<Feather name="edit-2" size={18} color={theme.textPrimary} /> <Feather name="edit-2" size={18} color={theme.textPrimary} />
</Pressable> </TouchableOpacity>
<Pressable <TouchableOpacity
onPress={onDelete} onPress={onDelete}
delayPressIn={100}
activeOpacity={0.7}
className="w-10 h-10 rounded-full items-center justify-center" className="w-10 h-10 rounded-full items-center justify-center"
style={{ style={{
borderWidth: 1, borderWidth: 1,
borderColor: theme.borderPrimary, borderColor: theme.borderPrimary,
}} }}
> >
<Feather <Feather name="trash-2" size={18} color={theme.textPrimary} />
name="trash-2" </TouchableOpacity>
size={18}
color={theme.textPrimary}
/>
</Pressable>
</View> </View>
</EventCardBase> </EventCardBase>
</View> </View>

View File

@@ -2,6 +2,14 @@ import { View, Text } from "react-native";
import { Feather } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import { ReactNode } from "react"; import { ReactNode } from "react";
import { useThemeStore } from "../stores/ThemeStore"; import { useThemeStore } from "../stores/ThemeStore";
import { CardBase } from "./CardBase";
import {
isMultiDayEvent,
formatDateWithWeekday,
formatDateWithWeekdayShort,
formatTime,
formatRecurrenceRule,
} from "@calchat/shared";
type EventCardBaseProps = { type EventCardBaseProps = {
className?: string; className?: string;
@@ -9,28 +17,10 @@ type EventCardBaseProps = {
startTime: Date; startTime: Date;
endTime: Date; endTime: Date;
description?: string; description?: string;
isRecurring?: boolean; recurrenceRule?: string;
children?: ReactNode; children?: ReactNode;
}; };
function formatDate(date: Date): string {
const d = new Date(date);
return d.toLocaleDateString("de-DE", {
weekday: "short",
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
function formatTime(date: Date): string {
const d = new Date(date);
return d.toLocaleTimeString("de-DE", {
hour: "2-digit",
minute: "2-digit",
});
}
function formatDuration(start: Date, end: Date): string { function formatDuration(start: Date, end: Date): string {
const startDate = new Date(start); const startDate = new Date(start);
const endDate = new Date(end); const endDate = new Date(end);
@@ -57,29 +47,14 @@ export const EventCardBase = ({
startTime, startTime,
endTime, endTime,
description, description,
isRecurring, recurrenceRule,
children, children,
}: EventCardBaseProps) => { }: EventCardBaseProps) => {
const { theme } = useThemeStore(); const { theme } = useThemeStore();
return ( const multiDay = isMultiDayEvent(startTime, endTime);
<View
className={`rounded-xl overflow-hidden ${className}`}
style={{ borderWidth: 2, borderColor: theme.borderPrimary }}
>
{/* Header with title */}
<View
className="px-3 py-2"
style={{
backgroundColor: theme.chatBot,
borderBottomWidth: 2,
borderBottomColor: theme.borderPrimary,
}}
>
<Text className="font-bold text-base" style={{ color: theme.textPrimary }}>{title}</Text>
</View>
{/* Content */} return (
<View className="px-3 py-2" style={{ backgroundColor: theme.secondaryBg }}> <CardBase title={title} className={className} borderWidth={2}>
{/* Date */} {/* Date */}
<View className="flex-row items-center mb-1"> <View className="flex-row items-center mb-1">
<Feather <Feather
@@ -88,9 +63,16 @@ export const EventCardBase = ({
color={theme.textPrimary} color={theme.textPrimary}
style={{ marginRight: 8 }} style={{ marginRight: 8 }}
/> />
{multiDay ? (
<Text style={{ color: theme.textPrimary }}> <Text style={{ color: theme.textPrimary }}>
{formatDate(startTime)} {formatDateWithWeekdayShort(startTime)} {" "}
{formatDateWithWeekday(endTime)}
</Text> </Text>
) : (
<Text style={{ color: theme.textPrimary }}>
{formatDateWithWeekday(startTime)}
</Text>
)}
</View> </View>
{/* Time with duration */} {/* Time with duration */}
@@ -101,14 +83,20 @@ export const EventCardBase = ({
color={theme.textPrimary} color={theme.textPrimary}
style={{ marginRight: 8 }} style={{ marginRight: 8 }}
/> />
{multiDay ? (
<Text style={{ color: theme.textPrimary }}>
{formatTime(startTime)} {formatTime(endTime)}
</Text>
) : (
<Text style={{ color: theme.textPrimary }}> <Text style={{ color: theme.textPrimary }}>
{formatTime(startTime)} - {formatTime(endTime)} ( {formatTime(startTime)} - {formatTime(endTime)} (
{formatDuration(startTime, endTime)}) {formatDuration(startTime, endTime)})
</Text> </Text>
)}
</View> </View>
{/* Recurring indicator */} {/* Recurring indicator */}
{isRecurring && ( {recurrenceRule && (
<View className="flex-row items-center mb-1"> <View className="flex-row items-center mb-1">
<Feather <Feather
name="repeat" name="repeat"
@@ -117,25 +105,21 @@ export const EventCardBase = ({
style={{ marginRight: 8 }} style={{ marginRight: 8 }}
/> />
<Text style={{ color: theme.textPrimary }}> <Text style={{ color: theme.textPrimary }}>
Wiederkehrend {formatRecurrenceRule(recurrenceRule)}
</Text> </Text>
</View> </View>
)} )}
{/* Description */} {/* Description */}
{description && ( {description && (
<Text <Text style={{ color: theme.textPrimary }} className="text-sm mt-1">
style={{ color: theme.textPrimary }}
className="text-sm mt-1"
>
{description} {description}
</Text> </Text>
)} )}
{/* Action buttons slot */} {/* Action buttons slot */}
{children} {children}
</View> </CardBase>
</View>
); );
}; };

View File

@@ -29,7 +29,9 @@ const EventConfirmDialog = ({
<Modal visible={false} transparent animationType="fade"> <Modal visible={false} transparent animationType="fade">
<View> <View>
<Pressable> <Pressable>
<Text style={{ color: theme.textPrimary }}>EventConfirmDialog - Not Implemented</Text> <Text style={{ color: theme.textPrimary }}>
EventConfirmDialog - Not Implemented
</Text>
</Pressable> </Pressable>
</View> </View>
</Modal> </Modal>

View File

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

View File

@@ -0,0 +1,79 @@
import { Modal, Pressable, View } from "react-native";
import { ReactNode } from "react";
import { useThemeStore } from "../stores/ThemeStore";
import { CardBase } from "./CardBase";
type ModalBaseProps = {
visible: boolean;
onClose: () => void;
title: string;
subtitle?: string;
children: ReactNode;
attachment?: ReactNode;
footer?: {
label: string;
onPress: () => void;
};
scrollable?: boolean;
maxContentHeight?: number;
};
export const ModalBase = ({
visible,
onClose,
title,
subtitle,
children,
attachment,
footer,
scrollable,
maxContentHeight,
}: ModalBaseProps) => {
const { theme } = useThemeStore();
return (
<Modal
visible={visible}
transparent={true}
animationType="fade"
onRequestClose={onClose}
>
<View className="flex-1 justify-center items-center">
{/* Backdrop - absolute positioned behind the card */}
<Pressable
className="absolute inset-0"
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
onPress={onClose}
/>
{/* Card content - on top, naturally blocks touches to backdrop */}
<View
className="w-11/12 rounded-2xl overflow-hidden"
style={{
backgroundColor: theme.primeBg,
borderWidth: 4,
borderColor: theme.borderPrimary,
}}
>
<CardBase
title={title}
subtitle={subtitle}
attachment={attachment}
footer={footer}
scrollable={scrollable}
maxContentHeight={maxContentHeight}
borderWidth={0}
headerBorderWidth={3}
headerPadding={4}
contentPadding={4}
headerTextSize="text-lg"
contentBg={theme.primeBg}
>
{children}
</CardBase>
</View>
</View>
</Modal>
);
};
export default ModalBase;

View File

@@ -1,24 +1,31 @@
import { View, Text, Pressable } from "react-native"; import { View, Text, Pressable } from "react-native";
import { ProposedEventChange } from "@calchat/shared"; import { Feather, Ionicons } from "@expo/vector-icons";
import { ProposedEventChange, formatDate, formatTime } from "@calchat/shared";
import { rrulestr } from "rrule";
import { useThemeStore } from "../stores/ThemeStore"; import { useThemeStore } from "../stores/ThemeStore";
import { EventCardBase } from "./EventCardBase"; import { EventCardBase } from "./EventCardBase";
type ProposedEventCardProps = { type ProposedEventCardProps = {
proposedChange: ProposedEventChange; proposedChange: ProposedEventChange;
onConfirm: () => void; onConfirm: (proposal: ProposedEventChange) => void;
onReject: () => void; onReject: () => void;
onEdit?: (proposal: ProposedEventChange) => void;
}; };
const ConfirmRejectButtons = ({ const ActionButtons = ({
isDisabled, isDisabled,
respondedAction, respondedAction,
showEdit,
onConfirm, onConfirm,
onReject, onReject,
onEdit,
}: { }: {
isDisabled: boolean; isDisabled: boolean;
respondedAction?: "confirm" | "reject"; respondedAction?: "confirm" | "reject";
showEdit: boolean;
onConfirm: () => void; onConfirm: () => void;
onReject: () => void; onReject: () => void;
onEdit?: () => void;
}) => { }) => {
const { theme } = useThemeStore(); const { theme } = useThemeStore();
return ( return (
@@ -55,6 +62,19 @@ const ConfirmRejectButtons = ({
Ablehnen Ablehnen
</Text> </Text>
</Pressable> </Pressable>
{showEdit && onEdit && (
<Pressable
onPress={onEdit}
className="py-2 px-3 rounded-lg items-center"
style={{
backgroundColor: theme.secondaryBg,
borderWidth: 1,
borderColor: theme.borderPrimary,
}}
>
<Feather name="edit-2" size={18} color={theme.textPrimary} />
</Pressable>
)}
</View> </View>
); );
}; };
@@ -63,11 +83,24 @@ export const ProposedEventCard = ({
proposedChange, proposedChange,
onConfirm, onConfirm,
onReject, onReject,
onEdit,
}: ProposedEventCardProps) => { }: ProposedEventCardProps) => {
const { theme } = useThemeStore();
const event = proposedChange.event; const event = proposedChange.event;
// respondedAction is now part of the proposedChange
const isDisabled = !!proposedChange.respondedAction; const isDisabled = !!proposedChange.respondedAction;
// For delete/single action, the occurrenceDate becomes a new exception
const newExceptionDate =
proposedChange.action === "delete" &&
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) { if (!event) {
return null; return null;
} }
@@ -80,13 +113,73 @@ export const ProposedEventCard = ({
startTime={event.startTime} startTime={event.startTime}
endTime={event.endTime} endTime={event.endTime}
description={event.description} description={event.description}
isRecurring={event.isRecurring} recurrenceRule={event.recurrenceRule}
> >
<ConfirmRejectButtons {/* 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>
)}
{/* 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>
)}
{/* Show conflicting events warning */}
{proposedChange.conflictingEvents &&
proposedChange.conflictingEvents.length > 0 && (
<View className="mb-2">
{proposedChange.conflictingEvents.map((conflict, index) => (
<View key={index} className="flex-row items-center mt-1">
<Ionicons
name="alert-circle"
size={16}
color={theme.rejectButton}
style={{ marginRight: 8 }}
/>
<Text
style={{ color: theme.rejectButton }}
className="text-sm flex-1"
>
Konflikt: {conflict.title} ({formatTime(conflict.startTime)}{" "}
- {formatTime(conflict.endTime)})
</Text>
</View>
))}
</View>
)}
<ActionButtons
isDisabled={isDisabled} isDisabled={isDisabled}
respondedAction={proposedChange.respondedAction} respondedAction={proposedChange.respondedAction}
onConfirm={onConfirm} showEdit={proposedChange.action !== "delete" && !isDisabled}
onConfirm={() => onConfirm(proposedChange)}
onReject={onReject} onReject={onReject}
onEdit={onEdit ? () => onEdit(proposedChange) : undefined}
/> />
</EventCardBase> </EventCardBase>
</View> </View>

View File

@@ -0,0 +1,107 @@
import { useRef, useEffect } from "react";
import { Modal, Pressable, Animated, useWindowDimensions } from "react-native";
import { FlashList } from "@shopify/flash-list";
import { useThemeStore } from "../stores/ThemeStore";
import { Theme } from "../Themes";
export type ScrollableDropdownProps<T> = {
visible: boolean;
onClose: () => void;
position: {
top?: number;
bottom?: number;
left: number;
width: number;
};
data: T[];
keyExtractor: (item: T) => string;
renderItem: (item: T, theme: Theme) => React.ReactNode;
onSelect: (item: T) => void;
height?: number;
heightRatio?: number; // Alternative: fraction of screen height (0-1)
initialScrollIndex?: number;
// Infinite scroll (optional)
onEndReached?: () => void;
onStartReached?: () => void;
};
export const ScrollableDropdown = <T,>({
visible,
onClose,
position,
data,
keyExtractor,
renderItem,
onSelect,
height = 200,
heightRatio,
initialScrollIndex = 0,
onEndReached,
onStartReached,
}: ScrollableDropdownProps<T>) => {
const { theme } = useThemeStore();
const { height: screenHeight } = useWindowDimensions();
const heightAnim = useRef(new Animated.Value(0)).current;
const listRef = useRef<React.ComponentRef<typeof FlashList<T>>>(null);
// Calculate actual height: use heightRatio if provided, otherwise fall back to height
const actualHeight = heightRatio ? screenHeight * heightRatio : height;
// Calculate top position: use top if provided, otherwise calculate from bottom
const topValue =
position.top ?? screenHeight - actualHeight - (position.bottom ?? 0);
useEffect(() => {
if (visible) {
Animated.timing(heightAnim, {
toValue: actualHeight,
duration: 200,
useNativeDriver: false,
}).start();
} else {
heightAnim.setValue(0);
}
}, [visible, heightAnim, actualHeight]);
return (
<Modal
visible={visible}
transparent={true}
animationType="none"
onRequestClose={onClose}
>
<Pressable className="flex-1 rounded-lg" onPress={onClose}>
<Animated.View
className="absolute overflow-hidden"
style={{
top: topValue,
left: position.left,
width: position.width,
height: heightAnim,
backgroundColor: theme.primeBg,
borderWidth: 2,
borderColor: theme.borderPrimary,
borderRadius: 8,
}}
>
<FlashList
className="w-full"
style={{ borderRadius: 8 }}
ref={listRef}
keyExtractor={keyExtractor}
data={data}
initialScrollIndex={initialScrollIndex}
onEndReachedThreshold={0.5}
onEndReached={onEndReached}
onStartReachedThreshold={0.5}
onStartReached={onStartReached}
renderItem={({ item }) => (
<Pressable onPress={() => onSelect(item)}>
{renderItem(item, theme)}
</Pressable>
)}
/>
</Animated.View>
</Pressable>
</Modal>
);
};

View File

@@ -0,0 +1,37 @@
import { useCallback, useRef, useState } from "react";
import { View } from "react-native";
type DropdownPosition = {
top: number;
left: number;
width: number;
};
/**
* Hook for managing dropdown position measurement and visibility.
* @param widthMultiplier - Multiply the measured width (default: 1)
*/
export const useDropdownPosition = (widthMultiplier = 1) => {
const [visible, setVisible] = useState(false);
const [position, setPosition] = useState<DropdownPosition>({
top: 0,
left: 0,
width: 0,
});
const ref = useRef<View>(null);
const open = useCallback(() => {
ref.current?.measureInWindow((x, y, width, height) => {
setPosition({
top: y + height,
left: x,
width: width * widthMultiplier,
});
setVisible(true);
});
}, [widthMultiplier]);
const close = useCallback(() => setVisible(false), []);
return { ref, visible, position, open, close };
};

View File

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

View File

@@ -0,0 +1,32 @@
import { CalendarEvent, CaldavConfig } from "@calchat/shared";
import { ApiClient } from "./ApiClient";
export const CaldavConfigService = {
saveConfig: async (
serverUrl: string,
username: string,
password: string,
): Promise<CaldavConfig> => {
return ApiClient.put<CaldavConfig>("/caldav/config", {
serverUrl,
username,
password,
});
},
getConfig: async (): Promise<CaldavConfig> => {
return ApiClient.get<CaldavConfig>("/caldav/config");
},
deleteConfig: async (): Promise<void> => {
return ApiClient.delete<void>("/caldav/config");
},
pull: async (): Promise<CalendarEvent[]> => {
return ApiClient.post<CalendarEvent[]>("/caldav/pull");
},
pushAll: async (): Promise<void> => {
return ApiClient.post<void>("/caldav/pushAll");
},
sync: async (): Promise<void> => {
await ApiClient.post<void>("/caldav/pushAll");
await ApiClient.post<CalendarEvent[]>("/caldav/pull");
},
};

View File

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

View File

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

View File

@@ -0,0 +1,14 @@
import { create } from "zustand";
import { CaldavConfig } from "@calchat/shared";
interface CaldavConfigState {
config: CaldavConfig | null;
setConfig: (config: CaldavConfig | null) => void;
}
export const useCaldavConfigStore = create<CaldavConfigState>((set) => ({
config: null,
setConfig: (config: CaldavConfig | null) => {
set({ config });
},
}));

View File

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

View File

@@ -5,3 +5,4 @@ export {
type MessageData, type MessageData,
} from "./ChatStore"; } from "./ChatStore";
export { useEventsStore } from "./EventsStore"; export { useEventsStore } from "./EventsStore";
export { useCaldavConfigStore } from "./CaldavConfigStore";

28
apps/server/.env.example Normal file
View File

@@ -0,0 +1,28 @@
# OpenAI API key for GPT-based chat assistant
# Required for AI chat functionality
OPENAI_API_KEY=sk-proj-your-key-here
# Port the server listens on
# Default: 3000
PORT=3000
# MongoDB connection URI
# Default: mongodb://localhost:27017/calchat
# The Docker Compose setup uses root:mongoose credentials with authSource=admin
MONGODB_URI=mongodb://root:mongoose@localhost:27017/calchat?authSource=admin
# Use static test responses instead of real GPT calls
# Values: true | false
# Default: false
USE_TEST_RESPONSES=false
# Log level for pino logger
# Values: debug | info | warn | error | fatal
# Default: debug (development), info (production)
LOG_LEVEL=debug
# Node environment
# Values: development | production
# development = pretty-printed logs, production = JSON logs
# Default: development
NODE_ENV=development

View File

@@ -0,0 +1,31 @@
FROM node:alpine AS build
WORKDIR /app
COPY package.json package-lock.json tsconfig.json ./
COPY packages/shared/package.json ./packages/shared/
COPY apps/server/package.json ./apps/server/
RUN npm ci -w @calchat/server -w @calchat/shared --include-workspace-root
COPY packages/shared/ packages/shared/
COPY apps/server/ apps/server/
RUN npm run build -w @calchat/shared && npm run build -w @calchat/server
FROM node:alpine
WORKDIR /app
COPY --from=build /app/package.json /app/package-lock.json ./
COPY --from=build /app/packages/shared/package.json packages/shared/
COPY --from=build /app/apps/server/package.json apps/server/
RUN npm ci --omit=dev -w @calchat/server -w @calchat/shared
COPY --from=build /app/packages/shared/dist/ packages/shared/dist/
COPY --from=build /app/apps/server/dist/ apps/server/dist/
EXPOSE 3001
CMD ["node", "apps/server/dist/app.js"]

View File

@@ -0,0 +1,25 @@
name: Radicale
services:
radicale:
image: ghcr.io/kozea/radicale:stable
ports:
- 5232:5232
volumes:
- config:/etc/radicale
- data:/var/lib/radicale
volumes:
config:
name: radicale-config
driver: local
driver_opts:
type: none
o: bind
device: ./config
data:
name: radicale-data
driver: local
driver_opts:
type: none
o: bind
device: ./data

View File

@@ -0,0 +1,5 @@
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
testPathIgnorePatterns: ["/node_modules/", "/dist/"],
};

View File

@@ -3,28 +3,33 @@
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "tsx watch src/app.ts", "dev": "npm run build --workspace=@calchat/shared && tsx watch src/app.ts",
"build": "tsc", "build": "npm run build --workspace=@calchat/shared && tsc",
"start": "node dist/app.js" "start": "node dist/app.js",
"test": "jest"
}, },
"dependencies": { "dependencies": {
"@calchat/shared": "*", "@calchat/shared": "*",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"express": "^5.2.1", "express": "^5.2.1",
"jsonwebtoken": "^9.0.3", "ical.js": "^2.2.1",
"mongoose": "^9.1.1", "mongoose": "^9.1.1",
"openai": "^6.15.0", "openai": "^6.15.0",
"pino": "^10.1.1", "pino": "^10.1.1",
"pino-http": "^11.0.0", "pino-http": "^11.0.0",
"rrule": "^2.8.1" "pino-pretty": "^13.1.3",
"rrule": "^2.8.1",
"tsdav": "^2.1.6"
}, },
"devDependencies": { "devDependencies": {
"@types/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/jsonwebtoken": "^9.0.10", "@types/ical": "^0.8.3",
"@types/jest": "^30.0.0",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"pino-pretty": "^13.1.3", "jest": "^30.2.0",
"ts-jest": "^29.4.6",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }

View File

@@ -8,6 +8,11 @@ import {
ToolDefinition, ToolDefinition,
} from "./utils"; } from "./utils";
import { Logged } from "../logging"; import { Logged } from "../logging";
import {
ChatCompletionMessageParam,
ChatCompletionMessageToolCall,
ChatCompletionTool,
} from "openai/resources/chat/completions/completions";
/** /**
* Convert tool definitions to OpenAI format. * Convert tool definitions to OpenAI format.
@@ -29,7 +34,7 @@ function toOpenAITools(
export class GPTAdapter implements AIProvider { export class GPTAdapter implements AIProvider {
private client: OpenAI; private client: OpenAI;
private model: string; private model: string;
private tools: OpenAI.Chat.Completions.ChatCompletionTool[]; private tools: ChatCompletionTool[];
constructor(apiKey?: string, model: string = "gpt-5-mini") { constructor(apiKey?: string, model: string = "gpt-5-mini") {
this.client = new OpenAI({ this.client = new OpenAI({
@@ -46,7 +51,7 @@ export class GPTAdapter implements AIProvider {
const systemPrompt = buildSystemPrompt(context); const systemPrompt = buildSystemPrompt(context);
// Build messages array with conversation history // Build messages array with conversation history
const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [ const messages: ChatCompletionMessageParam[] = [
{ role: "developer", content: systemPrompt }, { role: "developer", content: systemPrompt },
]; ];
@@ -87,17 +92,21 @@ export class GPTAdapter implements AIProvider {
}; };
} }
// Process tool calls // Process all tool calls and collect results
const toolResults: Array<{
toolCall: ChatCompletionMessageToolCall;
content: string;
}> = [];
for (const toolCall of assistantMessage.tool_calls) { for (const toolCall of assistantMessage.tool_calls) {
// Skip non-function tool calls
if (toolCall.type !== "function") continue; if (toolCall.type !== "function") continue;
const { name, arguments: argsRaw } = toolCall.function; const { name, arguments: argsRaw } = toolCall.function;
const args = JSON.parse(argsRaw); const args = JSON.parse(argsRaw);
const result = executeToolCall(name, args, context); const result = await executeToolCall(name, args, context);
// If the tool returned a proposedChange, add it to the array with unique ID // Collect proposed changes
if (result.proposedChange) { if (result.proposedChange) {
proposedChanges.push({ proposedChanges.push({
id: `proposal-${proposalIndex++}`, id: `proposal-${proposalIndex++}`,
@@ -105,17 +114,22 @@ export class GPTAdapter implements AIProvider {
}); });
} }
// Add assistant message with tool call toolResults.push({ toolCall, content: result.content });
}
// Add assistant message with ALL tool calls at once
messages.push({ messages.push({
role: "assistant", role: "assistant",
tool_calls: [toolCall], tool_calls: assistantMessage.tool_calls,
content: assistantMessage.content,
}); });
// Add tool result // Add all tool results
for (const { toolCall, content } of toolResults) {
messages.push({ messages.push({
role: "tool", role: "tool",
tool_call_id: toolCall.id, tool_call_id: toolCall.id,
content: result.content, content,
}); });
} }
} }

View File

@@ -1,29 +0,0 @@
import { CalendarEvent } 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")}`;
/**
* Format a list of events for display in the system prompt.
* Output is in German with date/time formatting.
*/
export function formatExistingEvents(events: CalendarEvent[]): string {
if (events.length === 0) {
return "Keine Termine vorhanden.";
}
return events
.map((e) => {
const start = new Date(e.startTime);
const end = new Date(e.endTime);
const timeStr = `${formatTime(start)} - ${formatTime(end)}`;
const recurring = e.isRecurring ? " (wiederkehrend)" : "";
const desc = e.description ? ` | ${e.description}` : "";
return `- ${e.title} (ID: ${e.id}) | ${formatDate(start)} ${timeStr}${recurring}${desc}`;
})
.join("\n");
}

View File

@@ -1,9 +1,3 @@
export {
formatExistingEvents,
formatDate,
formatTime,
formatDateTime,
} from "./eventFormatter";
export { buildSystemPrompt } from "./systemPrompt"; export { buildSystemPrompt } from "./systemPrompt";
export { export {
TOOL_DEFINITIONS, TOOL_DEFINITIONS,

View File

@@ -1,5 +1,4 @@
import { AIContext } from "../../services/interfaces"; import { AIContext } from "../../services/interfaces";
import { formatExistingEvents } from "./eventFormatter";
/** /**
* Build the system prompt for the AI assistant. * Build the system prompt for the AI assistant.
@@ -15,8 +14,6 @@ export function buildSystemPrompt(context: AIContext): string {
minute: "2-digit", minute: "2-digit",
}); });
const eventsText = formatExistingEvents(context.existingEvents);
return `Du bist ein hilfreicher Kalender-Assistent für die App "CalChat". return `Du bist ein hilfreicher Kalender-Assistent für die App "CalChat".
Du hilfst Benutzern beim Erstellen, Ändern und Löschen von Terminen. Du hilfst Benutzern beim Erstellen, Ändern und Löschen von Terminen.
Antworte immer auf Deutsch. Antworte immer auf Deutsch.
@@ -29,8 +26,16 @@ Wichtige Regeln:
- Wenn der Benutzer einen Termin ändern will, nutze proposeUpdateEvent mit der Event-ID - Wenn der Benutzer einen Termin ändern will, nutze proposeUpdateEvent mit der Event-ID
- Wenn der Benutzer einen Termin löschen will, nutze proposeDeleteEvent mit der Event-ID - Wenn der Benutzer einen Termin löschen will, nutze proposeDeleteEvent mit der Event-ID
- Du kannst mehrere Event-Vorschläge in einer Antwort machen (z.B. für mehrere Termine auf einmal) - Du kannst mehrere Event-Vorschläge in einer Antwort machen (z.B. für mehrere Termine auf einmal)
- Wenn der Benutzer nach seinen Terminen fragt, nutze die unten stehende Liste - WICHTIG: Bei Terminen in der VERGANGENHEIT: Weise den Benutzer darauf hin und erstelle KEIN Event. Beispiel: "Das Datum liegt in der Vergangenheit. Meintest du vielleicht [nächstes Jahr]?"
- Nutze searchEvents um nach Terminen zu suchen, wenn du die genaue ID brauchst - KRITISCH: Wenn ein Tool-Result eine ⚠️-Warnung enthält (z.B. "Zeitkonflikt mit..."), MUSST du diese dem Benutzer mitteilen! Ignoriere NIEMALS solche Warnungen! Beispiel: "An diesem Tag hast du bereits 'Jannes Geburtstag'. Soll ich den Termin trotzdem erstellen?"
WICHTIG - Event-Abfragen:
- Du hast KEINEN vorgeladenen Kalender-Kontext!
- Nutze IMMER getEventsInRange um Events zu laden, wenn der Benutzer nach Terminen fragt
- Nutze searchEvents um nach Terminen per Titel zu suchen (gibt auch die Event-ID zurück)
- Beispiel: "Was habe ich heute?" → getEventsInRange für heute
- Beispiel: "Was habe ich diese Woche?" → getEventsInRange für die Woche
- Beispiel: "Wann ist der Zahnarzt?" → searchEvents mit "Zahnarzt"
WICHTIG - Tool-Verwendung: WICHTIG - Tool-Verwendung:
- Du MUSST die proposeCreateEvent/proposeUpdateEvent/proposeDeleteEvent Tools verwenden, um Termine vorzuschlagen! - Du MUSST die proposeCreateEvent/proposeUpdateEvent/proposeDeleteEvent Tools verwenden, um Termine vorzuschlagen!
@@ -46,15 +51,25 @@ WICHTIG - Wiederkehrende Termine (RRULE):
2. "Arbeit" Fr 9:00-13:00 (RRULE mit BYDAY=FR) 2. "Arbeit" Fr 9:00-13:00 (RRULE mit BYDAY=FR)
- Nutze NIEMALS BYHOUR/BYMINUTE in RRULE - diese überschreiben die Startzeit nicht wie erwartet! - 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 - 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: WICHTIG - Antwortformat:
- Halte deine Textantworten SEHR KURZ (1-2 Sätze maximal) - Verwende kontextbezogene Antworten in der GEGENWARTSFORM je nach Aktion:
- Die Event-Details (Titel, Datum, Uhrzeit, Beschreibung) werden dem Benutzer automatisch in separaten Karten angezeigt - Bei Termin-Erstellung: "Ich schlage folgenden Termin vor:" oder "Neuer Termin:"
- Wiederhole NIEMALS die Event-Details im Text! Der Benutzer sieht sie bereits in den Karten - Bei Termin-Änderung: "Ich schlage folgende Änderung vor:" oder "Änderung:"
- Gute Beispiele: "Alles klar!" oder "Hier sind deine Termine:" - Bei Termin-Löschung: "Ich schlage vor, diesen Termin zu löschen:" oder "Löschung:"
- Schlechte Beispiele: Lange Listen mit allen Terminen und ihren Details im Text - WICHTIG: Verwende NIEMALS Vergangenheitsform wie "Ich habe ... vorgeschlagen" - immer Gegenwartsform!
- Bei Rückfragen oder wenn keine Termine erstellt werden, kannst du ausführlicher antworten
Existierende Termine des Benutzers: WICHTIG - Unterscheide zwischen PROPOSALS und ABFRAGEN:
${eventsText}`; 1. Bei PROPOSALS (proposeCreateEvent/proposeUpdateEvent/proposeDeleteEvent):
- Halte deine Textantworten SEHR KURZ (1-2 Sätze)
- Die Event-Details werden automatisch in Karten angezeigt
- Wiederhole NICHT die Details im Text
2. Bei ABFRAGEN (searchEvents, getEventsInRange, oder Fragen zu existierenden Terminen):
- Du MUSST die gefundenen Termine im Text nennen!
- Liste die relevanten Termine mit Titel, Datum und Uhrzeit auf
- NIEMALS Event-IDs dem Benutzer zeigen! Die IDs sind nur für dich intern
- Wenn keine Termine gefunden wurden, sage das explizit (z.B. "In diesem Zeitraum hast du keine Termine.")
- Beispiel: "Heute hast du: Zahnarzt um 10:00 Uhr, Meeting um 14:00 Uhr."`;
} }

View File

@@ -94,10 +94,6 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
type: "string", type: "string",
description: "Optional event description", description: "Optional event description",
}, },
isRecurring: {
type: "boolean",
description: "Whether this is a recurring event",
},
recurrenceRule: { recurrenceRule: {
type: "string", type: "string",
description: "RRULE format string for recurring events", description: "RRULE format string for recurring events",
@@ -131,7 +127,12 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
}, },
description: { description: {
type: "string", 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"], required: ["eventId"],
@@ -140,7 +141,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
{ {
name: "proposeDeleteEvent", name: "proposeDeleteEvent",
description: description:
"Propose deleting an event. The user must confirm. Use this when the user wants to remove an appointment.", "Propose deleting an event. The user must confirm. Use this when the user wants to remove an appointment. For recurring events, specify deleteMode to control which occurrences to delete.",
parameters: { parameters: {
type: "object", type: "object",
properties: { properties: {
@@ -148,6 +149,17 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
type: "string", type: "string",
description: "ID of the event to delete", description: "ID of the event to delete",
}, },
deleteMode: {
type: "string",
enum: ["single", "future", "all"],
description:
"For recurring events: 'single' = only this occurrence, 'future' = this and all future, 'all' = entire recurring event. Defaults to 'all' for non-recurring events.",
},
occurrenceDate: {
type: "string",
description:
"ISO date string (YYYY-MM-DD) of the specific occurrence to delete. Required for 'single' and 'future' modes.",
},
}, },
required: ["eventId"], required: ["eventId"],
}, },
@@ -167,4 +179,23 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
required: ["query"], required: ["query"],
}, },
}, },
{
name: "getEventsInRange",
description:
"Load events from a specific date range. Use this when the user asks about a time period beyond the default 4 weeks (e.g., 'birthdays in the next 6 months', 'what do I have planned for summer').",
parameters: {
type: "object",
properties: {
startDate: {
type: "string",
description: "Start date as ISO string (YYYY-MM-DD)",
},
endDate: {
type: "string",
description: "End date as ISO string (YYYY-MM-DD)",
},
},
required: ["startDate", "endDate"],
},
},
]; ];

View File

@@ -3,9 +3,22 @@ import {
getDay, getDay,
Day, Day,
DAY_TO_GERMAN, DAY_TO_GERMAN,
RecurringDeleteMode,
} from "@calchat/shared"; } from "@calchat/shared";
import { AIContext } from "../../services/interfaces"; import { AIContext } from "../../services/interfaces";
import { formatDate, formatTime, formatDateTime } from "./eventFormatter"; import { formatDate, formatTime, formatDateTime } from "@calchat/shared";
/**
* Check if two time ranges overlap.
*/
function hasTimeOverlap(
start1: Date,
end1: Date,
start2: Date,
end2: Date,
): boolean {
return start1 < end2 && end1 > start2;
}
/** /**
* Proposed change without ID - ID is added by GPTAdapter when collecting proposals * Proposed change without ID - ID is added by GPTAdapter when collecting proposals
@@ -23,12 +36,13 @@ export interface ToolResult {
/** /**
* Execute a tool call and return the result. * Execute a tool call and return the result.
* This function is provider-agnostic and can be used with any LLM. * This function is provider-agnostic and can be used with any LLM.
* Async to support tools that need to fetch data (e.g., getEventsInRange).
*/ */
export function executeToolCall( export async function executeToolCall(
name: string, name: string,
args: Record<string, unknown>, args: Record<string, unknown>,
context: AIContext, context: AIContext,
): ToolResult { ): Promise<ToolResult> {
switch (name) { switch (name) {
case "getDay": { case "getDay": {
const date = getDay( const date = getDay(
@@ -56,26 +70,57 @@ export function executeToolCall(
startTime: new Date(args.startTime as string), startTime: new Date(args.startTime as string),
endTime: new Date(args.endTime as string), endTime: new Date(args.endTime as string),
description: args.description as string | undefined, description: args.description as string | undefined,
isRecurring: args.isRecurring as boolean | undefined,
recurrenceRule: args.recurrenceRule as string | undefined, recurrenceRule: args.recurrenceRule as string | undefined,
}; };
const dateStr = formatDate(event.startTime); const dateStr = formatDate(event.startTime);
const startStr = formatTime(event.startTime); const startStr = formatTime(event.startTime);
const endStr = formatTime(event.endTime); const endStr = formatTime(event.endTime);
// Check for conflicts - fetch events for the specific day
const dayStart = new Date(event.startTime);
dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(dayStart);
dayEnd.setDate(dayStart.getDate() + 1);
const dayEvents = await context.fetchEventsInRange(dayStart, dayEnd);
// Use occurrenceStart/occurrenceEnd for expanded recurring events
const conflicts = dayEvents.filter((e) =>
hasTimeOverlap(
event.startTime,
event.endTime,
new Date(e.occurrenceStart),
new Date(e.occurrenceEnd),
),
);
// Build conflict warning if any
let conflictWarning = "";
if (conflicts.length > 0) {
const conflictNames = conflicts.map((c) => `"${c.title}"`).join(", ");
conflictWarning = `\n⚠ ACHTUNG: Zeitkonflikt mit ${conflictNames}!`;
}
return { return {
content: `Event-Vorschlag erstellt: "${event.title}" am ${dateStr} von ${startStr} bis ${endStr} Uhr`, content: `Event-Vorschlag erstellt: "${event.title}" am ${dateStr} von ${startStr} bis ${endStr} Uhr${conflictWarning}`,
proposedChange: { proposedChange: {
action: "create", action: "create",
event, event,
conflictingEvents:
conflicts.length > 0
? conflicts.map((c) => ({
title: c.title,
startTime: new Date(c.occurrenceStart),
endTime: new Date(c.occurrenceEnd),
}))
: undefined,
}, },
}; };
} }
case "proposeUpdateEvent": { case "proposeUpdateEvent": {
const eventId = args.eventId as string; const eventId = args.eventId as string;
const existingEvent = context.existingEvents.find( const existingEvent = await context.fetchEventById(eventId);
(e) => e.id === eventId,
);
if (!existingEvent) { if (!existingEvent) {
return { content: `Event mit ID ${eventId} nicht gefunden.` }; return { content: `Event mit ID ${eventId} nicht gefunden.` };
@@ -87,6 +132,7 @@ export function executeToolCall(
updates.startTime = new Date(args.startTime as string); updates.startTime = new Date(args.startTime as string);
if (args.endTime) updates.endTime = new Date(args.endTime as string); if (args.endTime) updates.endTime = new Date(args.endTime as string);
if (args.description) updates.description = args.description; if (args.description) updates.description = args.description;
if (args.recurrenceRule) updates.recurrenceRule = args.recurrenceRule;
// Build event object for display (merge existing with updates) // Build event object for display (merge existing with updates)
const displayEvent = { const displayEvent = {
@@ -95,7 +141,9 @@ export function executeToolCall(
endTime: (updates.endTime as Date) || existingEvent.endTime, endTime: (updates.endTime as Date) || existingEvent.endTime,
description: description:
(updates.description as string) || existingEvent.description, (updates.description as string) || existingEvent.description,
isRecurring: existingEvent.isRecurring, recurrenceRule:
(updates.recurrenceRule as string) || existingEvent.recurrenceRule,
exceptionDates: existingEvent.exceptionDates,
}; };
return { return {
@@ -111,16 +159,32 @@ export function executeToolCall(
case "proposeDeleteEvent": { case "proposeDeleteEvent": {
const eventId = args.eventId as string; const eventId = args.eventId as string;
const existingEvent = context.existingEvents.find( const deleteMode = (args.deleteMode as RecurringDeleteMode) || "all";
(e) => e.id === eventId, const occurrenceDate = args.occurrenceDate as string | undefined;
); const existingEvent = await context.fetchEventById(eventId);
if (!existingEvent) { if (!existingEvent) {
return { content: `Event mit ID ${eventId} nicht gefunden.` }; return { content: `Event mit ID ${eventId} nicht gefunden.` };
} }
// Build descriptive content based on delete mode
let modeDescription = "";
if (existingEvent.recurrenceRule) {
switch (deleteMode) {
case "single":
modeDescription = " (nur dieses Vorkommen)";
break;
case "future":
modeDescription = " (dieses und alle zukünftigen Vorkommen)";
break;
case "all":
modeDescription = " (alle Vorkommen)";
break;
}
}
return { return {
content: `Lösch-Vorschlag für "${existingEvent.title}" erstellt.`, content: `Lösch-Vorschlag für "${existingEvent.title}"${modeDescription} erstellt.`,
proposedChange: { proposedChange: {
action: "delete", action: "delete",
eventId, eventId,
@@ -129,32 +193,58 @@ export function executeToolCall(
startTime: existingEvent.startTime, startTime: existingEvent.startTime,
endTime: existingEvent.endTime, endTime: existingEvent.endTime,
description: existingEvent.description, description: existingEvent.description,
isRecurring: existingEvent.isRecurring, recurrenceRule: existingEvent.recurrenceRule,
exceptionDates: existingEvent.exceptionDates,
}, },
deleteMode: existingEvent.recurrenceRule ? deleteMode : undefined,
occurrenceDate: existingEvent.recurrenceRule
? occurrenceDate
: undefined,
}, },
}; };
} }
case "searchEvents": { case "searchEvents": {
const query = (args.query as string).toLowerCase(); const query = args.query as string;
const matches = context.existingEvents.filter((e) => const matches = await context.searchEvents(query);
e.title.toLowerCase().includes(query),
);
if (matches.length === 0) { if (matches.length === 0) {
return { content: `Keine Termine mit "${args.query}" gefunden.` }; return { content: `Keine Termine mit "${query}" gefunden.` };
} }
const results = matches const results = matches
.map((e) => { .map((e) => {
const start = new Date(e.startTime); const start = new Date(e.startTime);
return `- ${e.title} (ID: ${e.id}) am ${formatDate(start)} um ${formatTime(start)} Uhr`; const recurrenceInfo = e.recurrenceRule ? " (wiederkehrend)" : "";
return `- ${e.title} (ID: ${e.id}) am ${formatDate(start)} um ${formatTime(start)} Uhr${recurrenceInfo}`;
}) })
.join("\n"); .join("\n");
return { content: `Gefundene Termine:\n${results}` }; return { content: `Gefundene Termine:\n${results}` };
} }
case "getEventsInRange": {
const startDate = new Date(args.startDate as string);
const endDate = new Date(args.endDate as string);
const events = await context.fetchEventsInRange(startDate, endDate);
if (events.length === 0) {
return { content: "Keine Termine in diesem Zeitraum." };
}
const eventsText = events
.map((e) => {
const start = new Date(e.occurrenceStart);
const recurrenceInfo = e.recurrenceRule ? " (wiederkehrend)" : "";
return `- ${e.title} (ID: ${e.id}) am ${formatDate(start)} um ${formatTime(start)} Uhr${recurrenceInfo}`;
})
.join("\n");
return {
content: `Termine von ${formatDate(startDate)} bis ${formatDate(endDate)}:\n${eventsText}`,
};
}
default: default:
return { content: `Unbekannte Funktion: ${name}` }; return { content: `Unbekannte Funktion: ${name}` };
} }

View File

@@ -17,6 +17,9 @@ import {
} from "./repositories"; } from "./repositories";
import { GPTAdapter } from "./ai"; import { GPTAdapter } from "./ai";
import { logger } from "./logging"; import { logger } from "./logging";
import { MongoCaldavRepository } from "./repositories/mongo/MongoCaldavRepository";
import { CaldavService } from "./services/CaldavService";
import { CaldavController } from "./controllers/CaldavController";
const app = express(); const app = express();
const port = process.env.PORT || 3000; const port = process.env.PORT || 3000;
@@ -35,7 +38,10 @@ if (process.env.NODE_ENV !== "production") {
"Access-Control-Allow-Methods", "Access-Control-Allow-Methods",
"GET, POST, PUT, DELETE, OPTIONS", "GET, POST, PUT, DELETE, OPTIONS",
); );
res.header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-User-Id"); res.header(
"Access-Control-Allow-Headers",
"Content-Type, Authorization, X-User-Id",
);
if (req.method === "OPTIONS") { if (req.method === "OPTIONS") {
res.sendStatus(200); res.sendStatus(200);
return; return;
@@ -48,19 +54,22 @@ if (process.env.NODE_ENV !== "production") {
const userRepo = new MongoUserRepository(); const userRepo = new MongoUserRepository();
const eventRepo = new MongoEventRepository(); const eventRepo = new MongoEventRepository();
const chatRepo = new MongoChatRepository(); const chatRepo = new MongoChatRepository();
const caldavRepo = new MongoCaldavRepository();
// Initialize AI provider // Initialize AI provider
const aiProvider = new GPTAdapter(); const aiProvider = new GPTAdapter();
// Initialize services // Initialize services
const authService = new AuthService(userRepo); const authService = new AuthService(userRepo);
const chatService = new ChatService(chatRepo, eventRepo, aiProvider);
const eventService = new EventService(eventRepo); const eventService = new EventService(eventRepo);
const caldavService = new CaldavService(caldavRepo, eventService);
const chatService = new ChatService(chatRepo, eventService, aiProvider);
// Initialize controllers // Initialize controllers
const authController = new AuthController(authService); const authController = new AuthController(authService);
const chatController = new ChatController(chatService); const chatController = new ChatController(chatService, caldavService);
const eventController = new EventController(eventService); const eventController = new EventController(eventService, caldavService);
const caldavController = new CaldavController(caldavService);
// Setup routes // Setup routes
app.use( app.use(
@@ -69,6 +78,7 @@ app.use(
authController, authController,
chatController, chatController,
eventController, eventController,
caldavController,
}), }),
); );
@@ -88,8 +98,10 @@ app.post("/api/ai/test", async (req, res) => {
const result = await aiProvider.processMessage(message, { const result = await aiProvider.processMessage(message, {
userId: "test-user", userId: "test-user",
conversationHistory: [], conversationHistory: [],
existingEvents: [],
currentDate: new Date(), currentDate: new Date(),
fetchEventsInRange: async () => [],
searchEvents: async () => [],
fetchEventById: async () => null,
}); });
res.json(result); res.json(result);
} catch (error) { } catch (error) {

View File

@@ -21,12 +21,4 @@ export class AuthController {
res.status(400).json({ error: (error as Error).message }); res.status(400).json({ error: (error as Error).message });
} }
} }
async refresh(req: Request, res: Response): Promise<void> {
throw new Error("Not implemented");
}
async logout(req: Request, res: Response): Promise<void> {
throw new Error("Not implemented");
}
} }

View File

@@ -0,0 +1,103 @@
import { Response } from "express";
import { createLogger } from "../logging/logger";
import { AuthenticatedRequest } from "./AuthMiddleware";
import { CaldavConfig } from "@calchat/shared";
import { CaldavService } from "../services/CaldavService";
const log = createLogger("CaldavController");
export class CaldavController {
constructor(private caldavService: CaldavService) {}
async saveConfig(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const config: CaldavConfig = { userId: req.user!.userId, ...req.body };
const response = await this.caldavService.saveConfig(config);
res.json(response);
} catch (error) {
log.error(
{ err: error, userId: req.user?.userId },
"Error saving config",
);
res.status(500).json({ error: "Failed to save config" });
}
}
async loadConfig(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const config = await this.caldavService.getConfig(req.user!.userId);
if (!config) {
res.status(404).json({ error: "No CalDAV config found" });
return;
}
// Don't expose the password to the client
res.json(config);
} catch (error) {
log.error(
{ err: error, userId: req.user?.userId },
"Error loading config",
);
res.status(500).json({ error: "Failed to load config" });
}
}
async deleteConfig(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
await this.caldavService.deleteConfig(req.user!.userId);
res.status(204).send();
} catch (error) {
log.error(
{ err: error, userId: req.user?.userId },
"Error deleting config",
);
res.status(500).json({ error: "Failed to delete config" });
}
}
async pullEvents(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const events = await this.caldavService.pullEvents(req.user!.userId);
res.json(events);
} catch (error) {
log.error(
{ err: error, userId: req.user?.userId },
"Error pulling events",
);
res.status(500).json({ error: "Failed to pull events" });
}
}
async pushEvents(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
await this.caldavService.pushAll(req.user!.userId);
res.status(204).send();
} catch (error) {
log.error(
{ err: error, userId: req.user?.userId },
"Error pushing events",
);
res.status(500).json({ error: "Failed to push events" });
}
}
async pushEvent(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const event = await this.caldavService.findEventByCaldavUUID(
req.user!.userId,
req.params.caldavUUID,
);
if (!event) {
res.status(404).json({ error: "Event not found" });
return;
}
await this.caldavService.pushEvent(req.user!.userId, event);
res.status(204).send();
} catch (error) {
log.error(
{ err: error, userId: req.user?.userId },
"Error pushing event",
);
res.status(500).json({ error: "Failed to push event" });
}
}
}

View File

@@ -5,15 +5,20 @@ import {
UpdateEventDTO, UpdateEventDTO,
EventAction, EventAction,
GetMessagesOptions, GetMessagesOptions,
RecurringDeleteMode,
} from "@calchat/shared"; } from "@calchat/shared";
import { ChatService } from "../services"; import { ChatService } from "../services";
import { CaldavService } from "../services/CaldavService";
import { createLogger } from "../logging"; import { createLogger } from "../logging";
import { AuthenticatedRequest } from "./AuthMiddleware"; import { AuthenticatedRequest } from "./AuthMiddleware";
const log = createLogger("ChatController"); const log = createLogger("ChatController");
export class ChatController { export class ChatController {
constructor(private chatService: ChatService) {} constructor(
private chatService: ChatService,
private caldavService: CaldavService,
) {}
async sendMessage(req: AuthenticatedRequest, res: Response): Promise<void> { async sendMessage(req: AuthenticatedRequest, res: Response): Promise<void> {
try { try {
@@ -22,7 +27,10 @@ export class ChatController {
const response = await this.chatService.processMessage(userId, data); const response = await this.chatService.processMessage(userId, data);
res.json(response); res.json(response);
} catch (error) { } catch (error) {
log.error({ error, userId: req.user?.userId }, "Error processing message"); log.error(
{ err: error, userId: req.user?.userId },
"Error processing message",
);
res.status(500).json({ error: "Failed to process message" }); res.status(500).json({ error: "Failed to process message" });
} }
} }
@@ -31,12 +39,26 @@ export class ChatController {
try { try {
const userId = req.user!.userId; const userId = req.user!.userId;
const { conversationId, messageId } = req.params; const { conversationId, messageId } = req.params;
const { proposalId, action, event, eventId, updates } = req.body as {
// DEBUG: Log incoming request body to trace deleteMode issue
log.debug({ body: req.body }, "confirmEvent request body");
const {
proposalId,
action,
event,
eventId,
updates,
deleteMode,
occurrenceDate,
} = req.body as {
proposalId: string; proposalId: string;
action: EventAction; action: EventAction;
event?: CreateEventDTO; event?: CreateEventDTO;
eventId?: string; eventId?: string;
updates?: UpdateEventDTO; updates?: UpdateEventDTO;
deleteMode?: RecurringDeleteMode;
occurrenceDate?: string;
}; };
const response = await this.chatService.confirmEvent( const response = await this.chatService.confirmEvent(
userId, userId,
@@ -47,10 +69,25 @@ export class ChatController {
event, event,
eventId, eventId,
updates, updates,
deleteMode,
occurrenceDate,
); );
// Sync confirmed event to CalDAV
try {
if (await this.caldavService.getConfig(userId)) {
await this.caldavService.pushAll(userId);
}
} catch (error) {
log.error({ err: error, userId }, "CalDAV push after confirm failed");
}
res.json(response); res.json(response);
} catch (error) { } catch (error) {
log.error({ error, conversationId: req.params.conversationId }, "Error confirming event"); log.error(
{ err: error, conversationId: req.params.conversationId },
"Error confirming event",
);
res.status(500).json({ error: "Failed to confirm event" }); res.status(500).json({ error: "Failed to confirm event" });
} }
} }
@@ -68,7 +105,10 @@ export class ChatController {
); );
res.json(response); res.json(response);
} catch (error) { } catch (error) {
log.error({ error, conversationId: req.params.conversationId }, "Error rejecting event"); log.error(
{ err: error, conversationId: req.params.conversationId },
"Error rejecting event",
);
res.status(500).json({ error: "Failed to reject event" }); res.status(500).json({ error: "Failed to reject event" });
} }
} }
@@ -82,7 +122,10 @@ export class ChatController {
const conversations = await this.chatService.getConversations(userId); const conversations = await this.chatService.getConversations(userId);
res.json(conversations); res.json(conversations);
} catch (error) { } catch (error) {
log.error({ error, userId: req.user?.userId }, "Error getting conversations"); log.error(
{ err: error, userId: req.user?.userId },
"Error getting conversations",
);
res.status(500).json({ error: "Failed to get conversations" }); res.status(500).json({ error: "Failed to get conversations" });
} }
} }
@@ -113,9 +156,41 @@ export class ChatController {
if ((error as Error).message === "Conversation not found") { if ((error as Error).message === "Conversation not found") {
res.status(404).json({ error: "Conversation not found" }); res.status(404).json({ error: "Conversation not found" });
} else { } else {
log.error({ error, conversationId: req.params.id }, "Error getting conversation"); log.error(
{ err: error, conversationId: req.params.id },
"Error getting conversation",
);
res.status(500).json({ error: "Failed to get conversation" }); res.status(500).json({ error: "Failed to get conversation" });
} }
} }
} }
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(
{ err: error, messageId: req.params.messageId },
"Error updating proposal event",
);
res.status(500).json({ error: "Failed to update proposal event" });
}
}
} }

View File

@@ -1,19 +1,49 @@
import { Response } from "express"; import { Response } from "express";
import { CalendarEvent, RecurringDeleteMode } from "@calchat/shared";
import { EventService } from "../services"; import { EventService } from "../services";
import { createLogger } from "../logging"; import { createLogger } from "../logging";
import { AuthenticatedRequest } from "./AuthMiddleware"; import { AuthenticatedRequest } from "./AuthMiddleware";
import { CaldavService } from "../services/CaldavService";
const log = createLogger("EventController"); const log = createLogger("EventController");
export class EventController { export class EventController {
constructor(private eventService: EventService) {} constructor(
private eventService: EventService,
private caldavService: CaldavService,
) {}
private async pushToCaldav(userId: string, event: CalendarEvent) {
if (await this.caldavService.getConfig(userId)) {
try {
await this.caldavService.pushEvent(userId, event);
} catch (error) {
log.error({ err: error, userId }, "Error pushing event to CalDAV");
}
}
}
private async deleteFromCaldav(userId: string, event: CalendarEvent) {
if (event.caldavUUID && (await this.caldavService.getConfig(userId))) {
try {
await this.caldavService.deleteEvent(userId, event.caldavUUID);
} catch (error) {
log.error({ err: error, userId }, "Error deleting event from CalDAV");
}
}
}
async create(req: AuthenticatedRequest, res: Response): Promise<void> { async create(req: AuthenticatedRequest, res: Response): Promise<void> {
try { try {
const event = await this.eventService.create(req.user!.userId, req.body); const userId = req.user!.userId;
const event = await this.eventService.create(userId, req.body);
await this.pushToCaldav(userId, event);
res.status(201).json(event); res.status(201).json(event);
} catch (error) { } catch (error) {
log.error({ error, userId: req.user?.userId }, "Error creating event"); log.error(
{ err: error, userId: req.user?.userId },
"Error creating event",
);
res.status(500).json({ error: "Failed to create event" }); res.status(500).json({ error: "Failed to create event" });
} }
} }
@@ -30,7 +60,7 @@ export class EventController {
} }
res.json(event); res.json(event);
} catch (error) { } catch (error) {
log.error({ error, eventId: req.params.id }, "Error getting event"); log.error({ err: error, eventId: req.params.id }, "Error getting event");
res.status(500).json({ error: "Failed to get event" }); res.status(500).json({ error: "Failed to get event" });
} }
} }
@@ -40,7 +70,10 @@ export class EventController {
const events = await this.eventService.getAll(req.user!.userId); const events = await this.eventService.getAll(req.user!.userId);
res.json(events); res.json(events);
} catch (error) { } catch (error) {
log.error({ error, userId: req.user?.userId }, "Error getting events"); log.error(
{ err: error, userId: req.user?.userId },
"Error getting events",
);
res.status(500).json({ error: "Failed to get events" }); res.status(500).json({ error: "Failed to get events" });
} }
} }
@@ -72,42 +105,79 @@ export class EventController {
); );
res.json(events); res.json(events);
} catch (error) { } catch (error) {
log.error({ error, start: req.query.start, end: req.query.end }, "Error getting events by range"); log.error(
{ err: error, start: req.query.start, end: req.query.end },
"Error getting events by range",
);
res.status(500).json({ error: "Failed to get events" }); res.status(500).json({ error: "Failed to get events" });
} }
} }
async update(req: AuthenticatedRequest, res: Response): Promise<void> { async update(req: AuthenticatedRequest, res: Response): Promise<void> {
try { try {
const userId = req.user!.userId;
const event = await this.eventService.update( const event = await this.eventService.update(
req.params.id, req.params.id,
req.user!.userId, userId,
req.body, req.body,
); );
if (!event) { if (!event) {
res.status(404).json({ error: "Event not found" }); res.status(404).json({ error: "Event not found" });
return; return;
} }
await this.pushToCaldav(userId, event);
res.json(event); res.json(event);
} catch (error) { } catch (error) {
log.error({ error, eventId: req.params.id }, "Error updating event"); log.error({ err: error, eventId: req.params.id }, "Error updating event");
res.status(500).json({ error: "Failed to update event" }); res.status(500).json({ error: "Failed to update event" });
} }
} }
async delete(req: AuthenticatedRequest, res: Response): Promise<void> { async delete(req: AuthenticatedRequest, res: Response): Promise<void> {
try { try {
const deleted = await this.eventService.delete( const userId = req.user!.userId;
req.params.id, const { mode, occurrenceDate } = req.query as {
req.user!.userId, mode?: RecurringDeleteMode;
); occurrenceDate?: string;
if (!deleted) { };
// Fetch event before deletion to get caldavUUID for sync
const event = await this.eventService.getById(req.params.id, userId);
if (!event) {
res.status(404).json({ error: "Event not found" }); res.status(404).json({ error: "Event not found" });
return; return;
} }
// If mode is specified, use deleteRecurring
if (mode) {
const result = await this.eventService.deleteRecurring(
req.params.id,
userId,
mode,
occurrenceDate,
);
// Event was updated (single/future mode) - push update to CalDAV
if (result) {
await this.pushToCaldav(userId, result);
res.json(result);
return;
}
// Event was fully deleted (all mode, or future from first occurrence)
await this.deleteFromCaldav(userId, event);
res.status(204).send();
return;
}
// Default behavior: delete completely
await this.eventService.delete(req.params.id, userId);
await this.deleteFromCaldav(userId, event);
res.status(204).send(); res.status(204).send();
} catch (error) { } catch (error) {
log.error({ error, eventId: req.params.id }, "Error deleting event"); log.error({ err: error, eventId: req.params.id }, "Error deleting event");
res.status(500).json({ error: "Failed to delete event" }); res.status(500).json({ error: "Failed to delete event" });
} }
} }

View File

@@ -3,3 +3,4 @@ export * from "./ChatController";
export * from "./EventController"; export * from "./EventController";
export * from "./AuthMiddleware"; export * from "./AuthMiddleware";
export * from "./LoggingMiddleware"; export * from "./LoggingMiddleware";
export * from "./CaldavController";

View File

@@ -4,7 +4,7 @@ import { createLogger } from "./logger";
* Summarize args for logging to avoid huge log entries. * Summarize args for logging to avoid huge log entries.
* - Arrays: show length only * - Arrays: show length only
* - Long strings: truncate * - Long strings: truncate
* - Objects with conversationHistory/existingEvents: summarize * - Objects with conversationHistory: summarize
*/ */
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
function summarizeArgs(args: any[]): any[] { function summarizeArgs(args: any[]): any[] {
@@ -31,8 +31,6 @@ function summarizeValue(value: any, depth = 0): any {
for (const [key, val] of Object.entries(value)) { for (const [key, val] of Object.entries(value)) {
if (key === "conversationHistory" && Array.isArray(val)) { if (key === "conversationHistory" && Array.isArray(val)) {
summarized[key] = `[${val.length} messages]`; summarized[key] = `[${val.length} messages]`;
} else if (key === "existingEvents" && Array.isArray(val)) {
summarized[key] = `[${val.length} events]`;
} else if (key === "proposedChanges" && Array.isArray(val)) { } else if (key === "proposedChanges" && Array.isArray(val)) {
// Log full proposedChanges for debugging AI issues // Log full proposedChanges for debugging AI issues
summarized[key] = val.map((p) => summarizeValue(p, depth + 1)); summarized[key] = val.map((p) => summarizeValue(p, depth + 1));
@@ -80,7 +78,10 @@ export function Logged(name: string) {
const method = String(propKey); const method = String(propKey);
// Summarize args to avoid huge log entries // Summarize args to avoid huge log entries
log.debug({ method, args: summarizeArgs(methodArgs) }, `${method} started`); log.debug(
{ method, args: summarizeArgs(methodArgs) },
`${method} started`,
);
const logCompletion = (err?: unknown) => { const logCompletion = (err?: unknown) => {
const duration = Math.round(performance.now() - start); const duration = Math.round(performance.now() - start);

View File

@@ -0,0 +1,31 @@
import { CaldavConfig } from "@calchat/shared";
import { Logged } from "../../logging/Logged";
import { CaldavRepository } from "../../services/interfaces/CaldavRepository";
import { CaldavConfigModel } from "./models/CaldavConfigModel";
@Logged("MongoCaldavRepository")
export class MongoCaldavRepository implements CaldavRepository {
async findByUserId(userId: string): Promise<CaldavConfig | null> {
const config = await CaldavConfigModel.findOne({ userId });
if (!config) return null;
return config.toJSON() as unknown as CaldavConfig;
}
async createOrUpdate(config: CaldavConfig): Promise<CaldavConfig> {
const caldavConfig = await CaldavConfigModel.findOneAndUpdate(
{ userId: config.userId },
config,
{
upsert: true,
new: true,
},
);
// NOTE: Casting required because Mongoose's toJSON() type doesn't reflect our virtual 'id' field
return caldavConfig.toJSON() as unknown as CaldavConfig;
}
async deleteByUserId(userId: string): Promise<boolean> {
const result = await CaldavConfigModel.findOneAndDelete({ userId });
return result !== null;
}
}

View File

@@ -2,8 +2,10 @@ import {
ChatMessage, ChatMessage,
Conversation, Conversation,
CreateMessageDTO, CreateMessageDTO,
CreateEventDTO,
GetMessagesOptions, GetMessagesOptions,
UpdateMessageDTO, UpdateMessageDTO,
ConflictingEvent,
} from "@calchat/shared"; } from "@calchat/shared";
import { ChatRepository } from "../../services/interfaces"; import { ChatRepository } from "../../services/interfaces";
import { Logged } from "../../logging"; import { Logged } from "../../logging";
@@ -24,12 +26,20 @@ export class MongoChatRepository implements ChatRepository {
return conversation.toJSON() as unknown as Conversation; return conversation.toJSON() as unknown as Conversation;
} }
async getConversationById(
conversationId: string,
): Promise<Conversation | null> {
const conversation = await ConversationModel.findById(conversationId);
return conversation
? (conversation.toJSON() as unknown as Conversation)
: null;
}
// Messages (cursor-based pagination) // Messages (cursor-based pagination)
async getMessages( async getMessages(
conversationId: string, conversationId: string,
options?: GetMessagesOptions, options?: GetMessagesOptions,
): Promise<ChatMessage[]> { ): Promise<ChatMessage[]> {
const limit = options?.limit ?? 20;
const query: Record<string, unknown> = { conversationId }; const query: Record<string, unknown> = { conversationId };
// Cursor: load messages before this ID (for "load more" scrolling up) // Cursor: load messages before this ID (for "load more" scrolling up)
@@ -38,9 +48,12 @@ export class MongoChatRepository implements ChatRepository {
} }
// Fetch newest first, then reverse for chronological order // Fetch newest first, then reverse for chronological order
const docs = await ChatMessageModel.find(query) // Only apply limit if explicitly specified (no default - load all messages)
.sort({ _id: -1 }) let queryBuilder = ChatMessageModel.find(query).sort({ _id: -1 });
.limit(limit); if (options?.limit) {
queryBuilder = queryBuilder.limit(options.limit);
}
const docs = await queryBuilder;
return docs.reverse().map((doc) => doc.toJSON() as unknown as ChatMessage); return docs.reverse().map((doc) => doc.toJSON() as unknown as ChatMessage);
} }
@@ -82,4 +95,33 @@ export class MongoChatRepository implements ChatRepository {
); );
return doc ? (doc.toJSON() as unknown as ChatMessage) : null; return doc ? (doc.toJSON() as unknown as ChatMessage) : null;
} }
async updateProposalEvent(
messageId: string,
proposalId: string,
event: CreateEventDTO,
conflictingEvents?: ConflictingEvent[],
): Promise<ChatMessage | null> {
// Always set both fields - use empty array when no conflicts
// (MongoDB has issues combining $set and $unset on positional operator)
const setFields: Record<string, unknown> = {
"proposedChanges.$.event": event,
"proposedChanges.$.conflictingEvents":
conflictingEvents && conflictingEvents.length > 0
? conflictingEvents
: [],
};
const doc = await ChatMessageModel.findOneAndUpdate(
{ _id: messageId, "proposedChanges.id": proposalId },
{ $set: setFields },
{ new: true },
);
return doc ? (doc.toJSON() as unknown as ChatMessage) : null;
}
async getMessageById(messageId: string): Promise<ChatMessage | null> {
const doc = await ChatMessageModel.findById(messageId);
return doc ? (doc.toJSON() as unknown as ChatMessage) : null;
}
} }

View File

@@ -28,6 +28,23 @@ export class MongoEventRepository implements EventRepository {
return events.map((e) => e.toJSON() as unknown as CalendarEvent); return events.map((e) => e.toJSON() as unknown as CalendarEvent);
} }
async findByCaldavUUID(
userId: string,
caldavUUID: string,
): Promise<CalendarEvent | null> {
const event = await EventModel.findOne({ userId, caldavUUID });
if (!event) return null;
return event.toJSON() as unknown as CalendarEvent;
}
async searchByTitle(userId: string, query: string): Promise<CalendarEvent[]> {
const events = await EventModel.find({
userId,
title: { $regex: query, $options: "i" },
}).sort({ startTime: 1 });
return events.map((e) => e.toJSON() as unknown as CalendarEvent);
}
async create(userId: string, data: CreateEventDTO): Promise<CalendarEvent> { async create(userId: string, data: CreateEventDTO): Promise<CalendarEvent> {
const event = await EventModel.create({ userId, ...data }); const event = await EventModel.create({ userId, ...data });
// NOTE: Casting required because Mongoose's toJSON() type doesn't reflect our virtual 'id' field // NOTE: Casting required because Mongoose's toJSON() type doesn't reflect our virtual 'id' field
@@ -47,4 +64,17 @@ export class MongoEventRepository implements EventRepository {
const result = await EventModel.findByIdAndDelete(id); const result = await EventModel.findByIdAndDelete(id);
return result !== null; return result !== null;
} }
async addExceptionDate(
id: string,
date: string,
): Promise<CalendarEvent | null> {
const event = await EventModel.findByIdAndUpdate(
id,
{ $addToSet: { exceptionDates: date } },
{ new: true },
);
if (!event) return null;
return event.toJSON() as unknown as CalendarEvent;
}
} }

View File

@@ -0,0 +1,22 @@
import { CaldavConfig } from "@calchat/shared";
import mongoose, { Document, Schema } from "mongoose";
export interface CaldavConfigDocument extends CaldavConfig, Document {
toJSON(): CaldavConfig;
}
const CaldavConfigSchema = new Schema<CaldavConfigDocument>(
{
userId: { type: String, required: true, index: true },
serverUrl: { type: String, required: true },
username: { type: String, required: true },
password: { type: String, required: true },
syncIntervalSeconds: { type: Number },
},
{ _id: false },
);
export const CaldavConfigModel = mongoose.model<CaldavConfigDocument>(
"CaldavConfig",
CaldavConfigSchema,
);

View File

@@ -5,6 +5,7 @@ import {
CreateEventDTO, CreateEventDTO,
UpdateEventDTO, UpdateEventDTO,
ProposedEventChange, ProposedEventChange,
ConflictingEvent,
} from "@calchat/shared"; } from "@calchat/shared";
import { IdVirtual } from "./types"; import { IdVirtual } from "./types";
@@ -23,8 +24,8 @@ const EventSchema = new Schema<CreateEventDTO>(
startTime: { type: Date, required: true }, startTime: { type: Date, required: true },
endTime: { type: Date, required: true }, endTime: { type: Date, required: true },
note: { type: String }, note: { type: String },
isRecurring: { type: Boolean },
recurrenceRule: { type: String }, recurrenceRule: { type: String },
exceptionDates: { type: [String] },
}, },
{ _id: false }, { _id: false },
); );
@@ -36,12 +37,20 @@ const UpdatesSchema = new Schema<UpdateEventDTO>(
startTime: { type: Date }, startTime: { type: Date },
endTime: { type: Date }, endTime: { type: Date },
note: { type: String }, note: { type: String },
isRecurring: { type: Boolean },
recurrenceRule: { type: String }, recurrenceRule: { type: String },
}, },
{ _id: false }, { _id: false },
); );
const ConflictingEventSchema = new Schema<ConflictingEvent>(
{
title: { type: String, required: true },
startTime: { type: Date, required: true },
endTime: { type: Date, required: true },
},
{ _id: false },
);
const ProposedChangeSchema = new Schema<ProposedEventChange>( const ProposedChangeSchema = new Schema<ProposedEventChange>(
{ {
id: { type: String, required: true }, id: { type: String, required: true },
@@ -57,6 +66,12 @@ const ProposedChangeSchema = new Schema<ProposedEventChange>(
type: String, type: String,
enum: ["confirm", "reject"], enum: ["confirm", "reject"],
}, },
deleteMode: {
type: String,
enum: ["single", "future", "all"],
},
occurrenceDate: { type: String },
conflictingEvents: { type: [ConflictingEventSchema] },
}, },
{ _id: false }, { _id: false },
); );

View File

@@ -19,6 +19,12 @@ const EventSchema = new Schema<
required: true, required: true,
index: true, index: true,
}, },
caldavUUID: {
type: String,
},
etag: {
type: String,
},
title: { title: {
type: String, type: String,
required: true, required: true,
@@ -39,13 +45,13 @@ const EventSchema = new Schema<
note: { note: {
type: String, type: String,
}, },
isRecurring: {
type: Boolean,
default: false,
},
recurrenceRule: { recurrenceRule: {
type: String, type: String,
}, },
exceptionDates: {
type: [String],
default: [],
},
}, },
{ {
timestamps: true, timestamps: true,

View File

@@ -6,8 +6,6 @@ export function createAuthRoutes(authController: AuthController): Router {
router.post("/login", (req, res) => authController.login(req, res)); router.post("/login", (req, res) => authController.login(req, res));
router.post("/register", (req, res) => authController.register(req, res)); router.post("/register", (req, res) => authController.register(req, res));
router.post("/refresh", (req, res) => authController.refresh(req, res));
router.post("/logout", (req, res) => authController.logout(req, res));
return router; return router;
} }

View File

@@ -0,0 +1,22 @@
import { Router } from "express";
import { authenticate } from "../controllers";
import { CaldavController } from "../controllers/CaldavController";
export function createCaldavRoutes(caldavController: CaldavController): Router {
const router = Router();
router.use(authenticate);
router.put("/config", (req, res) => caldavController.saveConfig(req, res));
router.get("/config", (req, res) => caldavController.loadConfig(req, res));
router.delete("/config", (req, res) =>
caldavController.deleteConfig(req, res),
);
router.post("/pull", (req, res) => caldavController.pullEvents(req, res));
router.post("/pushAll", (req, res) => caldavController.pushEvents(req, res));
router.post("/push/:caldavUUID", (req, res) =>
caldavController.pushEvent(req, res),
);
return router;
}

View File

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

View File

@@ -6,12 +6,15 @@ import {
AuthController, AuthController,
ChatController, ChatController,
EventController, EventController,
CaldavController,
} from "../controllers"; } from "../controllers";
import { createCaldavRoutes } from "./caldav.routes";
export interface Controllers { export interface Controllers {
authController: AuthController; authController: AuthController;
chatController: ChatController; chatController: ChatController;
eventController: EventController; eventController: EventController;
caldavController: CaldavController;
} }
export function createRoutes(controllers: Controllers): Router { export function createRoutes(controllers: Controllers): Router {
@@ -20,6 +23,7 @@ export function createRoutes(controllers: Controllers): Router {
router.use("/auth", createAuthRoutes(controllers.authController)); router.use("/auth", createAuthRoutes(controllers.authController));
router.use("/chat", createChatRoutes(controllers.chatController)); router.use("/chat", createChatRoutes(controllers.chatController));
router.use("/events", createEventRoutes(controllers.eventController)); router.use("/events", createEventRoutes(controllers.eventController));
router.use("/caldav", createCaldavRoutes(controllers.caldavController));
return router; return router;
} }

View File

@@ -1,6 +1,5 @@
import { User, CreateUserDTO, LoginDTO, AuthResponse } from "@calchat/shared"; import { CreateUserDTO, LoginDTO, AuthResponse } from "@calchat/shared";
import { UserRepository } from "./interfaces"; import { UserRepository } from "./interfaces";
import * as jwt from "../utils/jwt";
import * as password from "../utils/password"; import * as password from "../utils/password";
export class AuthService { export class AuthService {
@@ -45,12 +44,4 @@ export class AuthService {
return { user, accessToken: "" }; return { user, accessToken: "" };
} }
async refreshToken(refreshToken: string): Promise<AuthResponse> {
throw new Error("Not implemented");
}
async logout(userId: string): Promise<void> {
throw new Error("Not implemented");
}
} }

View File

@@ -0,0 +1,271 @@
import crypto from "crypto";
import { DAVClient } from "tsdav";
import ICAL from "ical.js";
import { createLogger } from "../logging/logger";
import { CaldavRepository } from "./interfaces/CaldavRepository";
import {
CalendarEvent,
CreateEventDTO,
CaldavConfig,
formatDateKey,
} from "@calchat/shared";
import { EventService } from "./EventService";
const logger = createLogger("CaldavService");
export class CaldavService {
constructor(
private caldavRepo: CaldavRepository,
private eventService: EventService,
) {}
/**
* Login to CalDAV server and return client + first calendar.
*/
async connect(userId: string) {
const config = await this.caldavRepo.findByUserId(userId);
if (config === null) {
throw new Error(`Coudn't find config by user id ${userId}`);
}
const client = new DAVClient({
serverUrl: config.serverUrl,
credentials: {
username: config.username,
password: config.password,
},
authMethod: "Basic",
defaultAccountType: "caldav",
});
try {
await client.login();
} catch (error) {
throw new Error("Caldav login failed");
}
const calendars = await client.fetchCalendars();
if (calendars.length === 0) {
throw new Error("No calendars found on CalDAV server");
}
return { client, calendar: calendars[0] };
}
/**
* Pull events from CalDAV server and sync with local database.
* - Compares etags to skip unchanged events
* - Creates new or updates existing events in the database
* - Deletes local events that were removed on the CalDAV server
*
* @returns List of newly created or updated events
*/
async pullEvents(userId: string): Promise<CalendarEvent[]> {
const { client, calendar } = await this.connect(userId);
const calendarEvents: CalendarEvent[] = [];
const caldavEventUUIDs = new Set<string>();
const events = await client.fetchCalendarObjects({ calendar });
for (const event of events) {
const etag = event.etag;
const jcal = ICAL.parse(event.data);
const comp = new ICAL.Component(jcal);
// A CalendarObject (.ics file) can contain multiple VEVENTs (e.g.
// recurring events with RECURRENCE-ID exceptions), but the etag belongs
// to the whole file, not individual VEVENTs. We only need the first
// VEVENT since we handle recurrence via RRULE/exceptionDates, not as
// separate events.
const vevent = comp.getFirstSubcomponent("vevent");
if (!vevent) continue;
const icalEvent = new ICAL.Event(vevent);
caldavEventUUIDs.add(icalEvent.uid);
const exceptionDates = vevent
.getAllProperties("exdate")
.flatMap((prop) => prop.getValues())
.map((time: ICAL.Time) => formatDateKey(time.toJSDate()));
const existingEvent = await this.eventService.findByCaldavUUID(
userId,
icalEvent.uid,
);
const didChange = existingEvent?.etag !== etag;
if (existingEvent && !didChange) {
continue;
}
const eventObject: CreateEventDTO = {
caldavUUID: icalEvent.uid,
etag,
title: icalEvent.summary,
description: icalEvent.description,
startTime: icalEvent.startDate.toJSDate(),
endTime: icalEvent.endDate.toJSDate(),
recurrenceRule: vevent.getFirstPropertyValue("rrule")?.toString(),
exceptionDates,
caldavSyncStatus: "synced",
};
const calendarEvent = existingEvent
? await this.eventService.update(existingEvent.id, userId, eventObject)
: await this.eventService.create(userId, eventObject);
if (calendarEvent) {
calendarEvents.push(calendarEvent);
}
}
// delete all events, that got deleted remotely
const localEvents = await this.eventService.getAll(userId);
for (const localEvent of localEvents) {
if (
localEvent.caldavUUID &&
!caldavEventUUIDs.has(localEvent.caldavUUID)
) {
await this.eventService.delete(localEvent.id, userId);
}
}
return calendarEvents;
}
/**
* Push a single event to the CalDAV server.
* Creates a new event if no caldavUUID exists, updates otherwise.
*/
async pushEvent(userId: string, event: CalendarEvent): Promise<void> {
const { client, calendar } = await this.connect(userId);
try {
if (event.caldavUUID) {
await client.updateCalendarObject({
calendarObject: {
url: `${calendar.url}${event.caldavUUID}.ics`,
data: this.toICalString(event.caldavUUID, event),
etag: event.etag || "",
},
});
} else {
const uid = crypto.randomUUID();
await client.createCalendarObject({
calendar,
filename: `${uid}.ics`,
iCalString: this.toICalString(uid, event),
});
await this.eventService.update(event.id, userId, { caldavUUID: uid });
}
// Fetch updated etag from server
const objects = await client.fetchCalendarObjects({ calendar });
const caldavUUID =
event.caldavUUID ||
(await this.eventService.getById(event.id, userId))?.caldavUUID;
const pushed = objects.find((o) => o.data?.includes(caldavUUID!));
await this.eventService.update(event.id, userId, {
etag: pushed?.etag || undefined,
caldavSyncStatus: "synced",
});
} catch (error) {
await this.eventService.update(event.id, userId, {
caldavSyncStatus: "error",
});
throw error;
}
}
/**
* Build an iCalendar string from a CalendarEvent using ical.js.
*/
private toICalString(uid: string, event: CalendarEvent): string {
const vcalendar = new ICAL.Component("vcalendar");
vcalendar.addPropertyWithValue("version", "2.0");
vcalendar.addPropertyWithValue("prodid", "-//CalChat//EN");
const vevent = new ICAL.Component("vevent");
vevent.addPropertyWithValue("uid", uid);
vevent.addPropertyWithValue("summary", event.title);
vevent.addPropertyWithValue(
"dtstart",
ICAL.Time.fromJSDate(new Date(event.startTime)),
);
vevent.addPropertyWithValue(
"dtend",
ICAL.Time.fromJSDate(new Date(event.endTime)),
);
if (event.description) {
vevent.addPropertyWithValue("description", event.description);
}
if (event.recurrenceRule) {
// Strip RRULE: prefix if present — fromString expects only the value part,
// and addPropertyWithValue("rrule", ...) adds the RRULE: prefix automatically.
const rule = event.recurrenceRule.replace(/^RRULE:/i, "");
vevent.addPropertyWithValue("rrule", ICAL.Recur.fromString(rule));
}
if (event.exceptionDates?.length) {
for (const exdate of event.exceptionDates) {
vevent.addPropertyWithValue("exdate", ICAL.Time.fromDateString(exdate));
}
}
vcalendar.addSubcomponent(vevent);
return vcalendar.toString();
}
async pushAll(userId: string): Promise<void> {
const allEvents = await this.eventService.getAll(userId);
for (const event of allEvents) {
if (event.caldavSyncStatus !== "synced") {
await this.pushEvent(userId, event);
}
}
}
async deleteEvent(userId: string, caldavUUID: string) {
const { client, calendar } = await this.connect(userId);
await client.deleteCalendarObject({
calendarObject: {
url: `${calendar.url}${caldavUUID}.ics`,
},
});
}
async findEventByCaldavUUID(userId: string, caldavUUID: string) {
return this.eventService.findByCaldavUUID(userId, caldavUUID);
}
async getConfig(userId: string): Promise<CaldavConfig | null> {
return this.caldavRepo.findByUserId(userId);
}
async saveConfig(config: CaldavConfig): Promise<CaldavConfig> {
const savedConfig = await this.caldavRepo.createOrUpdate(config);
try {
await this.connect(savedConfig.userId);
} catch (error) {
await this.caldavRepo.deleteByUserId(savedConfig.userId);
throw new Error("failed to connect");
}
return savedConfig;
}
async deleteConfig(userId: string) {
return await this.caldavRepo.deleteByUserId(userId);
}
/**
* Sync with CalDAV server if config exists. Silent no-op if no config.
*/
async sync(userId: string): Promise<void> {
const config = await this.getConfig(userId);
if (!config) return;
await this.pushAll(userId);
await this.pullEvents(userId);
}
}

View File

@@ -9,12 +9,17 @@ import {
CreateEventDTO, CreateEventDTO,
UpdateEventDTO, UpdateEventDTO,
EventAction, EventAction,
CreateMessageDTO, RecurringDeleteMode,
ConflictingEvent,
} from "@calchat/shared"; } from "@calchat/shared";
import { ChatRepository, EventRepository, AIProvider } from "./interfaces"; import { ChatRepository, AIProvider } from "./interfaces";
import { EventService } from "./EventService";
import { getWeeksOverview, getMonthOverview } from "../utils/eventFormatters"; import { getWeeksOverview, getMonthOverview } from "../utils/eventFormatters";
type TestResponse = { content: string; proposedChanges?: ProposedEventChange[] }; type TestResponse = {
content: string;
proposedChanges?: ProposedEventChange[];
};
// Test response index (cycles through responses) // Test response index (cycles through responses)
let responseIndex = 0; let responseIndex = 0;
@@ -22,11 +27,35 @@ let responseIndex = 0;
// Static test responses (event proposals) // Static test responses (event proposals)
const staticResponses: TestResponse[] = [ const staticResponses: TestResponse[] = [
// {{{ // {{{
// === MULTI-EVENT TEST RESPONSES === // === SPORT TEST SCENARIO (3 steps) ===
// Response 0: 3 Meetings an verschiedenen Tagen // Response 0: Wiederkehrendes Event - jeden Mittwoch Sport
{ {
content: content:
"Alles klar! Ich erstelle dir 3 Team-Meetings für diese Woche:", "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 3: 3 Meetings an verschiedenen Tagen
{
content: "Alles klar! Ich erstelle dir 3 Team-Meetings für diese Woche:",
proposedChanges: [ proposedChanges: [
{ {
id: "multi-1-a", id: "multi-1-a",
@@ -62,8 +91,7 @@ const staticResponses: TestResponse[] = [
}, },
// Response 1: 5 Termine für einen Projekttag // Response 1: 5 Termine für einen Projekttag
{ {
content: content: "Ich habe deinen kompletten Projekttag am Dienstag geplant:",
"Ich habe deinen kompletten Projekttag am Dienstag geplant:",
proposedChanges: [ proposedChanges: [
{ {
id: "multi-2-a", id: "multi-2-a",
@@ -119,8 +147,7 @@ const staticResponses: TestResponse[] = [
}, },
// Response 2: 2 wiederkehrende Termine // Response 2: 2 wiederkehrende Termine
{ {
content: content: "Ich erstelle dir zwei wiederkehrende Fitness-Termine:",
"Ich erstelle dir zwei wiederkehrende Fitness-Termine:",
proposedChanges: [ proposedChanges: [
{ {
id: "multi-3-a", id: "multi-3-a",
@@ -130,7 +157,6 @@ const staticResponses: TestResponse[] = [
startTime: getDay("Monday", 1, 7, 0), startTime: getDay("Monday", 1, 7, 0),
endTime: getDay("Monday", 1, 8, 0), endTime: getDay("Monday", 1, 8, 0),
description: "Morgen-Yoga", description: "Morgen-Yoga",
isRecurring: true,
recurrenceRule: "FREQ=WEEKLY;BYDAY=MO,WE,FR", recurrenceRule: "FREQ=WEEKLY;BYDAY=MO,WE,FR",
}, },
}, },
@@ -142,7 +168,6 @@ const staticResponses: TestResponse[] = [
startTime: getDay("Tuesday", 1, 18, 0), startTime: getDay("Tuesday", 1, 18, 0),
endTime: getDay("Tuesday", 1, 19, 0), endTime: getDay("Tuesday", 1, 19, 0),
description: "Abendlauf im Park", description: "Abendlauf im Park",
isRecurring: true,
recurrenceRule: "FREQ=WEEKLY;BYDAY=TU,TH", recurrenceRule: "FREQ=WEEKLY;BYDAY=TU,TH",
}, },
}, },
@@ -187,7 +212,6 @@ const staticResponses: TestResponse[] = [
title: "Badezimmer putzen", title: "Badezimmer putzen",
startTime: getDay("Saturday", 1, 10, 0), startTime: getDay("Saturday", 1, 10, 0),
endTime: getDay("Saturday", 1, 11, 0), endTime: getDay("Saturday", 1, 11, 0),
isRecurring: true,
recurrenceRule: "FREQ=WEEKLY;BYDAY=SA", recurrenceRule: "FREQ=WEEKLY;BYDAY=SA",
}, },
}, },
@@ -209,7 +233,8 @@ const staticResponses: TestResponse[] = [
title: "Arzttermin Dr. Müller", title: "Arzttermin Dr. Müller",
startTime: getDay("Wednesday", 1, 9, 30), startTime: getDay("Wednesday", 1, 9, 30),
endTime: getDay("Wednesday", 1, 10, 30), endTime: getDay("Wednesday", 1, 10, 30),
description: "Routineuntersuchung - Versichertenkarte nicht vergessen", description:
"Routineuntersuchung - Versichertenkarte nicht vergessen",
}, },
}, },
], ],
@@ -226,7 +251,6 @@ const staticResponses: TestResponse[] = [
title: "Mamas Geburtstag", title: "Mamas Geburtstag",
startTime: getDay("Thursday", 2, 0, 0), startTime: getDay("Thursday", 2, 0, 0),
endTime: getDay("Thursday", 2, 23, 59), endTime: getDay("Thursday", 2, 23, 59),
isRecurring: true,
recurrenceRule: "FREQ=YEARLY", recurrenceRule: "FREQ=YEARLY",
}, },
}, },
@@ -244,7 +268,6 @@ const staticResponses: TestResponse[] = [
title: "Fitnessstudio Probetraining", title: "Fitnessstudio Probetraining",
startTime: getDay("Tuesday", 1, 18, 0), startTime: getDay("Tuesday", 1, 18, 0),
endTime: getDay("Tuesday", 1, 19, 30), endTime: getDay("Tuesday", 1, 19, 30),
isRecurring: true,
recurrenceRule: "FREQ=WEEKLY;BYDAY=TU;COUNT=8", recurrenceRule: "FREQ=WEEKLY;BYDAY=TU;COUNT=8",
}, },
}, },
@@ -298,7 +321,6 @@ const staticResponses: TestResponse[] = [
title: "Spanischkurs VHS", title: "Spanischkurs VHS",
startTime: getDay("Thursday", 1, 19, 0), startTime: getDay("Thursday", 1, 19, 0),
endTime: getDay("Thursday", 1, 20, 30), endTime: getDay("Thursday", 1, 20, 30),
isRecurring: true,
recurrenceRule: "FREQ=WEEKLY;BYDAY=TH,SA;COUNT=8", recurrenceRule: "FREQ=WEEKLY;BYDAY=TH,SA;COUNT=8",
}, },
}, },
@@ -311,19 +333,136 @@ const staticResponses: TestResponse[] = [
async function getTestResponse( async function getTestResponse(
index: number, index: number,
eventRepo: EventRepository, eventService: EventService,
userId: string, userId: string,
): Promise<TestResponse> { ): Promise<TestResponse> {
const responseIdx = index % staticResponses.length; const responseIdx = index % staticResponses.length;
// Dynamic responses: fetch events from DB and format // === SPORT TEST SCENARIO (Dynamic responses) ===
if (responseIdx === 3) { // Response 1: Add exception to "Sport" (2 weeks later)
return { content: await getWeeksOverview(eventRepo, userId, 2) }; if (responseIdx === 1) {
const events = await eventService.getAll(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.",
};
} }
if (responseIdx === 4) { // Response 2: Add UNTIL to "Sport" (after 6 weeks total)
if (responseIdx === 2) {
const events = await eventService.getAll(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 eventService.getAll(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(eventService, userId, 2) };
}
if (responseIdx === 7) {
// Delete "Meeting mit Jens" // Delete "Meeting mit Jens"
const events = await eventRepo.findByUserId(userId); const events = await eventService.getAll(userId);
const jensEvent = events.find((e) => e.title === "Meeting mit Jens"); const jensEvent = events.find((e) => e.title === "Meeting mit Jens");
if (jensEvent) { if (jensEvent) {
return { return {
@@ -338,7 +477,6 @@ async function getTestResponse(
startTime: jensEvent.startTime, startTime: jensEvent.startTime,
endTime: jensEvent.endTime, endTime: jensEvent.endTime,
description: jensEvent.description, description: jensEvent.description,
isRecurring: jensEvent.isRecurring,
}, },
}, },
], ],
@@ -347,13 +485,13 @@ async function getTestResponse(
return { content: "Ich konnte keinen Termin 'Meeting mit Jens' finden." }; return { content: "Ich konnte keinen Termin 'Meeting mit Jens' finden." };
} }
if (responseIdx === 8) { if (responseIdx === 11) {
return { content: await getWeeksOverview(eventRepo, userId, 1) }; return { content: await getWeeksOverview(eventService, userId, 1) };
} }
if (responseIdx === 10) { if (responseIdx === 13) {
// Update "Telefonat mit Mama" +3 days and change time to 13:00 // Update "Telefonat mit Mama" +3 days and change time to 13:00
const events = await eventRepo.findByUserId(userId); const events = await eventService.getAll(userId);
const mamaEvent = events.find((e) => e.title === "Telefonat mit Mama"); const mamaEvent = events.find((e) => e.title === "Telefonat mit Mama");
if (mamaEvent) { if (mamaEvent) {
const newStart = new Date(mamaEvent.startTime); const newStart = new Date(mamaEvent.startTime);
@@ -384,11 +522,11 @@ async function getTestResponse(
return { content: "Ich konnte keinen Termin 'Telefonat mit Mama' finden." }; return { content: "Ich konnte keinen Termin 'Telefonat mit Mama' finden." };
} }
if (responseIdx === 13) { if (responseIdx === 16) {
const now = new Date(); const now = new Date();
return { return {
content: await getMonthOverview( content: await getMonthOverview(
eventRepo, eventService,
userId, userId,
now.getFullYear(), now.getFullYear(),
now.getMonth(), now.getMonth(),
@@ -402,7 +540,7 @@ async function getTestResponse(
export class ChatService { export class ChatService {
constructor( constructor(
private chatRepo: ChatRepository, private chatRepo: ChatRepository,
private eventRepo: EventRepository, private eventService: EventService,
private aiProvider: AIProvider, private aiProvider: AIProvider,
) {} ) {}
@@ -426,11 +564,14 @@ export class ChatService {
if (process.env.USE_TEST_RESPONSES === "true") { if (process.env.USE_TEST_RESPONSES === "true") {
// Test mode: use static responses // Test mode: use static responses
response = await getTestResponse(responseIndex, this.eventRepo, userId); response = await getTestResponse(
responseIndex,
this.eventService,
userId,
);
responseIndex++; responseIndex++;
} else { } else {
// Production mode: use real AI // Production mode: use real AI
const events = await this.eventRepo.findByUserId(userId);
const history = await this.chatRepo.getMessages(conversationId, { const history = await this.chatRepo.getMessages(conversationId, {
limit: 20, limit: 20,
}); });
@@ -438,8 +579,16 @@ export class ChatService {
response = await this.aiProvider.processMessage(data.content, { response = await this.aiProvider.processMessage(data.content, {
userId, userId,
conversationHistory: history, conversationHistory: history,
existingEvents: events,
currentDate: new Date(), currentDate: new Date(),
fetchEventsInRange: async (start, end) => {
return this.eventService.getByDateRange(userId, start, end);
},
searchEvents: async (query) => {
return this.eventService.searchByTitle(userId, query);
},
fetchEventById: async (eventId) => {
return this.eventService.getById(eventId, userId);
},
}); });
} }
@@ -462,26 +611,51 @@ export class ChatService {
event?: CreateEventDTO, event?: CreateEventDTO,
eventId?: string, eventId?: string,
updates?: UpdateEventDTO, updates?: UpdateEventDTO,
deleteMode?: RecurringDeleteMode,
occurrenceDate?: string,
): Promise<ChatResponse> { ): Promise<ChatResponse> {
// Update specific proposal with respondedAction // Update specific proposal with respondedAction
await this.chatRepo.updateProposalResponse(messageId, proposalId, "confirm"); await this.chatRepo.updateProposalResponse(
messageId,
proposalId,
"confirm",
);
// Perform the actual event operation // Perform the actual event operation
let content: string; let content: string;
if (action === "create" && event) { if (action === "create" && event) {
const createdEvent = await this.eventRepo.create(userId, event); const createdEvent = await this.eventService.create(userId, event);
content = `Der Termin "${createdEvent.title}" wurde erstellt.`; content = `Der Termin "${createdEvent.title}" wurde erstellt.`;
} else if (action === "update" && eventId && updates) { } else if (action === "update" && eventId && updates) {
const updatedEvent = await this.eventRepo.update(eventId, updates); const updatedEvent = await this.eventService.update(
eventId,
userId,
updates,
);
content = updatedEvent content = updatedEvent
? `Der Termin "${updatedEvent.title}" wurde aktualisiert.` ? `Der Termin "${updatedEvent.title}" wurde aktualisiert.`
: "Termin nicht gefunden."; : "Termin nicht gefunden.";
} else if (action === "delete" && eventId) { } else if (action === "delete" && eventId) {
await this.eventRepo.delete(eventId); // Use deleteRecurring for proper handling of recurring events
const mode = deleteMode || "all";
await this.eventService.deleteRecurring(
eventId,
userId,
mode,
occurrenceDate,
);
// Build appropriate response message
let deleteDescription = "";
if (deleteMode === "single") {
deleteDescription = " (dieses Vorkommen)";
} else if (deleteMode === "future") {
deleteDescription = " (dieses und zukünftige Vorkommen)";
}
content = event?.title content = event?.title
? `Der Termin "${event.title}" wurde gelöscht.` ? `Der Termin "${event.title}"${deleteDescription} wurde gelöscht.`
: "Der Termin wurde gelöscht."; : `Der Termin${deleteDescription} wurde gelöscht.`;
} else { } else {
content = "Ungültige Aktion."; content = "Ungültige Aktion.";
} }
@@ -546,4 +720,62 @@ export class ChatService {
return this.chatRepo.getMessages(conversationId, options); return this.chatRepo.getMessages(conversationId, options);
} }
async updateProposalEvent(
messageId: string,
proposalId: string,
event: CreateEventDTO,
): Promise<ChatMessage | null> {
// Get the message to find the conversation
const message = await this.chatRepo.getMessageById(messageId);
if (!message) {
return null;
}
// Get the conversation to find the userId
const conversation = await this.chatRepo.getConversationById(
message.conversationId,
);
if (!conversation) {
return null;
}
const userId = conversation.userId;
// Get event times
const eventStart = new Date(event.startTime);
const eventEnd = new Date(event.endTime);
// Get day range for conflict checking
const dayStart = new Date(eventStart);
dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(dayStart);
dayEnd.setDate(dayStart.getDate() + 1);
// Fetch events for the day
const dayEvents = await this.eventService.getByDateRange(
userId,
dayStart,
dayEnd,
);
// Check for time overlaps (use occurrenceStart/End for expanded recurring events)
const conflicts: ConflictingEvent[] = dayEvents
.filter(
(e) =>
new Date(e.occurrenceStart) < eventEnd &&
new Date(e.occurrenceEnd) > eventStart,
)
.map((e) => ({
title: e.title,
startTime: new Date(e.occurrenceStart),
endTime: new Date(e.occurrenceEnd),
}));
return this.chatRepo.updateProposalEvent(
messageId,
proposalId,
event,
conflicts.length > 0 ? conflicts : undefined,
);
}
} }

View File

@@ -3,7 +3,9 @@ import {
CreateEventDTO, CreateEventDTO,
UpdateEventDTO, UpdateEventDTO,
ExpandedEvent, ExpandedEvent,
RecurringDeleteMode,
} from "@calchat/shared"; } from "@calchat/shared";
import { RRule, rrulestr } from "rrule";
import { EventRepository } from "./interfaces"; import { EventRepository } from "./interfaces";
import { expandRecurringEvents } from "../utils/recurrenceExpander"; import { expandRecurringEvents } from "../utils/recurrenceExpander";
@@ -22,10 +24,21 @@ export class EventService {
return event; return event;
} }
async findByCaldavUUID(
userId: string,
caldavUUID: string,
): Promise<CalendarEvent | null> {
return this.eventRepo.findByCaldavUUID(userId, caldavUUID);
}
async getAll(userId: string): Promise<CalendarEvent[]> { async getAll(userId: string): Promise<CalendarEvent[]> {
return this.eventRepo.findByUserId(userId); return this.eventRepo.findByUserId(userId);
} }
async searchByTitle(userId: string, query: string): Promise<CalendarEvent[]> {
return this.eventRepo.searchByTitle(userId, query);
}
async getByDateRange( async getByDateRange(
userId: string, userId: string,
startDate: Date, startDate: Date,
@@ -35,8 +48,8 @@ export class EventService {
const allEvents = await this.eventRepo.findByUserId(userId); const allEvents = await this.eventRepo.findByUserId(userId);
// Separate recurring and non-recurring events // Separate recurring and non-recurring events
const recurringEvents = allEvents.filter((e) => e.isRecurring); const recurringEvents = allEvents.filter((e) => e.recurrenceRule);
const nonRecurringEvents = allEvents.filter((e) => !e.isRecurring); const nonRecurringEvents = allEvents.filter((e) => !e.recurrenceRule);
// Expand all events (recurring get multiple instances, non-recurring stay as-is) // Expand all events (recurring get multiple instances, non-recurring stay as-is)
const expanded = expandRecurringEvents( const expanded = expandRecurringEvents(
@@ -67,4 +80,96 @@ export class EventService {
} }
return this.eventRepo.delete(id); return this.eventRepo.delete(id);
} }
/**
* Delete a recurring event with different modes:
* - 'all': Delete the entire event (all occurrences)
* - 'single': Add the occurrence date to exception list (EXDATE)
* - 'future': Set UNTIL in RRULE to stop future occurrences
*
* @returns Updated event for 'single'/'future' modes, null for 'all' mode or if not found
*/
async deleteRecurring(
id: string,
userId: string,
mode: RecurringDeleteMode,
occurrenceDate?: string,
): Promise<CalendarEvent | null> {
const event = await this.eventRepo.findById(id);
if (!event || event.userId !== userId) {
return null;
}
// For non-recurring events, always delete completely
if (!event.recurrenceRule) {
await this.eventRepo.delete(id);
return null;
}
switch (mode) {
case "all":
await this.eventRepo.delete(id);
return null;
case "single":
if (!occurrenceDate) {
throw new Error("occurrenceDate required for single delete mode");
}
// Add to exception dates
return this.eventRepo.addExceptionDate(id, occurrenceDate);
case "future":
if (!occurrenceDate) {
throw new Error("occurrenceDate required for future delete mode");
}
// Check if this is the first occurrence
const startDateKey = this.formatDateKey(new Date(event.startTime));
if (occurrenceDate <= startDateKey) {
// Deleting from first occurrence = delete all
await this.eventRepo.delete(id);
return null;
}
// Set UNTIL to the day before the occurrence
const updatedRule = this.addUntilToRRule(
event.recurrenceRule,
occurrenceDate,
);
return this.eventRepo.update(id, { recurrenceRule: updatedRule });
default:
throw new Error(`Unknown delete mode: ${mode}`);
}
}
/**
* Add or replace UNTIL clause in an RRULE string.
* The UNTIL is set to 23:59:59 of the day before the occurrence date.
*/
private addUntilToRRule(ruleString: string, occurrenceDate: string): string {
// Normalize: ensure we have RRULE: prefix for parsing
const normalizedRule = ruleString.replace(/^RRULE:/i, "");
const parsedRule = rrulestr(`RRULE:${normalizedRule}`);
// Calculate the day before the occurrence at 23:59:59
const untilDate = new Date(occurrenceDate);
untilDate.setDate(untilDate.getDate() - 1);
untilDate.setHours(23, 59, 59, 0);
// Create new rule with UNTIL, removing COUNT (they're mutually exclusive)
const newRule = new RRule({
...parsedRule.options,
count: undefined,
until: untilDate,
});
// toString() returns "RRULE:...", we store without prefix
return newRule.toString().replace(/^RRULE:/, "");
}
private formatDateKey(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
} }

View File

@@ -1,14 +1,24 @@
import { import {
CalendarEvent,
ChatMessage, ChatMessage,
ProposedEventChange, ProposedEventChange,
ExpandedEvent,
CalendarEvent,
} from "@calchat/shared"; } from "@calchat/shared";
export interface AIContext { export interface AIContext {
userId: string; userId: string;
conversationHistory: ChatMessage[]; conversationHistory: ChatMessage[];
existingEvents: CalendarEvent[];
currentDate: Date; currentDate: Date;
// Callback to load events from a specific date range
// Returns ExpandedEvent[] with occurrenceStart/occurrenceEnd for recurring events
fetchEventsInRange: (
startDate: Date,
endDate: Date,
) => Promise<ExpandedEvent[]>;
// Callback to search events by title
searchEvents: (query: string) => Promise<CalendarEvent[]>;
// Callback to fetch a single event by ID
fetchEventById: (eventId: string) => Promise<CalendarEvent | null>;
} }
export interface AIResponse { export interface AIResponse {

View File

@@ -0,0 +1,7 @@
import { CaldavConfig } from "@calchat/shared";
export interface CaldavRepository {
findByUserId(userId: string): Promise<CaldavConfig | null>;
createOrUpdate(config: CaldavConfig): Promise<CaldavConfig>;
deleteByUserId(userId: string): Promise<boolean>;
}

View File

@@ -2,13 +2,16 @@ import {
ChatMessage, ChatMessage,
Conversation, Conversation,
CreateMessageDTO, CreateMessageDTO,
CreateEventDTO,
GetMessagesOptions, GetMessagesOptions,
UpdateMessageDTO, UpdateMessageDTO,
ConflictingEvent,
} from "@calchat/shared"; } from "@calchat/shared";
export interface ChatRepository { export interface ChatRepository {
// Conversations // Conversations
getConversationsByUser(userId: string): Promise<Conversation[]>; getConversationsByUser(userId: string): Promise<Conversation[]>;
getConversationById(conversationId: string): Promise<Conversation | null>;
createConversation(userId: string): Promise<Conversation>; createConversation(userId: string): Promise<Conversation>;
// Messages (cursor-based pagination) // Messages (cursor-based pagination)
@@ -32,4 +35,13 @@ export interface ChatRepository {
proposalId: string, proposalId: string,
respondedAction: "confirm" | "reject", respondedAction: "confirm" | "reject",
): Promise<ChatMessage | null>; ): Promise<ChatMessage | null>;
updateProposalEvent(
messageId: string,
proposalId: string,
event: CreateEventDTO,
conflictingEvents?: ConflictingEvent[],
): Promise<ChatMessage | null>;
getMessageById(messageId: string): Promise<ChatMessage | null>;
} }

View File

@@ -8,7 +8,13 @@ export interface EventRepository {
startDate: Date, startDate: Date,
endDate: Date, endDate: Date,
): Promise<CalendarEvent[]>; ): Promise<CalendarEvent[]>;
findByCaldavUUID(
userId: string,
caldavUUID: string,
): Promise<CalendarEvent | null>;
searchByTitle(userId: string, query: string): Promise<CalendarEvent[]>;
create(userId: string, data: CreateEventDTO): Promise<CalendarEvent>; create(userId: string, data: CreateEventDTO): Promise<CalendarEvent>;
update(id: string, data: UpdateEventDTO): Promise<CalendarEvent | null>; update(id: string, data: UpdateEventDTO): Promise<CalendarEvent | null>;
delete(id: string): Promise<boolean>; delete(id: string): Promise<boolean>;
addExceptionDate(id: string, date: string): Promise<CalendarEvent | null>;
} }

View File

@@ -6,7 +6,7 @@ import {
MONTH_TO_GERMAN, MONTH_TO_GERMAN,
ExpandedEvent, ExpandedEvent,
} from "@calchat/shared"; } from "@calchat/shared";
import { EventRepository } from "../services/interfaces"; import { EventService } from "../services/EventService";
import { expandRecurringEvents } from "./recurrenceExpander"; import { expandRecurringEvents } from "./recurrenceExpander";
// Private formatting helpers // Private formatting helpers
@@ -107,13 +107,13 @@ function formatMonthText(events: ExpandedEvent[], monthName: string): string {
* Recurring events are expanded to show all occurrences within the range. * Recurring events are expanded to show all occurrences within the range.
*/ */
export async function getWeeksOverview( export async function getWeeksOverview(
eventRepo: EventRepository, eventService: EventService,
userId: string, userId: string,
weeks: number, weeks: number,
): Promise<string> { ): Promise<string> {
const now = new Date(); const now = new Date();
const endDate = new Date(now.getTime() + weeks * 7 * 24 * 60 * 60 * 1000); const endDate = new Date(now.getTime() + weeks * 7 * 24 * 60 * 60 * 1000);
const events = await eventRepo.findByUserId(userId); const events = await eventService.getAll(userId);
const expanded = expandRecurringEvents(events, now, endDate); const expanded = expandRecurringEvents(events, now, endDate);
return formatWeeksText(expanded, weeks); return formatWeeksText(expanded, weeks);
} }
@@ -123,14 +123,14 @@ export async function getWeeksOverview(
* Recurring events are expanded to show all occurrences within the month. * Recurring events are expanded to show all occurrences within the month.
*/ */
export async function getMonthOverview( export async function getMonthOverview(
eventRepo: EventRepository, eventService: EventService,
userId: string, userId: string,
year: number, year: number,
month: number, month: number,
): Promise<string> { ): Promise<string> {
const startOfMonth = new Date(year, month, 1); const startOfMonth = new Date(year, month, 1);
const endOfMonth = new Date(year, month + 1, 0, 23, 59, 59); const endOfMonth = new Date(year, month + 1, 0, 23, 59, 59);
const events = await eventRepo.findByUserId(userId); const events = await eventService.getAll(userId);
const expanded = expandRecurringEvents(events, startOfMonth, endOfMonth); const expanded = expandRecurringEvents(events, startOfMonth, endOfMonth);
const monthName = MONTH_TO_GERMAN[MONTHS[month]]; const monthName = MONTH_TO_GERMAN[MONTHS[month]];
return formatMonthText(expanded, monthName); return formatMonthText(expanded, monthName);

View File

@@ -1,2 +1 @@
export * from "./jwt";
export * from "./password"; export * from "./password";

View File

@@ -1,26 +0,0 @@
import jwt from "jsonwebtoken";
export interface TokenPayload {
userId: string;
email: string;
}
export interface JWTConfig {
secret: string;
expiresIn: string;
}
const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key";
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "1h";
export function signToken(payload: TokenPayload): string {
throw new Error("Not implemented");
}
export function verifyToken(token: string): TokenPayload {
throw new Error("Not implemented");
}
export function decodeToken(token: string): TokenPayload | null {
throw new Error("Not implemented");
}

View File

@@ -0,0 +1,37 @@
import { hash, compare } from "./password";
describe("password", () => {
describe("hash()", () => {
it("returns a valid bcrypt hash", async () => {
const result = await hash("testpassword");
expect(result).toMatch(/^\$2b\$/);
});
it("produces different hashes for the same password (salt)", async () => {
const hash1 = await hash("samepassword");
const hash2 = await hash("samepassword");
expect(hash1).not.toBe(hash2);
});
});
describe("compare()", () => {
it("returns true for the correct password", async () => {
const hashed = await hash("correct");
const result = await compare("correct", hashed);
expect(result).toBe(true);
});
it("returns false for a wrong password", async () => {
const hashed = await hash("correct");
const result = await compare("wrong", hashed);
expect(result).toBe(false);
});
it("handles special characters and unicode", async () => {
const password = "p@$$w0rd!#%& äöü 🔑";
const hashed = await hash(password);
expect(await compare(password, hashed)).toBe(true);
expect(await compare("other", hashed)).toBe(false);
});
});
});

View File

@@ -0,0 +1,210 @@
import { CalendarEvent } from "@calchat/shared";
import { expandRecurringEvents } from "./recurrenceExpander";
// Helper: create a CalendarEvent with sensible defaults
function makeEvent(
overrides: Partial<CalendarEvent> & { startTime: Date; endTime: Date },
): CalendarEvent {
return {
id: "evt-1",
userId: "user-1",
title: "Test Event",
...overrides,
};
}
// Helper: create a date from "YYYY-MM-DD HH:mm" (local time)
function d(dateStr: string): Date {
const [datePart, timePart] = dateStr.split(" ");
const [y, m, day] = datePart.split("-").map(Number);
if (timePart) {
const [h, min] = timePart.split(":").map(Number);
return new Date(y, m - 1, day, h, min);
}
return new Date(y, m - 1, day);
}
describe("expandRecurringEvents", () => {
// Range: 2025-06-01 to 2025-06-30
const rangeStart = d("2025-06-01 00:00");
const rangeEnd = d("2025-06-30 23:59");
describe("non-recurring events", () => {
it("returns event within the range", () => {
const event = makeEvent({
startTime: d("2025-06-10 09:00"),
endTime: d("2025-06-10 10:00"),
});
const result = expandRecurringEvents([event], rangeStart, rangeEnd);
expect(result).toHaveLength(1);
expect(result[0].occurrenceStart).toEqual(d("2025-06-10 09:00"));
expect(result[0].occurrenceEnd).toEqual(d("2025-06-10 10:00"));
});
it("excludes event outside the range", () => {
const event = makeEvent({
startTime: d("2025-07-05 09:00"),
endTime: d("2025-07-05 10:00"),
});
const result = expandRecurringEvents([event], rangeStart, rangeEnd);
expect(result).toHaveLength(0);
});
it("includes event that starts before range and ends within", () => {
const event = makeEvent({
startTime: d("2025-05-31 22:00"),
endTime: d("2025-06-01 02:00"),
});
const result = expandRecurringEvents([event], rangeStart, rangeEnd);
expect(result).toHaveLength(1);
});
it("includes event that spans the entire range", () => {
const event = makeEvent({
startTime: d("2025-05-01 00:00"),
endTime: d("2025-07-31 23:59"),
});
const result = expandRecurringEvents([event], rangeStart, rangeEnd);
expect(result).toHaveLength(1);
});
it("returns empty array for empty input", () => {
const result = expandRecurringEvents([], rangeStart, rangeEnd);
expect(result).toEqual([]);
});
});
describe("recurring events", () => {
it("expands weekly event to all occurrences in range", () => {
// Weekly on Mondays, starting 2025-06-02 (a Monday)
const event = makeEvent({
startTime: d("2025-06-02 10:00"),
endTime: d("2025-06-02 11:00"),
recurrenceRule: "FREQ=WEEKLY;BYDAY=MO",
});
const result = expandRecurringEvents([event], rangeStart, rangeEnd);
// Mondays in June 2025: 2, 9, 16, 23, 30
expect(result).toHaveLength(5);
expect(result[0].occurrenceStart).toEqual(d("2025-06-02 10:00"));
expect(result[1].occurrenceStart).toEqual(d("2025-06-09 10:00"));
expect(result[2].occurrenceStart).toEqual(d("2025-06-16 10:00"));
expect(result[3].occurrenceStart).toEqual(d("2025-06-23 10:00"));
expect(result[4].occurrenceStart).toEqual(d("2025-06-30 10:00"));
});
it("daily event with UNTIL stops at the right date", () => {
const event = makeEvent({
startTime: d("2025-06-01 08:00"),
endTime: d("2025-06-01 09:00"),
recurrenceRule: "FREQ=DAILY;UNTIL=20250605T235959",
});
const result = expandRecurringEvents([event], rangeStart, rangeEnd);
// June 1, 2, 3, 4, 5
expect(result).toHaveLength(5);
expect(result[4].occurrenceStart).toEqual(d("2025-06-05 08:00"));
});
it("skips occurrences on exception dates (EXDATE)", () => {
const event = makeEvent({
startTime: d("2025-06-02 10:00"),
endTime: d("2025-06-02 11:00"),
recurrenceRule: "FREQ=WEEKLY;BYDAY=MO",
exceptionDates: ["2025-06-09", "2025-06-23"],
});
const result = expandRecurringEvents([event], rangeStart, rangeEnd);
// 5 Mondays minus 2 exceptions = 3
expect(result).toHaveLength(3);
const dates = result.map((r) => r.occurrenceStart.getDate());
expect(dates).toEqual([2, 16, 30]);
});
it("handles RRULE: prefix (strips it)", () => {
const event = makeEvent({
startTime: d("2025-06-01 08:00"),
endTime: d("2025-06-01 09:00"),
recurrenceRule: "RRULE:FREQ=DAILY;COUNT=3",
});
const result = expandRecurringEvents([event], rangeStart, rangeEnd);
expect(result).toHaveLength(3);
});
it("falls back to single occurrence on invalid RRULE", () => {
const consoleSpy = jest.spyOn(console, "error").mockImplementation();
const event = makeEvent({
startTime: d("2025-06-10 09:00"),
endTime: d("2025-06-10 10:00"),
recurrenceRule: "COMPLETELY_INVALID_RULE",
});
const result = expandRecurringEvents([event], rangeStart, rangeEnd);
expect(result).toHaveLength(1);
expect(result[0].occurrenceStart).toEqual(d("2025-06-10 09:00"));
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
describe("multi-day events", () => {
it("finds event starting before range that ends within range", () => {
// 3-day recurring event starting May 15, weekly
const event = makeEvent({
startTime: d("2025-05-15 08:00"),
endTime: d("2025-05-18 08:00"),
recurrenceRule: "FREQ=WEEKLY",
});
// The occurrence starting May 29 ends June 1 → overlaps with range
const result = expandRecurringEvents([event], rangeStart, rangeEnd);
const starts = result.map((r) => r.occurrenceStart.getDate());
// May 29 (ends June 1), June 5, 12, 19, 26
expect(starts).toContain(29); // May 29
});
});
describe("sorting", () => {
it("returns events sorted by occurrenceStart", () => {
const laterEvent = makeEvent({
id: "evt-later",
startTime: d("2025-06-20 14:00"),
endTime: d("2025-06-20 15:00"),
});
const earlierEvent = makeEvent({
id: "evt-earlier",
startTime: d("2025-06-05 09:00"),
endTime: d("2025-06-05 10:00"),
});
// Pass in reverse order
const result = expandRecurringEvents(
[laterEvent, earlierEvent],
rangeStart,
rangeEnd,
);
expect(result).toHaveLength(2);
expect(result[0].id).toBe("evt-earlier");
expect(result[1].id).toBe("evt-later");
});
});
});

View File

@@ -1,5 +1,5 @@
import { RRule, rrulestr } from "rrule"; import { RRule, rrulestr } from "rrule";
import { CalendarEvent, ExpandedEvent } from "@calchat/shared"; import { CalendarEvent, ExpandedEvent, formatDateKey } from "@calchat/shared";
// Convert local time to "fake UTC" for rrule // Convert local time to "fake UTC" for rrule
// rrule interprets all dates as UTC internally, so we need to trick it // rrule interprets all dates as UTC internally, so we need to trick it
@@ -44,9 +44,13 @@ export function expandRecurringEvents(
const endTime = new Date(event.endTime); const endTime = new Date(event.endTime);
const duration = endTime.getTime() - startTime.getTime(); const duration = endTime.getTime() - startTime.getTime();
if (!event.isRecurring || !event.recurrenceRule) { // For multi-day events: adjust range start back by event duration
// Non-recurring event: add as-is if within range // to find events that start before rangeStart but extend into the range
if (startTime >= rangeStart && startTime <= rangeEnd) { const adjustedRangeStart = new Date(rangeStart.getTime() - duration);
if (!event.recurrenceRule) {
// Non-recurring event: add if it overlaps with the range
if (endTime >= rangeStart && startTime <= rangeEnd) {
expanded.push({ expanded.push({
...event, ...event,
occurrenceStart: startTime, occurrenceStart: startTime,
@@ -64,17 +68,33 @@ export function expandRecurringEvents(
`DTSTART:${formatRRuleDateString(startTime)}\nRRULE:${ruleString}`, `DTSTART:${formatRRuleDateString(startTime)}\nRRULE:${ruleString}`,
); );
// Get occurrences within the range (using fake UTC dates) // Get occurrences within the adjusted range (using fake UTC dates)
// Use adjustedRangeStart to catch multi-day events that start before
// rangeStart but still extend into the range
const occurrences = rule.between( const occurrences = rule.between(
toRRuleDate(rangeStart), toRRuleDate(adjustedRangeStart),
toRRuleDate(rangeEnd), toRRuleDate(rangeEnd),
true, // inclusive true, // inclusive
); );
// Build set of exception dates for fast lookup
const exceptionSet = new Set(event.exceptionDates || []);
for (const occurrence of occurrences) { for (const occurrence of occurrences) {
const occurrenceStart = fromRRuleDate(occurrence); const occurrenceStart = fromRRuleDate(occurrence);
const occurrenceEnd = new Date(occurrenceStart.getTime() + duration); const occurrenceEnd = new Date(occurrenceStart.getTime() + duration);
// Only include if occurrence actually overlaps with the original range
if (occurrenceEnd < rangeStart || occurrenceStart > rangeEnd) {
continue;
}
// Skip if this occurrence is in the exception dates
const dateKey = formatDateKey(occurrenceStart);
if (exceptionSet.has(dateKey)) {
continue;
}
expanded.push({ expanded.push({
...event, ...event,
occurrenceStart, occurrenceStart,

View File

@@ -8,7 +8,8 @@
"esModuleInterop": true, "esModuleInterop": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"experimentalDecorators": true, "experimentalDecorators": true,
"emitDecoratorMetadata": true "emitDecoratorMetadata": true,
"skipLibCheck": true
}, },
"include": ["src"] "include": ["src"]
} }

View File

@@ -11,15 +11,13 @@ Base URL: `/api`
|--------|----------|--------------| |--------|----------|--------------|
| POST | `/auth/login` | User Login | | POST | `/auth/login` | User Login |
| POST | `/auth/register` | User Registrierung | | POST | `/auth/register` | User Registrierung |
| POST | `/auth/refresh` | JWT Token erneuern |
| POST | `/auth/logout` | User Logout |
--- ---
## Events ## Events
### Event Endpoints (`/api/events`) ### Event Endpoints (`/api/events`)
Alle Endpoints erfordern JWT-Authentifizierung. Alle Endpoints erfordern Authentifizierung (X-User-Id Header).
| Method | Endpoint | Beschreibung | | Method | Endpoint | Beschreibung |
|--------|----------|--------------| |--------|----------|--------------|
@@ -35,7 +33,7 @@ Alle Endpoints erfordern JWT-Authentifizierung.
## Chat ## Chat
### Chat Endpoints (`/api/chat`) ### Chat Endpoints (`/api/chat`)
Alle Endpoints erfordern JWT-Authentifizierung. Alle Endpoints erfordern Authentifizierung (X-User-Id Header).
| Method | Endpoint | Beschreibung | | Method | Endpoint | Beschreibung |
|--------|----------|--------------| |--------|----------|--------------|

View File

@@ -30,6 +30,8 @@ package "Controller Layer" #ADD8E6 {
} }
class EventController { class EventController {
' -pushToCaldav()
' -deleteFromCaldav()
' +create() ' +create()
' +getById() ' +getById()
' +getAll() ' +getAll()
@@ -38,6 +40,15 @@ package "Controller Layer" #ADD8E6 {
' +delete() ' +delete()
} }
class CaldavController {
' +saveConfig()
' +loadConfig()
' +deleteConfig()
' +pullEvents()
' +pushEvents()
' +pushEvent()
}
class AuthMiddleware { class AuthMiddleware {
' +authenticate() ' +authenticate()
} }
@@ -59,9 +70,12 @@ package "Service Layer" #90EE90 {
' +findById() ' +findById()
' +findByUserId() ' +findByUserId()
' +findByDateRange() ' +findByDateRange()
' +findByCaldavUUID()
' +searchByTitle()
' +create() ' +create()
' +update() ' +update()
' +delete() ' +delete()
' +addExceptionDate()
} }
interface ChatRepository { interface ChatRepository {
@@ -69,6 +83,14 @@ package "Service Layer" #90EE90 {
' +createConversation() ' +createConversation()
' +getMessages() ' +getMessages()
' +createMessage() ' +createMessage()
' +updateProposalResponse()
' +updateProposalEvent()
}
interface CaldavRepository {
' +findByUserId()
' +createOrUpdate()
' +deleteByUserId()
} }
} }
@@ -80,7 +102,7 @@ package "Service Layer" #90EE90 {
class ChatService { class ChatService {
' -chatRepo: ChatRepository ' -chatRepo: ChatRepository
' -eventRepo: EventRepository ' -eventService: EventService
' -aiProvider: AIProvider ' -aiProvider: AIProvider
' +processMessage() ' +processMessage()
' +confirmEvent() ' +confirmEvent()
@@ -95,13 +117,29 @@ package "Service Layer" #90EE90 {
' +getById() ' +getById()
' +getAll() ' +getAll()
' +getByDateRange() ' +getByDateRange()
' +searchByTitle()
' +findByCaldavUUID()
' +update() ' +update()
' +delete() ' +delete()
' +deleteRecurring()
}
class CaldavService {
' -caldavRepo: CaldavRepository
' -eventService: EventService
' +connect()
' +pullEvents()
' +pushEvent()
' +pushAll()
' +deleteEvent()
' +getConfig()
' +saveConfig()
' +deleteConfig()
} }
} }
package "AI Implementations" #FFA07A { package "AI Implementations" #FFA07A {
class ClaudeAdapter implements AIProvider { class GPTAdapter implements AIProvider {
' -apiKey: string ' -apiKey: string
' +processMessage() ' +processMessage()
} }
@@ -119,6 +157,10 @@ package "Data Access Implementations" #FFD700 {
class MongoChatRepository implements ChatRepository { class MongoChatRepository implements ChatRepository {
' -model: ChatModel ' -model: ChatModel
} }
class MongoCaldavRepository implements CaldavRepository {
' -model: CaldavConfigModel
}
} }
package "Models" #D3D3D3 { package "Models" #D3D3D3 {
@@ -146,11 +188,6 @@ package "Models" #D3D3D3 {
} }
package "Utils" #DDA0DD { package "Utils" #DDA0DD {
class JWT {
' +signToken()
' +verifyToken()
}
class Password { class Password {
' +hash() ' +hash()
' +compare() ' +compare()
@@ -169,18 +206,20 @@ package "Utils" #DDA0DD {
' Controller -> Service ' Controller -> Service
AuthController --> AuthService AuthController --> AuthService
ChatController --> ChatService ChatController --> ChatService
ChatController --> CaldavService
EventController --> EventService EventController --> EventService
AuthMiddleware --> JWT EventController --> CaldavService
CaldavController --> CaldavService
' Service -> Interfaces (intern) ' Service -> Interfaces (intern)
AuthService --> UserRepository AuthService --> UserRepository
ChatService --> ChatRepository ChatService --> ChatRepository
ChatService --> EventRepository ChatService --> EventService
ChatService --> AIProvider ChatService --> AIProvider
EventService --> EventRepository EventService --> EventRepository
CaldavService --> CaldavRepository
CaldavService --> EventService
' Auth uses Utils ' Auth uses Utils
AuthService --> JWT
AuthService --> Password AuthService --> Password
' Event/Chat uses Utils ' Event/Chat uses Utils

View File

@@ -16,6 +16,8 @@ package "apps/client (Expo React Native)" as ClientPkg #87CEEB {
[Login/Register] as AuthScreens [Login/Register] as AuthScreens
[Calendar View] as CalendarScreen [Calendar View] as CalendarScreen
[Chat View] as ChatScreen [Chat View] as ChatScreen
[Settings] as SettingsScreen
[Edit Event] as EditEventScreen
[Event Detail] as EventDetail [Event Detail] as EventDetail
[Note Editor] as NoteScreen [Note Editor] as NoteScreen
} }
@@ -25,17 +27,20 @@ package "apps/client (Expo React Native)" as ClientPkg #87CEEB {
[Auth Service] as ClientAuth [Auth Service] as ClientAuth
[Event Service] as ClientEvent [Event Service] as ClientEvent
[Chat Service] as ClientChat [Chat Service] as ClientChat
[Caldav Config Service] as ClientCaldav
} }
package "Components" { package "Components" {
[UI Components] as UIComponents [UI Components] as UIComponents
[Event Cards] as EventCards [Event Cards] as EventCards
[Auth Guard] as AuthGuard
} }
package "Stores" { package "Stores" {
[Auth Store] as AuthStore [Auth Store] as AuthStore
[Events Store] as EventsStore [Events Store] as EventsStore
[Chat Store] as ChatStore [Chat Store] as ChatStore
[Theme Store] as ThemeStore
} }
} }
@@ -59,10 +64,11 @@ package "apps/server (Express.js)" as ServerPkg #98FB98 {
[AuthService] as AuthSvc [AuthService] as AuthSvc
[ChatService] as ChatSvc [ChatService] as ChatSvc
[EventService] as EventSvc [EventService] as EventSvc
[CaldavService] as CaldavSvc
} }
package "AI Implementations" { package "AI Implementations" {
[ClaudeAdapter] as Claude [GPTAdapter] as GPT
} }
package "Data Access Implementations" { package "Data Access Implementations" {
@@ -71,7 +77,6 @@ package "apps/server (Express.js)" as ServerPkg #98FB98 {
} }
package "Utils" { package "Utils" {
[JWT] as JWTUtil
[Password] as PwdUtil [Password] as PwdUtil
[RecurrenceExpander] as RecExpander [RecurrenceExpander] as RecExpander
[EventFormatters] as EvtFormatters [EventFormatters] as EvtFormatters
@@ -80,25 +85,35 @@ package "apps/server (Express.js)" as ServerPkg #98FB98 {
' ===== ROW 4: EXTERNAL ===== ' ===== ROW 4: EXTERNAL =====
database "MongoDB" as MongoDB database "MongoDB" as MongoDB
cloud "Claude API" as ClaudeAPI cloud "OpenAI API" as OpenAIAPI
cloud "CalDAV Server" as CaldavServer
' ===== CONNECTIONS ===== ' ===== CONNECTIONS =====
' Frontend: Screens -> Services ' Frontend: Screens -> Services
AuthScreens --> ClientAuth AuthScreens --> ClientAuth
CalendarScreen --> ClientEvent CalendarScreen --> ClientEvent
CalendarScreen --> ClientCaldav
ChatScreen --> ClientChat ChatScreen --> ClientChat
SettingsScreen --> ClientCaldav
EditEventScreen --> ClientEvent
EventDetail --> ClientEvent EventDetail --> ClientEvent
NoteScreen --> ClientEvent NoteScreen --> ClientEvent
ClientAuth --> ApiClient ClientAuth --> ApiClient
ClientEvent --> ApiClient ClientEvent --> ApiClient
ClientChat --> ApiClient ClientChat --> ApiClient
ClientCaldav --> ApiClient
ApiClient --> AuthStore ApiClient --> AuthStore
ClientEvent --> EventsStore ClientEvent --> EventsStore
ClientChat --> ChatStore ClientChat --> ChatStore
' Frontend: Auth
AuthGuard --> AuthStore
AuthGuard --> ClientCaldav
AuthScreens --> ClientCaldav
' Frontend: Screens -> Components ' Frontend: Screens -> Components
CalendarScreen --> EventCards CalendarScreen --> EventCards
ChatScreen --> EventCards ChatScreen --> EventCards
@@ -121,20 +136,24 @@ Routes --> Controllers
Controllers --> AuthSvc Controllers --> AuthSvc
Controllers --> ChatSvc Controllers --> ChatSvc
Controllers --> EventSvc Controllers --> EventSvc
Controllers --> CaldavSvc
' Backend: Service -> Interfaces ' Backend: Service -> Interfaces
AuthSvc --> Interfaces AuthSvc --> Interfaces
ChatSvc --> Interfaces ChatSvc --> Interfaces
EventSvc --> Interfaces EventSvc --> Interfaces
CaldavSvc --> Interfaces
' Backend: Service dependencies
ChatSvc --> EventSvc
CaldavSvc --> EventSvc
' Backend: AI & Data Access implement Interfaces ' Backend: AI & Data Access implement Interfaces
Claude ..|> Interfaces GPT ..|> Interfaces
Repos ..|> Interfaces Repos ..|> Interfaces
' Backend: Service -> Utils ' Backend: Service -> Utils
AuthSvc --> JWTUtil
AuthSvc --> PwdUtil AuthSvc --> PwdUtil
Middleware --> JWTUtil
EventSvc --> RecExpander EventSvc --> RecExpander
ChatSvc --> EvtFormatters ChatSvc --> EvtFormatters
@@ -143,6 +162,7 @@ Repos --> Schemas
' Backend -> External ' Backend -> External
Schemas --> MongoDB Schemas --> MongoDB
Claude --> ClaudeAPI GPT --> OpenAIAPI
CaldavSvc --> CaldavServer
@enduml @enduml

View File

@@ -12,58 +12,73 @@ skinparam wrapWidth 100
skinparam nodesep 30 skinparam nodesep 30
skinparam ranksep 30 skinparam ranksep 30
top to bottom direction left to right direction
title Frontend (Expo React Native) title Frontend (Expo React Native)
' ===== COMPONENTS =====
package "Components" #FFA07A {
class AuthGuard
class BaseBackground
class Header
class BaseButton
class CardBase
class ModalBase
class EventCardBase
class EventCard
class ProposedEventCard
class DeleteEventModal
class ChatBubble
class TypingIndicator
}
' ===== SCREENS ===== ' ===== SCREENS =====
package "Screens" #87CEEB { package "Screens" #87CEEB {
class LoginScreen class LoginScreen
class RegisterScreen class RegisterScreen
class CalendarScreen class CalendarScreen
class ChatScreen class ChatScreen
class SettingsScreen
class EditEventScreen
class EventDetailScreen class EventDetailScreen
class NoteScreen class NoteScreen
} }
' ===== COMPONENTS =====
package "Components" #FFA07A {
class BaseBackground
class Header
class EventCardBase
class EventCard
class ProposedEventCard
class EventConfirmDialog
}
' ===== SERVICES ===== ' ===== SERVICES =====
package "Services" #90EE90 { package "Services" #90EE90 {
class ApiClient { class ApiClient {
+get() ' +get()
+post() ' +post()
+put() ' +put()
+delete() ' +delete()
} }
class AuthService { class AuthService {
+login() ' +login()
+register() ' +register()
+logout() ' +logout()
+refresh() ' +refresh()
} }
class EventService { class EventService {
+getAll() ' +getAll()
+getById() ' +getById()
+getByDateRange() ' +getByDateRange()
+create() ' +create()
+update() ' +update()
+delete() ' +delete()
} }
class ChatService { class ChatService {
+sendMessage() ' +sendMessage()
+confirmEvent() ' +confirmEvent()
+rejectEvent() ' +rejectEvent()
+getConversations() ' +getConversations()
+getConversation() ' +getConversation()
' +updateProposalEvent()
}
class CaldavConfigService {
' +getConfig()
' +saveConfig()
' +deleteConfig()
' +sync()
} }
} }
@@ -71,11 +86,10 @@ package "Services" #90EE90 {
package "Stores" #FFD700 { package "Stores" #FFD700 {
class AuthStore { class AuthStore {
' +user ' +user
' +token
' +isAuthenticated ' +isAuthenticated
' +login() ' +login()
' +logout() ' +logout()
' +setToken() ' +loadStoredUser()
} }
class EventsStore { class EventsStore {
' +events ' +events
@@ -86,10 +100,16 @@ package "Stores" #FFD700 {
} }
class ChatStore { class ChatStore {
' +messages ' +messages
' +isWaitingForResponse
' +addMessage() ' +addMessage()
' +addMessages()
' +updateMessage() ' +updateMessage()
' +clearMessages() ' +clearMessages()
} }
class ThemeStore {
' +theme
' +setTheme()
}
} }
' ===== MODELS ===== ' ===== MODELS =====
@@ -97,31 +117,47 @@ package "Models (shared)" #D3D3D3 {
class User class User
class CalendarEvent class CalendarEvent
class ChatMessage class ChatMessage
class CaldavConfig
} }
' ===== RELATIONSHIPS ===== ' ===== RELATIONSHIPS =====
' Screens -> Services
LoginScreen --> AuthService
CalendarScreen --> EventService
ChatScreen --> ChatService
NoteScreen --> EventService
' Screens -> Components ' Screens -> Components
CalendarScreen --> EventCard CalendarScreen --> EventCard
ChatScreen --> ProposedEventCard ChatScreen --> ProposedEventCard
ChatScreen --> EventConfirmDialog ChatScreen --> ChatBubble
ChatScreen --> TypingIndicator
EventCard --> EventCardBase EventCard --> EventCardBase
ProposedEventCard --> EventCardBase ProposedEventCard --> EventCardBase
EventCardBase --> CardBase
ModalBase --> CardBase
DeleteEventModal --> ModalBase
' Screens -> Services
LoginScreen --> AuthService
CalendarScreen --> EventService
CalendarScreen --> CaldavConfigService
ChatScreen --> ChatService
NoteScreen --> EventService
EditEventScreen --> EventService
EditEventScreen --> ChatService
SettingsScreen --> CaldavConfigService
' Auth
AuthGuard --> AuthStore
AuthGuard --> CaldavConfigService
LoginScreen --> CaldavConfigService
' Services -> ApiClient ' Services -> ApiClient
AuthService --> ApiClient AuthService --> ApiClient
EventService --> ApiClient EventService --> ApiClient
ChatService --> ApiClient ChatService --> ApiClient
CaldavConfigService --> ApiClient
' Services/Screens -> Stores ' Services/Screens -> Stores
AuthService --> AuthStore AuthService --> AuthStore
EventService --> EventsStore CalendarScreen --> EventsStore
ChatScreen --> ChatStore ChatScreen --> ChatStore
SettingsScreen --> ThemeStore
@enduml @enduml

View File

@@ -66,7 +66,7 @@ Backend & Express.js & Web-App Framework \\
& MongoDB & Datenbank \\ & MongoDB & Datenbank \\
& Mongoose & ODM \\ & Mongoose & ODM \\
& Claude (Anthropic) & KI / LLM \\ & Claude (Anthropic) & KI / LLM \\
& JWT & Authentifizierung \\ & X-User-Id Header & Authentifizierung \\
\hline \hline
Geplant & iCalendar & Event-Export \\ Geplant & iCalendar & Event-Export \\
\hline \hline
@@ -112,8 +112,9 @@ Der wichtigste Teil der App ist die KI-Integration über \textbf{Claude}
(Anthropic). Dieses LLM verarbeitet natürlichsprachliche Eingaben der Nutzer und (Anthropic). Dieses LLM verarbeitet natürlichsprachliche Eingaben der Nutzer und
generiert daraus strukturierte Event-Vorschläge. generiert daraus strukturierte Event-Vorschläge.
Die Authentifizierung läuft über \textbf{JSON Web Tokens} (JWT). Der Vorteil: Die Authentifizierung erfolgt über einen \textbf{X-User-Id Header}, der bei
zustandslose Sessions, bei denen der Server keine Session-Daten speichern muss. jedem Request die User-ID mitschickt. Diese einfache Lösung reicht für den
aktuellen Entwicklungsstand aus.
Geplant ist außerdem die Unterstützung des \textbf{iCalendar}-Formats (ICAL) Geplant ist außerdem die Unterstützung des \textbf{iCalendar}-Formats (ICAL)
für den Export von Kalender-Events. für den Export von Kalender-Events.
@@ -157,7 +158,7 @@ Notiz-Feld) und ChatMessage.
Der Controller Layer bildet die Schnittstelle zwischen Frontend und Der Controller Layer bildet die Schnittstelle zwischen Frontend und
Backend-Logik. Die Routes definieren die API-Endpunkte, die Controller Backend-Logik. Die Routes definieren die API-Endpunkte, die Controller
verarbeiten die eingehenden Requests und reichen diese an die Services weiter. verarbeiten die eingehenden Requests und reichen diese an die Services weiter.
Eine Auth Middleware prüft bei geschützten Routen den JWT-Token. Eine Auth Middleware prüft bei geschützten Routen den X-User-Id Header.
\subsubsection{Service Layer} \subsubsection{Service Layer}

6175
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,8 @@
"packages/*" "packages/*"
], ],
"scripts": { "scripts": {
"format": "prettier --write \"apps/*/src/**/*.{ts,tsx}\" \"packages/*/src/**/*.ts\"" "format": "prettier --write \"apps/*/src/**/*.{ts,tsx}\" \"packages/*/src/**/*.ts\"",
"check_format": "prettier --check \"apps/*/src/**/*.{ts,tsx}\" \"packages/*/src/**/*.ts\""
}, },
"devDependencies": { "devDependencies": {
"eslint": "^9.25.0", "eslint": "^9.25.0",

1
packages/shared/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
dist/

View File

@@ -2,10 +2,16 @@
"name": "@calchat/shared", "name": "@calchat/shared",
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"main": "./src/index.ts", "main": "./dist/index.js",
"types": "./src/index.ts", "types": "./dist/index.d.ts",
"exports": { "exports": {
".": "./src/index.ts", ".": "./dist/index.js",
"./*": "./src/*" "./*": "./dist/*"
},
"scripts": {
"build": "tsc"
},
"dependencies": {
"rrule": "^2.8.1"
} }
} }

View File

@@ -0,0 +1,7 @@
export interface CaldavConfig {
userId: string;
serverUrl: string;
username: string;
password: string;
syncIntervalSeconds?: number;
}

View File

@@ -1,35 +1,52 @@
export interface CalendarEvent { export interface CalendarEvent {
id: string; id: string;
userId: string; userId: string;
caldavUUID?: string;
etag?: string;
title: string; title: string;
description?: string; description?: string;
startTime: Date; startTime: Date;
endTime: Date; endTime: Date;
note?: string; note?: string;
isRecurring?: boolean;
recurrenceRule?: string; recurrenceRule?: string;
exceptionDates?: string[]; // ISO date strings (YYYY-MM-DD) for excluded occurrences
createdAt?: Date; createdAt?: Date;
updatedAt?: Date; updatedAt?: Date;
caldavSyncStatus?: CaldavSyncStatus;
}
export type RecurringDeleteMode = "single" | "future" | "all";
export type CaldavSyncStatus = "synced" | "error";
export interface DeleteRecurringEventDTO {
mode: RecurringDeleteMode;
occurrenceDate?: string; // ISO date string of the occurrence to delete
} }
export interface CreateEventDTO { export interface CreateEventDTO {
caldavUUID?: string;
etag?: string;
title: string; title: string;
description?: string; description?: string;
startTime: Date; startTime: Date;
endTime: Date; endTime: Date;
note?: string; note?: string;
isRecurring?: boolean;
recurrenceRule?: string; recurrenceRule?: string;
exceptionDates?: string[]; // For display in proposals
caldavSyncStatus?: CaldavSyncStatus;
} }
export interface UpdateEventDTO { export interface UpdateEventDTO {
caldavUUID?: string;
etag?: string;
title?: string; title?: string;
description?: string; description?: string;
startTime?: Date; startTime?: Date;
endTime?: Date; endTime?: Date;
note?: string; note?: string;
isRecurring?: boolean;
recurrenceRule?: string; recurrenceRule?: string;
exceptionDates?: string[];
caldavSyncStatus?: CaldavSyncStatus;
} }
export interface ExpandedEvent extends CalendarEvent { export interface ExpandedEvent extends CalendarEvent {

View File

@@ -1,4 +1,8 @@
import { CreateEventDTO, UpdateEventDTO } from "./CalendarEvent"; import {
CreateEventDTO,
UpdateEventDTO,
RecurringDeleteMode,
} from "./CalendarEvent";
export type MessageSender = "user" | "assistant"; export type MessageSender = "user" | "assistant";
@@ -6,6 +10,12 @@ export type EventAction = "create" | "update" | "delete";
export type RespondedAction = "confirm" | "reject"; export type RespondedAction = "confirm" | "reject";
export interface ConflictingEvent {
title: string;
startTime: Date;
endTime: Date;
}
export interface ProposedEventChange { export interface ProposedEventChange {
id: string; // Unique ID for each proposal id: string; // Unique ID for each proposal
action: EventAction; action: EventAction;
@@ -13,6 +23,9 @@ export interface ProposedEventChange {
event?: CreateEventDTO; // Required for create event?: CreateEventDTO; // Required for create
updates?: UpdateEventDTO; // Required for update updates?: UpdateEventDTO; // Required for update
respondedAction?: RespondedAction; // User's response to this specific proposal respondedAction?: RespondedAction; // User's response to this specific proposal
deleteMode?: RecurringDeleteMode; // For recurring event deletion
occurrenceDate?: string; // ISO date string of specific occurrence for single/future delete
conflictingEvents?: ConflictingEvent[]; // Overlapping events for conflict warnings
} }
export interface ChatMessage { export interface ChatMessage {

View File

@@ -2,3 +2,4 @@ export * from "./User";
export * from "./CalendarEvent"; export * from "./CalendarEvent";
export * from "./ChatMessage"; export * from "./ChatMessage";
export * from "./Constants"; export * from "./Constants";
export * from "./CaldavConfig";

View File

@@ -34,3 +34,15 @@ export function getDay(
result.setHours(hour, minute, 0, 0); result.setHours(hour, minute, 0, 0);
return result; return result;
} }
/**
* Check if an event spans multiple days.
* Compares dates at midnight to determine if start and end are on different calendar days.
*/
export function isMultiDayEvent(start: Date, end: Date): boolean {
const startDate = new Date(start);
const endDate = new Date(end);
startDate.setHours(0, 0, 0, 0);
endDate.setHours(0, 0, 0, 0);
return startDate.getTime() !== endDate.getTime();
}

View File

@@ -0,0 +1,79 @@
/**
* 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",
});
}
// Format date as YYYY-MM-DD for exception date comparison
export function formatDateKey(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}

View File

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

Some files were not shown because too many files have changed in this diff Show More