Compare commits

..

31 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
81 changed files with 8670 additions and 650 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/*.png
.env
apps/server/docker/radicale/config/
apps/server/docker/radicale/data/

236
CLAUDE.md
View File

@@ -14,6 +14,7 @@ This is a fullstack TypeScript monorepo with npm workspaces.
```bash
npm install # Install all dependencies for all workspaces
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
@@ -26,11 +27,17 @@ npm run lint -w @calchat/client # Run ESLint
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
```bash
npm run dev -w @calchat/server # Start dev server with hot reload (tsx watch)
npm run build -w @calchat/server # Compile TypeScript
npm run dev -w @calchat/server # Build shared + start dev server with hot reload (tsx watch)
npm run build -w @calchat/server # Build shared + compile TypeScript
npm run start -w @calchat/server # Run compiled server (port 3000)
npm run test -w @calchat/server # Run Jest unit tests
```
## Technology Stack
@@ -48,9 +55,14 @@ npm run start -w @calchat/server # Run compiled server (port 3000)
| | MongoDB | Database |
| | Mongoose | ODM |
| | 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 |
| | 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 |
## Architecture
@@ -74,15 +86,15 @@ src/
│ ├── (tabs)/ # Tab navigation group
│ │ ├── _layout.tsx # Tab bar configuration (themed)
│ │ ├── chat.tsx # Chat screen (AI conversation)
│ │ ├── calendar.tsx # Calendar overview
│ │ └── settings.tsx # Settings screen (theme switcher, logout)
│ │ ├── calendar.tsx # Calendar overview (with CalendarToolbar: sync + logout)
│ │ └── settings.tsx # Settings screen (theme switcher, logout, CalDAV config with feedback)
│ ├── editEvent.tsx # Event edit screen (dual-mode: calendar/chat)
│ ├── event/
│ │ └── [id].tsx # Event detail screen (dynamic route)
│ └── note/
│ └── [id].tsx # Note editor for event (dynamic route)
├── 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)
│ ├── BaseButton.tsx # Reusable button component (themed, supports children)
│ ├── Header.tsx # Header component (themed)
@@ -96,6 +108,7 @@ src/
│ ├── EventConfirmDialog.tsx # AI-proposed event confirmation modal (skeleton)
│ ├── 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
@@ -107,13 +120,15 @@ src/
│ ├── ApiClient.ts # HTTP client with X-User-Id header injection, request logging, handles empty responses (204)
│ ├── AuthService.ts # login(), register(), logout() - calls API and updates AuthStore
│ ├── EventService.ts # getAll(), getById(), getByDateRange(), create(), update(), delete(mode, occurrenceDate)
── ChatService.ts # sendMessage(), confirmEvent(deleteMode, occurrenceDate), rejectEvent(), getConversations(), getConversation(), updateProposalEvent()
── ChatService.ts # sendMessage(), confirmEvent(deleteMode, occurrenceDate), rejectEvent(), getConversations(), getConversation(), updateProposalEvent()
│ └── CaldavConfigService.ts # saveConfig(), getConfig(), deleteConfig(), pull(), pushAll(), sync()
├── stores/ # Zustand state management
│ ├── index.ts # Re-exports all stores
│ ├── AuthStore.ts # user, isAuthenticated, isLoading, login(), logout(), loadStoredUser()
│ │ # Uses expo-secure-store (native) / localStorage (web)
│ ├── ChatStore.ts # messages[], isWaitingForResponse, addMessage(), addMessages(), updateMessage(), clearMessages(), setWaitingForResponse(), chatMessageToMessageData()
│ ├── EventsStore.ts # events[], setEvents(), addEvent(), updateEvent(), deleteEvent()
│ ├── 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
@@ -124,9 +139,12 @@ src/
**Authentication Flow:**
- `AuthGuard` component wraps the tab layout in `(tabs)/_layout.tsx`
- 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`
- `login.tsx` also calls `preloadAppData()` after successful login (spinner stays visible during preload)
- `index.tsx` simply redirects to `/(tabs)/chat` - AuthGuard handles the rest
- 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
@@ -227,9 +245,10 @@ CardBase
src/
├── app.ts # Entry point, DI setup, Express config
├── controllers/ # Request handlers + middleware (per architecture diagram)
│ ├── AuthController.ts # login(), register(), refresh(), logout()
│ ├── ChatController.ts # sendMessage(), confirmEvent(), rejectEvent(), getConversations(), getConversation(), updateProposalEvent()
│ ├── EventController.ts # create(), getById(), getAll(), getByDateRange(), update(), delete()
│ ├── AuthController.ts # login(), register()
│ ├── ChatController.ts # sendMessage(), confirmEvent() + CalDAV push, rejectEvent(), getConversations(), getConversation(), updateProposalEvent()
│ ├── 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
│ └── LoggingMiddleware.ts # httpLogger - pino-http request logging
├── logging/
@@ -240,16 +259,19 @@ src/
│ ├── index.ts # Combines all routes under /api
│ ├── auth.routes.ts # /api/auth/*
│ ├── 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
│ ├── interfaces/ # DB-agnostic interfaces (for dependency injection)
│ │ ├── AIProvider.ts # processMessage()
│ │ ├── UserRepository.ts # findById, findByEmail, findByUserName, create + CreateUserData
│ │ ├── EventRepository.ts
│ │ ── ChatRepository.ts
│ │ ── ChatRepository.ts
│ │ └── CaldavRepository.ts
│ ├── AuthService.ts
│ ├── ChatService.ts
── EventService.ts
── EventService.ts
│ └── CaldavService.ts # connect(), pullEvents(), pushEvent(), pushAll(), deleteEvent(), sync logic
├── repositories/ # Data access (DB-specific implementations)
│ ├── index.ts # Re-exports from ./mongo
│ └── mongo/ # MongoDB implementation
@@ -257,21 +279,22 @@ src/
│ │ ├── types.ts # Shared types (IdVirtual interface)
│ │ ├── UserModel.ts
│ │ ├── EventModel.ts
│ │ ── ChatModel.ts
│ │ ── ChatModel.ts
│ │ └── CaldavConfigModel.ts
│ ├── MongoUserRepository.ts # findById, findByEmail, findByUserName, create
│ ├── MongoEventRepository.ts
── MongoChatRepository.ts
── MongoChatRepository.ts
│ └── MongoCaldavRepository.ts
├── ai/
│ ├── GPTAdapter.ts # Implements AIProvider using OpenAI GPT
│ ├── index.ts # Re-exports GPTAdapter
│ └── utils/ # Shared AI utilities (provider-agnostic)
│ ├── 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
│ ├── 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/
│ ├── jwt.ts # signToken(), verifyToken() - NOT USED YET (no JWT)
│ ├── password.ts # hash(), compare() using bcrypt
│ ├── eventFormatters.ts # getWeeksOverview(), getMonthOverview() - formatted event listings
│ └── recurrenceExpander.ts # expandRecurringEvents() - expand recurring events into occurrences
@@ -282,8 +305,6 @@ src/
**API Endpoints:**
- `POST /api/auth/login` - User login
- `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/range` - Get events by date range (protected)
- `GET /api/events/:id` - Get single event (protected)
@@ -296,18 +317,27 @@ src/
- `GET /api/chat/conversations` - Get all conversations (protected)
- `GET /api/chat/conversations/:id` - Get messages of a conversation with cursor-based pagination (protected)
- `PUT /api/chat/messages/:messageId/proposal` - Update proposal event data before confirming (protected)
- `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
- `POST /api/ai/test` - AI test endpoint (development only)
### 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/
├── index.ts
├── models/
│ ├── index.ts
│ ├── 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,
│ │ # GetMessagesOptions, ChatResponse, ConversationSummary,
│ │ # ProposedEventChange, EventAction, RespondedAction, UpdateMessageDTO
@@ -317,18 +347,22 @@ src/
├── index.ts
├── dateHelpers.ts # getDay() - get date for specific weekday relative to today
├── formatters.ts # formatDate(), formatTime(), formatDateTime(), formatDateWithWeekday() - German locale
└── rruleHelpers.ts # parseRRule() - parse RRULE strings to extract freq, until, count, interval, byDay
└── rruleHelpers.ts # parseRRule(), buildRRule(), formatRecurrenceRule() - RRULE parsing, building, and German formatting
```
**Key Types:**
- `User`: id, email, userName, passwordHash?, createdAt?, updatedAt?
- `CalendarEvent`: id, userId, title, description?, startTime, endTime, note?, isRecurring?, recurrenceRule?, exceptionDates?
- `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)
- `ChatMessage`: id, conversationId, sender ('user' | 'assistant'), content, proposedChanges?
- `ProposedEventChange`: id, action ('create' | 'update' | 'delete'), eventId?, event?, updates?, respondedAction?, deleteMode?, occurrenceDate?
- `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
- `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)
@@ -342,6 +376,69 @@ src/
- `Day`: "Monday" | "Tuesday" | ... | "Sunday"
- `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
The repository pattern allows swapping databases:
@@ -402,7 +499,6 @@ The decorator uses a Proxy to intercept method calls lazily, preserves sync/asyn
**Log Summarization:**
The `@Logged` decorator automatically summarizes large arguments to keep logs readable:
- `conversationHistory``"[5 messages]"`
- `existingEvents``"[3 events]"`
- `proposedChanges` → logged in full (for debugging AI issues)
- Long strings (>100 chars) → truncated
- Arrays → `"[Array(n)]"`
@@ -426,7 +522,7 @@ The `@Logged` decorator automatically summarizes large arguments to keep logs re
### Nice-to-Have
- iCalendar import/export
- Multiple calendars
- CalDAV synchronization with external services
- ~~CalDAV synchronization with external services~~ (implemented)
## Development Environment
@@ -439,11 +535,24 @@ docker compose down # Stop services
- MongoDB: `localhost:27017` (root/mongoose)
- 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
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
OPENAI_API_KEY=sk-proj-...
USE_TEST_RESPONSES=false # true = static test responses, false = real GPT AI
@@ -474,22 +583,28 @@ NODE_ENV=development # development = pretty logs, production = JSON
- `MongoChatRepository`: Full CRUD implemented (getConversationsByUser, createConversation, getMessages with cursor pagination, createMessage, updateMessage, updateProposalResponse, updateProposalEvent)
- `ChatRepository` interface: updateMessage(), updateProposalResponse(), updateProposalEvent() for per-proposal tracking
- `GPTAdapter`: Full implementation with OpenAI GPT (gpt-4o-mini model), function calling for calendar operations, collects multiple proposals per response
- `ai/utils/`: Provider-agnostic shared utilities (systemPrompt, toolDefinitions, toolExecutor, eventFormatter)
- `ai/utils/systemPrompt`: Includes RRULE documentation - AI knows to create separate events when times differ by day, warns AI not to put RRULE in description field
- `ai/utils/toolDefinitions`: proposeUpdateEvent supports `isRecurring` and `recurrenceRule` parameters for adding UNTIL or modifying recurrence
- `ai/utils/`: Provider-agnostic shared utilities (systemPrompt, toolDefinitions, toolExecutor)
- `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
- `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
- 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
- **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 defined and exported
- `rruleHelpers.ts`: `parseRRule()` parses RRULE strings using rrule library, returns `ParsedRRule` with freq, until, count, interval, byDay
- `formatters.ts`: German date/time formatters (`formatDate`, `formatTime`, `formatDateTime`, `formatDateWithWeekday`) used by both client and server
- 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:**
@@ -497,9 +612,9 @@ NODE_ENV=development # development = pretty logs, production = JSON
- `AuthStore`: Manages user state with expo-secure-store (native) / localStorage (web)
- `AuthService`: login(), register(), logout() - calls backend API
- `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
- Login screen: Supports email OR userName login
- Register screen: Email validation, checks for existing email/userName
- `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, uses CustomTextInput with focus border, preloads app data + triggers CalDAV sync after successful login
- Register screen: Email validation, checks for existing email/userName, uses CustomTextInput with focus border
- `AuthButton`: Reusable button component with themed shadow
- `Header`: Themed header component (logout moved to Settings)
- `(tabs)/_layout.tsx`: Wraps tabs with AuthGuard for protected access
@@ -508,7 +623,7 @@ NODE_ENV=development # development = pretty logs, production = JSON
- `ThemeStore`: Zustand store with theme state and setTheme()
- `Themes.tsx`: THEMES object with defaultLight/defaultDark variants
- 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
- Tab navigation (Chat, Calendar, Settings) implemented with themed UI
- Calendar screen fully functional:
@@ -518,7 +633,7 @@ NODE_ENV=development # development = pretty logs, production = JSON
- Orange dot indicator for days with events
- Tap-to-open modal overlay showing EventCards for selected day
- Supports events from adjacent months visible in grid
- Uses `useFocusEffect` for automatic reload on tab focus
- 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
@@ -530,19 +645,23 @@ NODE_ENV=development # development = pretty logs, production = JSON
- Tracks conversationId for message continuity across sessions
- ChatStore with addMessages() for bulk loading, chatMessageToMessageData() helper
- 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"
- `EventService`: getAll(), getById(), getByDateRange(), create(), update(), delete(mode, occurrenceDate) - fully implemented with recurring delete modes
- `ChatService`: sendMessage(), confirmEvent(deleteMode, occurrenceDate), rejectEvent(), getConversations(), getConversation(), updateProposalEvent() - fully implemented with cursor pagination, recurring delete support, and proposal editing
- `CaldavConfigService`: saveConfig(), getConfig(), deleteConfig(), pull(), pushAll(), sync() - CalDAV config management and sync trigger
- `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.
- `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
- `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). Edit button allows modifying proposals before confirming.
- `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.)
- `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
- `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
@@ -577,6 +696,29 @@ This uses the `preview` profile from `eas.json` which builds an APK with:
- Package name: `com.gilmour109.calchat`
- 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
Detailed architecture diagrams are in `docs/`:

157
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) |
```bash
npm install
```
## Voraussetzungen
2. Start the app
- Node.js (>= 20)
- npm
- Docker & Docker Compose (fuer MongoDB)
- OpenAI API Key (fuer KI-Chat)
- Android SDK + Java (nur fuer APK-Build)
```bash
npx expo start
```
## Projekt aufsetzen
In the output, you'll find options to open the app in a
- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
- [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:
### 1. Repository klonen
```bash
npm run reset-project
git clone <repo-url>
cd calchat
```
This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
### 2. Dependencies installieren
## Learn more
```bash
npm install
```
To learn more about developing your project with Expo, look at the following resources:
Installiert alle Dependencies fuer Client, Server und Shared.
- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
- [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.
### 3. MongoDB starten
## Join the community
```bash
cd apps/server/docker/mongo
docker compose up -d
```
Join our community of developers creating universal apps.
- MongoDB: `localhost:27017` (root/mongoose)
- Mongo Express UI: `localhost:8083` (admin/admin)
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.
### 4. Server konfigurieren
```bash
cp apps/server/.env.example apps/server/.env
```
`apps/server/.env` bearbeiten:
```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
```
### 5. Client konfigurieren
```bash
cp apps/client/.env.example apps/client/.env
```
`apps/client/.env` bearbeiten:
```env
# Fuer Emulator/Web:
EXPO_PUBLIC_API_URL=http://localhost:3000/api
# Fuer physisches Geraet im gleichen Netzwerk:
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
},
"env": {
"ORG_GRADLE_PROJECT_reactNativeArchitectures": "arm64-v8a"
"ORG_GRADLE_PROJECT_reactNativeArchitectures": "arm64-v8a",
"EXPO_PUBLIC_API_URL": "https://calchat.gilmour109.de/api"
}
},
"production": {

View File

@@ -1,4 +1,4 @@
import { Pressable, Text, View } from "react-native";
import { ActivityIndicator, Pressable, Text, View } from "react-native";
import {
DAYS,
MONTHS,
@@ -11,19 +11,16 @@ import { EventCard } from "../../components/EventCard";
import { DeleteEventModal } from "../../components/DeleteEventModal";
import { ModalBase } from "../../components/ModalBase";
import { ScrollableDropdown } from "../../components/ScrollableDropdown";
import React, {
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { router, useFocusEffect } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { useThemeStore } from "../../stores/ThemeStore";
import BaseBackground from "../../components/BaseBackground";
import { EventService } from "../../services";
import { AuthService, EventService } from "../../services";
import { useEventsStore } from "../../stores";
import { useDropdownPosition } from "../../hooks/useDropdownPosition";
import { CaldavConfigService } from "../../services/CaldavConfigService";
import { useCaldavConfigStore } from "../../stores/CaldavConfigStore";
// MonthSelector types and helpers
type MonthItem = {
@@ -84,7 +81,7 @@ const Calendar = () => {
const { events, setEvents, deleteEvent } = useEventsStore();
// Function to load events for current view
// Load events from local DB (fast, no network sync)
const loadEvents = useCallback(async () => {
try {
// Calculate first visible day (up to 6 days before month start)
@@ -112,17 +109,20 @@ const Calendar = () => {
}
}, [monthIndex, currentYear, setEvents]);
// Load events when tab gains focus or month/year changes
// NOTE: Wrapper needed because loadEvents is async (returns Promise)
// and useFocusEffect expects a sync function (optionally returning cleanup)
// Also re-open overlay if selectedDate exists (for back navigation from editEvent)
// Load events from DB on focus
useFocusEffect(
useCallback(() => {
loadEvents();
}, [loadEvents]),
);
// Re-open overlay after back navigation from editEvent
useFocusEffect(
useCallback(() => {
if (selectedDate) {
setOverlayVisible(true);
}
}, [loadEvents, selectedDate]),
}, [selectedDate]),
);
// Group events by date (YYYY-MM-DD format)
@@ -203,7 +203,7 @@ const Calendar = () => {
const occurrenceDate = getDateKey(new Date(event.occurrenceStart));
try {
if (event.isRecurring) {
if (event.recurrenceRule) {
// Recurring event: use mode and occurrenceDate
await EventService.delete(event.id, mode, occurrenceDate);
// Reload events to reflect changes
@@ -241,6 +241,7 @@ const Calendar = () => {
setMonthIndex={setMonthIndex}
setYear={setCurrentYear}
/>
<CalendarToolbar loadEvents={loadEvents} />
<WeekDaysLine />
<CalendarGrid
month={MONTHS[monthIndex]}
@@ -260,7 +261,7 @@ const Calendar = () => {
<DeleteEventModal
visible={deleteModalVisible}
eventTitle={eventToDelete?.title || ""}
isRecurring={eventToDelete?.isRecurring || false}
isRecurring={!!eventToDelete?.recurrenceRule}
onConfirm={handleDeleteConfirm}
onCancel={handleDeleteCancel}
/>
@@ -545,6 +546,131 @@ 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 { theme } = useThemeStore();
return (

View File

@@ -64,11 +64,11 @@ const Chat = () => {
string | undefined
>();
const [hasLoadedMessages, setHasLoadedMessages] = useState(false);
const needsInitialScroll = useRef(false);
useEffect(() => {
const keyboardDidShow = Keyboard.addListener(
"keyboardDidShow",
scrollToEnd,
const keyboardDidShow = Keyboard.addListener("keyboardDidShow", () =>
scrollToEnd(),
);
return () => keyboardDidShow.remove();
}, []);
@@ -90,7 +90,7 @@ const Chat = () => {
await ChatService.getConversation(conversationId);
const clientMessages = serverMessages.map(chatMessageToMessageData);
addMessages(clientMessages);
scrollToEnd();
needsInitialScroll.current = true;
}
} catch (error) {
console.error("Failed to load messages:", error);
@@ -102,9 +102,9 @@ const Chat = () => {
}, [isAuthLoading, isAuthenticated, hasLoadedMessages]),
);
const scrollToEnd = () => {
const scrollToEnd = (animated = true) => {
setTimeout(() => {
listRef.current?.scrollToEnd({ animated: true });
listRef.current?.scrollToEnd({ animated });
}, 100);
};
@@ -175,7 +175,11 @@ const Chat = () => {
params: {
mode: "chat",
eventData: JSON.stringify(proposal.event),
proposalContext: JSON.stringify({ messageId, proposalId, conversationId }),
proposalContext: JSON.stringify({
messageId,
proposalId,
conversationId,
}),
},
});
};
@@ -273,6 +277,12 @@ const Chat = () => {
keyExtractor={(item) => item.id}
keyboardDismissMode="interactive"
keyboardShouldPersistTaps="handled"
onContentSizeChange={() => {
if (needsInitialScroll.current) {
needsInitialScroll.current = false;
listRef.current?.scrollToEnd({ animated: false });
}
}}
ListFooterComponent={
isWaitingForResponse ? <TypingIndicator /> : null
}

View File

@@ -1,18 +1,212 @@
import { Text, View } from "react-native";
import { ActivityIndicator, Text, View } from "react-native";
import BaseBackground from "../../components/BaseBackground";
import BaseButton from "../../components/BaseButton";
import BaseButton, { BaseButtonProps } from "../../components/BaseButton";
import { useThemeStore } from "../../stores/ThemeStore";
import { AuthService } from "../../services/AuthService";
import { router } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { SimpleHeader } from "../../components/Header";
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 () => {
await AuthService.logout();
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 { theme, setTheme } = useThemeStore();
@@ -20,10 +214,10 @@ const Settings = () => {
<BaseBackground>
<SimpleHeader text="Settings" />
<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} />{" "}
Logout
</BaseButton>
</SettingsButton>
<View>
<Text
className="text-center text-2xl"
@@ -32,23 +226,24 @@ const Settings = () => {
Select Theme
</Text>
</View>
<BaseButton
<SettingsButton
solid={theme == THEMES.defaultLight}
onPress={() => {
setTheme("defaultLight");
}}
>
Default Light
</BaseButton>
<BaseButton
</SettingsButton>
<SettingsButton
solid={theme == THEMES.defaultDark}
onPress={() => {
setTheme("defaultDark");
}}
>
Default Dark
</BaseButton>
</SettingsButton>
</View>
<CaldavSettings />
</BaseBackground>
);
};

View File

@@ -19,43 +19,34 @@ import { Ionicons } from "@expo/vector-icons";
import { ScrollableDropdown } from "../components/ScrollableDropdown";
import { useDropdownPosition } from "../hooks/useDropdownPosition";
import { EventService, ChatService } from "../services";
import { buildRRule, CreateEventDTO } from "@calchat/shared";
import {
buildRRule,
CreateEventDTO,
REPEAT_TYPE_LABELS,
RepeatType,
} from "@calchat/shared";
import { useChatStore } from "../stores";
import CustomTextInput, {
CustomTextInputProps,
} from "../components/CustomTextInput";
// Direct store access for getting current state in callbacks
const getChatStoreState = () => useChatStore.getState();
type EditEventTextFieldProps = {
type EditEventTextFieldProps = CustomTextInputProps & {
titel: string;
text?: string;
focused?: boolean;
className?: string;
multiline?: boolean;
onValueChange?: (text: string) => void;
};
const EditEventTextField = (props: EditEventTextFieldProps) => {
const { theme } = useThemeStore();
const [focused, setFocused] = useState(props.focused ?? false);
return (
<View className={props.className}>
<Text className="text-xl" style={{ color: theme.textPrimary }}>
{props.titel}
</Text>
<TextInput
onChangeText={props.onValueChange}
value={props.text}
<CustomTextInput
className="flex-1 px-3 py-2"
text={props.text}
multiline={props.multiline}
className="flex-1 border border-solid rounded-2xl px-3 py-2 w-full h-11/12"
style={{
backgroundColor: theme.messageBorderBg,
color: theme.textPrimary,
textAlignVertical: "top",
borderColor: focused ? theme.chatBot : theme.borderPrimary,
}}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
onValueChange={props.onValueChange}
/>
</View>
);
@@ -100,15 +91,6 @@ const PickerRow = ({
);
};
type RepeatType = "Tag" | "Woche" | "Monat" | "Jahr";
const REPEAT_TYPE_LABELS: Record<RepeatType, string> = {
Tag: "Tage",
Woche: "Wochen",
Monat: "Monate",
Jahr: "Jahre",
};
type RepeatPressableProps = {
focused: boolean;
repeatType: RepeatType;
@@ -443,31 +425,25 @@ const EditEventScreen = () => {
: undefined,
};
// Chat mode: update proposal locally and on server
// Chat mode: update proposal on server and sync response to local store
if (mode === "chat" && proposalContext) {
try {
const context = JSON.parse(proposalContext) as ProposalContext;
// Update locally in ChatStore
const currentMessages = getChatStoreState().messages;
const message = currentMessages.find((m) => m.id === context.messageId);
if (message?.proposedChanges) {
const updatedProposals = message.proposedChanges.map((p) =>
p.id === context.proposalId ? { ...p, event: eventObject } : p,
);
updateMessage(context.messageId, {
proposedChanges: updatedProposals,
});
}
// Persist to server
await ChatService.updateProposalEvent(
// 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);

View File

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

View File

@@ -1,8 +1,9 @@
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 BaseBackground from "../components/BaseBackground";
import AuthButton from "../components/AuthButton";
import CustomTextInput from "../components/CustomTextInput";
import { AuthService } from "../services";
import { useThemeStore } from "../stores/ThemeStore";
@@ -59,50 +60,32 @@ const RegisterScreen = () => {
</Text>
)}
<TextInput
<CustomTextInput
placeholder="E-Mail"
placeholderTextColor={theme.textMuted}
value={email}
onChangeText={setEmail}
text={email}
onValueChange={setEmail}
autoCapitalize="none"
keyboardType="email-address"
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"
placeholderTextColor={theme.textMuted}
value={userName}
onChangeText={setUserName}
text={userName}
onValueChange={setUserName}
autoCapitalize="none"
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"
placeholderTextColor={theme.textMuted}
value={password}
onChangeText={setPassword}
text={password}
onValueChange={setPassword}
secureTextEntry
className="w-full rounded-lg p-4 mb-6"
style={{
backgroundColor: theme.secondaryBg,
color: theme.textPrimary,
borderWidth: 1,
borderColor: theme.borderPrimary,
}}
/>
<AuthButton

View File

@@ -1,16 +1,52 @@
import { useEffect, ReactNode } from "react";
import { useEffect, useState, ReactNode } from "react";
import { View, ActivityIndicator } from "react-native";
import { Redirect } from "expo-router";
import { useAuthStore } from "../stores";
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 = {
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.
* - Loads stored user on mount
* - Preloads app data (events, CalDAV config) before dismissing spinner
* - Shows loading indicator while checking auth state
* - Redirects to login if not authenticated
* - Renders children if authenticated
@@ -18,12 +54,19 @@ type AuthGuardProps = {
export const AuthGuard = ({ children }: AuthGuardProps) => {
const { theme } = useThemeStore();
const { isAuthenticated, isLoading, loadStoredUser } = useAuthStore();
const [dataReady, setDataReady] = useState(false);
useEffect(() => {
loadStoredUser();
const init = async () => {
await loadStoredUser();
if (!useAuthStore.getState().isAuthenticated) return;
await preloadAppData();
setDataReady(true);
};
init();
}, [loadStoredUser]);
if (isLoading) {
if (isLoading || (isAuthenticated && !dataReady)) {
return (
<View
style={{

View File

@@ -2,17 +2,23 @@ import { Pressable, Text } from "react-native";
import { useThemeStore } from "../stores/ThemeStore";
import { ReactNode } from "react";
type BaseButtonProps = {
export type BaseButtonProps = {
children?: ReactNode;
className?: string;
onPress: () => void;
solid?: boolean;
};
const BaseButton = ({ children, onPress, solid = false }: BaseButtonProps) => {
const BaseButton = ({
className,
children,
onPress,
solid = false,
}: BaseButtonProps) => {
const { theme } = useThemeStore();
return (
<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}
style={{
borderColor: theme.borderPrimary,

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

@@ -126,11 +126,11 @@ const DateTimePickerButton = ({
// Convenience wrappers for simpler usage
export const DatePickerButton = (
props: Omit<DateTimePickerButtonProps, "mode">
props: Omit<DateTimePickerButtonProps, "mode">,
) => <DateTimePickerButton {...props} mode="date" />;
export const TimePickerButton = (
props: Omit<DateTimePickerButtonProps, "mode">
props: Omit<DateTimePickerButtonProps, "mode">,
) => <DateTimePickerButton {...props} mode="time" />;
export default DateTimePickerButton;

View File

@@ -19,7 +19,7 @@ export const EventCard = ({ event, onEdit, onDelete }: EventCardProps) => {
startTime={event.occurrenceStart}
endTime={event.occurrenceEnd}
description={event.description}
isRecurring={event.isRecurring}
recurrenceRule={event.recurrenceRule}
>
{/* Action buttons - TouchableOpacity with delayPressIn allows ScrollView to detect scroll gestures */}
<View className="flex-row justify-end mt-3 gap-3">

View File

@@ -8,6 +8,7 @@ import {
formatDateWithWeekday,
formatDateWithWeekdayShort,
formatTime,
formatRecurrenceRule,
} from "@calchat/shared";
type EventCardBaseProps = {
@@ -16,7 +17,7 @@ type EventCardBaseProps = {
startTime: Date;
endTime: Date;
description?: string;
isRecurring?: boolean;
recurrenceRule?: string;
children?: ReactNode;
};
@@ -46,7 +47,7 @@ export const EventCardBase = ({
startTime,
endTime,
description,
isRecurring,
recurrenceRule,
children,
}: EventCardBaseProps) => {
const { theme } = useThemeStore();
@@ -95,7 +96,7 @@ export const EventCardBase = ({
</View>
{/* Recurring indicator */}
{isRecurring && (
{recurrenceRule && (
<View className="flex-row items-center mb-1">
<Feather
name="repeat"
@@ -103,7 +104,9 @@ export const EventCardBase = ({
color={theme.textPrimary}
style={{ marginRight: 8 }}
/>
<Text style={{ color: theme.textPrimary }}>Wiederkehrend</Text>
<Text style={{ color: theme.textPrimary }}>
{formatRecurrenceRule(recurrenceRule)}
</Text>
</View>
)}

View File

@@ -1,6 +1,6 @@
import { View, Text, Pressable } from "react-native";
import { Feather } from "@expo/vector-icons";
import { ProposedEventChange, formatDate } 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 { EventCardBase } from "./EventCardBase";
@@ -113,7 +113,7 @@ export const ProposedEventCard = ({
startTime={event.startTime}
endTime={event.endTime}
description={event.description}
isRecurring={!!event.recurrenceRule}
recurrenceRule={event.recurrenceRule}
>
{/* Show new exception date for delete/single actions */}
{newExceptionDate && (
@@ -124,8 +124,12 @@ export const ProposedEventCard = ({
color={theme.confirmButton}
style={{ marginRight: 8 }}
/>
<Text style={{ color: theme.confirmButton }} className="font-medium">
Neue Ausnahme: {formatDate(new Date(proposedChange.occurrenceDate!))}
<Text
style={{ color: theme.confirmButton }}
className="font-medium"
>
Neue Ausnahme:{" "}
{formatDate(new Date(proposedChange.occurrenceDate!))}
</Text>
</View>
)}
@@ -138,11 +142,37 @@ export const ProposedEventCard = ({
color={theme.confirmButton}
style={{ marginRight: 8 }}
/>
<Text style={{ color: theme.confirmButton }} className="font-medium">
<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}
respondedAction={proposedChange.respondedAction}

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

@@ -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

@@ -5,3 +5,4 @@ export {
type MessageData,
} from "./ChatStore";
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",
"private": true,
"scripts": {
"dev": "tsx watch src/app.ts",
"build": "tsc",
"start": "node dist/app.js"
"dev": "npm run build --workspace=@calchat/shared && tsx watch src/app.ts",
"build": "npm run build --workspace=@calchat/shared && tsc",
"start": "node dist/app.js",
"test": "jest"
},
"dependencies": {
"@calchat/shared": "*",
"bcrypt": "^6.0.0",
"dotenv": "^16.4.7",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"ical.js": "^2.2.1",
"mongoose": "^9.1.1",
"openai": "^6.15.0",
"pino": "^10.1.1",
"pino-http": "^11.0.0",
"rrule": "^2.8.1"
"pino-pretty": "^13.1.3",
"rrule": "^2.8.1",
"tsdav": "^2.1.6"
},
"devDependencies": {
"@types/bcrypt": "^6.0.0",
"@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",
"pino-pretty": "^13.1.3",
"jest": "^30.2.0",
"ts-jest": "^29.4.6",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}

View File

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

View File

@@ -1,30 +0,0 @@
import {
CalendarEvent,
formatDate,
formatTime,
formatDateTime,
} from "@calchat/shared";
// Re-export for backwards compatibility
export { formatDate, formatTime, formatDateTime };
/**
* 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 {
TOOL_DEFINITIONS,

View File

@@ -1,5 +1,4 @@
import { AIContext } from "../../services/interfaces";
import { formatExistingEvents } from "./eventFormatter";
/**
* Build the system prompt for the AI assistant.
@@ -15,8 +14,6 @@ export function buildSystemPrompt(context: AIContext): string {
minute: "2-digit",
});
const eventsText = formatExistingEvents(context.existingEvents);
return `Du bist ein hilfreicher Kalender-Assistent für die App "CalChat".
Du hilfst Benutzern beim Erstellen, Ändern und Löschen von Terminen.
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 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)
- Wenn der Benutzer nach seinen Terminen fragt, nutze die unten stehende Liste
- Nutze searchEvents um nach Terminen zu suchen, wenn du die genaue ID brauchst
- 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]?"
- 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:
- Du MUSST die proposeCreateEvent/proposeUpdateEvent/proposeDeleteEvent Tools verwenden, um Termine vorzuschlagen!
@@ -50,18 +55,21 @@ WICHTIG - Wiederkehrende Termine (RRULE):
- WICHTIG: Schreibe die RRULE NIEMALS in das description-Feld! Nutze IMMER das recurrenceRule-Feld!
WICHTIG - Antwortformat:
- Halte deine Textantworten SEHR KURZ (1-2 Sätze maximal)
- Die Event-Details (Titel, Datum, Uhrzeit, Beschreibung) werden dem Benutzer automatisch in separaten Karten angezeigt
- Wiederhole NIEMALS die Event-Details im Text! Der Benutzer sieht sie bereits in den Karten
- Verwende kontextbezogene Antworten in der GEGENWARTSFORM je nach Aktion:
- Bei Termin-Erstellung: "Ich schlage folgenden Termin vor:" oder "Neuer Termin:"
- Bei Termin-Änderung: "Ich schlage folgende Änderung vor:" oder "Änderung:"
- Bei Termin-Löschung: "Ich schlage vor, diesen Termin zu löschen:" oder "Löschung:"
- Bei Übersichten: "Hier sind deine Termine:"
- WICHTIG: Verwende NIEMALS Vergangenheitsform wie "Ich habe ... vorgeschlagen" - immer Gegenwartsform!
- Schlechte Beispiele: "Alles klar!" (zu unspezifisch), lange Listen mit Termin-Details im Text
- Bei Rückfragen oder wenn keine Termine erstellt werden, kannst du ausführlicher antworten
Existierende Termine des Benutzers:
${eventsText}`;
WICHTIG - Unterscheide zwischen PROPOSALS und ABFRAGEN:
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

@@ -179,4 +179,23 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
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

@@ -6,7 +6,19 @@ import {
RecurringDeleteMode,
} from "@calchat/shared";
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
@@ -24,12 +36,13 @@ export interface ToolResult {
/**
* Execute a tool call and return the result.
* 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,
args: Record<string, unknown>,
context: AIContext,
): ToolResult {
): Promise<ToolResult> {
switch (name) {
case "getDay": {
const date = getDay(
@@ -62,20 +75,52 @@ export function executeToolCall(
const dateStr = formatDate(event.startTime);
const startStr = formatTime(event.startTime);
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 {
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: {
action: "create",
event,
conflictingEvents:
conflicts.length > 0
? conflicts.map((c) => ({
title: c.title,
startTime: new Date(c.occurrenceStart),
endTime: new Date(c.occurrenceEnd),
}))
: undefined,
},
};
}
case "proposeUpdateEvent": {
const eventId = args.eventId as string;
const existingEvent = context.existingEvents.find(
(e) => e.id === eventId,
);
const existingEvent = await context.fetchEventById(eventId);
if (!existingEvent) {
return { content: `Event mit ID ${eventId} nicht gefunden.` };
@@ -116,9 +161,7 @@ export function executeToolCall(
const eventId = args.eventId as string;
const deleteMode = (args.deleteMode as RecurringDeleteMode) || "all";
const occurrenceDate = args.occurrenceDate as string | undefined;
const existingEvent = context.existingEvents.find(
(e) => e.id === eventId,
);
const existingEvent = await context.fetchEventById(eventId);
if (!existingEvent) {
return { content: `Event mit ID ${eventId} nicht gefunden.` };
@@ -162,25 +205,46 @@ export function executeToolCall(
}
case "searchEvents": {
const query = (args.query as string).toLowerCase();
const matches = context.existingEvents.filter((e) =>
e.title.toLowerCase().includes(query),
);
const query = args.query as string;
const matches = await context.searchEvents(query);
if (matches.length === 0) {
return { content: `Keine Termine mit "${args.query}" gefunden.` };
return { content: `Keine Termine mit "${query}" gefunden.` };
}
const results = matches
.map((e) => {
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");
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:
return { content: `Unbekannte Funktion: ${name}` };
}

View File

@@ -17,6 +17,9 @@ import {
} from "./repositories";
import { GPTAdapter } from "./ai";
import { logger } from "./logging";
import { MongoCaldavRepository } from "./repositories/mongo/MongoCaldavRepository";
import { CaldavService } from "./services/CaldavService";
import { CaldavController } from "./controllers/CaldavController";
const app = express();
const port = process.env.PORT || 3000;
@@ -51,6 +54,7 @@ if (process.env.NODE_ENV !== "production") {
const userRepo = new MongoUserRepository();
const eventRepo = new MongoEventRepository();
const chatRepo = new MongoChatRepository();
const caldavRepo = new MongoCaldavRepository();
// Initialize AI provider
const aiProvider = new GPTAdapter();
@@ -58,17 +62,14 @@ const aiProvider = new GPTAdapter();
// Initialize services
const authService = new AuthService(userRepo);
const eventService = new EventService(eventRepo);
const chatService = new ChatService(
chatRepo,
eventRepo,
eventService,
aiProvider,
);
const caldavService = new CaldavService(caldavRepo, eventService);
const chatService = new ChatService(chatRepo, eventService, aiProvider);
// Initialize controllers
const authController = new AuthController(authService);
const chatController = new ChatController(chatService);
const eventController = new EventController(eventService);
const chatController = new ChatController(chatService, caldavService);
const eventController = new EventController(eventService, caldavService);
const caldavController = new CaldavController(caldavService);
// Setup routes
app.use(
@@ -77,6 +78,7 @@ app.use(
authController,
chatController,
eventController,
caldavController,
}),
);
@@ -96,8 +98,10 @@ app.post("/api/ai/test", async (req, res) => {
const result = await aiProvider.processMessage(message, {
userId: "test-user",
conversationHistory: [],
existingEvents: [],
currentDate: new Date(),
fetchEventsInRange: async () => [],
searchEvents: async () => [],
fetchEventById: async () => null,
});
res.json(result);
} catch (error) {

View File

@@ -21,12 +21,4 @@ export class AuthController {
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

@@ -8,13 +8,17 @@ import {
RecurringDeleteMode,
} from "@calchat/shared";
import { ChatService } from "../services";
import { CaldavService } from "../services/CaldavService";
import { createLogger } from "../logging";
import { AuthenticatedRequest } from "./AuthMiddleware";
const log = createLogger("ChatController");
export class ChatController {
constructor(private chatService: ChatService) {}
constructor(
private chatService: ChatService,
private caldavService: CaldavService,
) {}
async sendMessage(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
@@ -24,7 +28,7 @@ export class ChatController {
res.json(response);
} catch (error) {
log.error(
{ error, userId: req.user?.userId },
{ err: error, userId: req.user?.userId },
"Error processing message",
);
res.status(500).json({ error: "Failed to process message" });
@@ -68,10 +72,20 @@ export class ChatController {
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);
} catch (error) {
log.error(
{ error, conversationId: req.params.conversationId },
{ err: error, conversationId: req.params.conversationId },
"Error confirming event",
);
res.status(500).json({ error: "Failed to confirm event" });
@@ -92,7 +106,7 @@ export class ChatController {
res.json(response);
} catch (error) {
log.error(
{ error, conversationId: req.params.conversationId },
{ err: error, conversationId: req.params.conversationId },
"Error rejecting event",
);
res.status(500).json({ error: "Failed to reject event" });
@@ -109,7 +123,7 @@ export class ChatController {
res.json(conversations);
} catch (error) {
log.error(
{ error, userId: req.user?.userId },
{ err: error, userId: req.user?.userId },
"Error getting conversations",
);
res.status(500).json({ error: "Failed to get conversations" });
@@ -143,7 +157,7 @@ export class ChatController {
res.status(404).json({ error: "Conversation not found" });
} else {
log.error(
{ error, conversationId: req.params.id },
{ err: error, conversationId: req.params.id },
"Error getting conversation",
);
res.status(500).json({ error: "Failed to get conversation" });
@@ -173,7 +187,7 @@ export class ChatController {
}
} catch (error) {
log.error(
{ error, messageId: req.params.messageId },
{ err: error, messageId: req.params.messageId },
"Error updating proposal event",
);
res.status(500).json({ error: "Failed to update proposal event" });

View File

@@ -1,20 +1,49 @@
import { Response } from "express";
import { RecurringDeleteMode } from "@calchat/shared";
import { CalendarEvent, RecurringDeleteMode } from "@calchat/shared";
import { EventService } from "../services";
import { createLogger } from "../logging";
import { AuthenticatedRequest } from "./AuthMiddleware";
import { CaldavService } from "../services/CaldavService";
const log = createLogger("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> {
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);
} 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" });
}
}
@@ -31,7 +60,7 @@ export class EventController {
}
res.json(event);
} 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" });
}
}
@@ -41,7 +70,10 @@ export class EventController {
const events = await this.eventService.getAll(req.user!.userId);
res.json(events);
} 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" });
}
}
@@ -74,7 +106,7 @@ export class EventController {
res.json(events);
} catch (error) {
log.error(
{ error, start: req.query.start, end: req.query.end },
{ err: error, start: req.query.start, end: req.query.end },
"Error getting events by range",
);
res.status(500).json({ error: "Failed to get events" });
@@ -83,67 +115,69 @@ export class EventController {
async update(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const userId = req.user!.userId;
const event = await this.eventService.update(
req.params.id,
req.user!.userId,
userId,
req.body,
);
if (!event) {
res.status(404).json({ error: "Event not found" });
return;
}
await this.pushToCaldav(userId, event);
res.json(event);
} 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" });
}
}
async delete(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const userId = req.user!.userId;
const { mode, occurrenceDate } = req.query as {
mode?: RecurringDeleteMode;
occurrenceDate?: string;
};
// 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" });
return;
}
// If mode is specified, use deleteRecurring
if (mode) {
const result = await this.eventService.deleteRecurring(
req.params.id,
req.user!.userId,
userId,
mode,
occurrenceDate,
);
// For 'all' mode or when event was completely deleted, return 204
if (result === null && mode === "all") {
res.status(204).send();
return;
}
// For 'single' or 'future' modes, return updated event
// Event was updated (single/future mode) - push update to CalDAV
if (result) {
await this.pushToCaldav(userId, result);
res.json(result);
return;
}
// result is null but mode wasn't 'all' - event not found or was deleted
// 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
const deleted = await this.eventService.delete(
req.params.id,
req.user!.userId,
);
if (!deleted) {
res.status(404).json({ error: "Event not found" });
return;
}
await this.eventService.delete(req.params.id, userId);
await this.deleteFromCaldav(userId, event);
res.status(204).send();
} 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" });
}
}

View File

@@ -3,3 +3,4 @@ export * from "./ChatController";
export * from "./EventController";
export * from "./AuthMiddleware";
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.
* - Arrays: show length only
* - Long strings: truncate
* - Objects with conversationHistory/existingEvents: summarize
* - Objects with conversationHistory: summarize
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-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)) {
if (key === "conversationHistory" && Array.isArray(val)) {
summarized[key] = `[${val.length} messages]`;
} else if (key === "existingEvents" && Array.isArray(val)) {
summarized[key] = `[${val.length} events]`;
} else if (key === "proposedChanges" && Array.isArray(val)) {
// Log full proposedChanges for debugging AI issues
summarized[key] = val.map((p) => summarizeValue(p, depth + 1));

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

@@ -5,6 +5,7 @@ import {
CreateEventDTO,
GetMessagesOptions,
UpdateMessageDTO,
ConflictingEvent,
} from "@calchat/shared";
import { ChatRepository } from "../../services/interfaces";
import { Logged } from "../../logging";
@@ -25,12 +26,20 @@ export class MongoChatRepository implements ChatRepository {
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)
async getMessages(
conversationId: string,
options?: GetMessagesOptions,
): Promise<ChatMessage[]> {
const limit = options?.limit ?? 20;
const query: Record<string, unknown> = { conversationId };
// Cursor: load messages before this ID (for "load more" scrolling up)
@@ -39,9 +48,12 @@ export class MongoChatRepository implements ChatRepository {
}
// Fetch newest first, then reverse for chronological order
const docs = await ChatMessageModel.find(query)
.sort({ _id: -1 })
.limit(limit);
// Only apply limit if explicitly specified (no default - load all messages)
let queryBuilder = ChatMessageModel.find(query).sort({ _id: -1 });
if (options?.limit) {
queryBuilder = queryBuilder.limit(options.limit);
}
const docs = await queryBuilder;
return docs.reverse().map((doc) => doc.toJSON() as unknown as ChatMessage);
}
@@ -88,12 +100,28 @@ export class MongoChatRepository implements ChatRepository {
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: { "proposedChanges.$.event": event } },
{ $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);
}
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> {
const event = await EventModel.create({ userId, ...data });
// NOTE: Casting required because Mongoose's toJSON() type doesn't reflect our virtual 'id' field

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,
UpdateEventDTO,
ProposedEventChange,
ConflictingEvent,
} from "@calchat/shared";
import { IdVirtual } from "./types";
@@ -41,6 +42,15 @@ const UpdatesSchema = new Schema<UpdateEventDTO>(
{ _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>(
{
id: { type: String, required: true },
@@ -61,6 +71,7 @@ const ProposedChangeSchema = new Schema<ProposedEventChange>(
enum: ["single", "future", "all"],
},
occurrenceDate: { type: String },
conflictingEvents: { type: [ConflictingEventSchema] },
},
{ _id: false },
);

View File

@@ -2,22 +2,16 @@ import mongoose, { Schema, Document, Model } from "mongoose";
import { CalendarEvent } from "@calchat/shared";
import { IdVirtual } from "./types";
interface EventVirtuals extends IdVirtual {
isRecurring: boolean;
}
export interface EventDocument
extends Omit<CalendarEvent, "id" | "isRecurring">,
Document {
export interface EventDocument extends Omit<CalendarEvent, "id">, Document {
toJSON(): CalendarEvent;
}
const EventSchema = new Schema<
EventDocument,
Model<EventDocument, {}, {}, EventVirtuals>,
Model<EventDocument, {}, {}, IdVirtual>,
{},
{},
EventVirtuals
IdVirtual
>(
{
userId: {
@@ -25,6 +19,12 @@ const EventSchema = new Schema<
required: true,
index: true,
},
caldavUUID: {
type: String,
},
etag: {
type: String,
},
title: {
type: String,
required: true,
@@ -61,11 +61,6 @@ const EventSchema = new Schema<
return this._id.toString();
},
},
isRecurring: {
get() {
return !!this.recurrenceRule;
},
},
},
toJSON: {
virtuals: true,

View File

@@ -6,8 +6,6 @@ export function createAuthRoutes(authController: AuthController): Router {
router.post("/login", (req, res) => authController.login(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;
}

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

@@ -6,12 +6,15 @@ import {
AuthController,
ChatController,
EventController,
CaldavController,
} from "../controllers";
import { createCaldavRoutes } from "./caldav.routes";
export interface Controllers {
authController: AuthController;
chatController: ChatController;
eventController: EventController;
caldavController: CaldavController;
}
export function createRoutes(controllers: Controllers): Router {
@@ -20,6 +23,7 @@ export function createRoutes(controllers: Controllers): Router {
router.use("/auth", createAuthRoutes(controllers.authController));
router.use("/chat", createChatRoutes(controllers.chatController));
router.use("/events", createEventRoutes(controllers.eventController));
router.use("/caldav", createCaldavRoutes(controllers.caldavController));
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 * as jwt from "../utils/jwt";
import * as password from "../utils/password";
export class AuthService {
@@ -45,12 +44,4 @@ export class AuthService {
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,10 +9,10 @@ import {
CreateEventDTO,
UpdateEventDTO,
EventAction,
CreateMessageDTO,
RecurringDeleteMode,
ConflictingEvent,
} from "@calchat/shared";
import { ChatRepository, EventRepository, AIProvider } from "./interfaces";
import { ChatRepository, AIProvider } from "./interfaces";
import { EventService } from "./EventService";
import { getWeeksOverview, getMonthOverview } from "../utils/eventFormatters";
@@ -333,7 +333,7 @@ const staticResponses: TestResponse[] = [
async function getTestResponse(
index: number,
eventRepo: EventRepository,
eventService: EventService,
userId: string,
): Promise<TestResponse> {
const responseIdx = index % staticResponses.length;
@@ -341,7 +341,7 @@ async function getTestResponse(
// === SPORT TEST SCENARIO (Dynamic responses) ===
// Response 1: Add exception to "Sport" (2 weeks later)
if (responseIdx === 1) {
const events = await eventRepo.findByUserId(userId);
const events = await eventService.getAll(userId);
const sportEvent = events.find((e) => e.title === "Sport");
if (sportEvent) {
// Calculate date 2 weeks from the first occurrence
@@ -362,9 +362,7 @@ async function getTestResponse(
event: {
title: sportEvent.title,
startTime: exceptionDate,
endTime: new Date(
exceptionDate.getTime() + 90 * 60 * 1000,
), // +90 min
endTime: new Date(exceptionDate.getTime() + 90 * 60 * 1000), // +90 min
description: sportEvent.description,
recurrenceRule: sportEvent.recurrenceRule,
exceptionDates: sportEvent.exceptionDates,
@@ -374,25 +372,26 @@ async function getTestResponse(
};
}
return {
content: "Ich konnte keinen Termin 'Sport' finden. Bitte erstelle ihn zuerst.",
content:
"Ich konnte keinen Termin 'Sport' finden. Bitte erstelle ihn zuerst.",
};
}
// Response 2: Add UNTIL to "Sport" (after 6 weeks total)
if (responseIdx === 2) {
const events = await eventRepo.findByUserId(userId);
const 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 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:",
content: "Alles klar! Ich beende die Sport-Serie nach 6 Wochen:",
proposedChanges: [
{
id: "sport-until",
@@ -412,13 +411,14 @@ async function getTestResponse(
};
}
return {
content: "Ich konnte keinen Termin 'Sport' finden. Bitte erstelle ihn zuerst.",
content:
"Ich konnte keinen Termin 'Sport' finden. Bitte erstelle ihn zuerst.",
};
}
// Response 3: Add another exception to "Sport" (2 weeks after the first exception)
if (responseIdx === 3) {
const events = await eventRepo.findByUserId(userId);
const 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)
@@ -439,9 +439,7 @@ async function getTestResponse(
event: {
title: sportEvent.title,
startTime: exceptionDate,
endTime: new Date(
exceptionDate.getTime() + 90 * 60 * 1000,
), // +90 min
endTime: new Date(exceptionDate.getTime() + 90 * 60 * 1000), // +90 min
description: sportEvent.description,
recurrenceRule: sportEvent.recurrenceRule,
exceptionDates: sportEvent.exceptionDates,
@@ -451,19 +449,20 @@ async function getTestResponse(
};
}
return {
content: "Ich konnte keinen Termin 'Sport' finden. Bitte erstelle ihn zuerst.",
content:
"Ich konnte keinen Termin 'Sport' finden. Bitte erstelle ihn zuerst.",
};
}
// Dynamic responses: fetch events from DB and format
// (Note: indices shifted by +3 due to new sport responses)
if (responseIdx === 6) {
return { content: await getWeeksOverview(eventRepo, userId, 2) };
return { content: await getWeeksOverview(eventService, userId, 2) };
}
if (responseIdx === 7) {
// 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");
if (jensEvent) {
return {
@@ -487,12 +486,12 @@ async function getTestResponse(
}
if (responseIdx === 11) {
return { content: await getWeeksOverview(eventRepo, userId, 1) };
return { content: await getWeeksOverview(eventService, userId, 1) };
}
if (responseIdx === 13) {
// 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");
if (mamaEvent) {
const newStart = new Date(mamaEvent.startTime);
@@ -527,7 +526,7 @@ async function getTestResponse(
const now = new Date();
return {
content: await getMonthOverview(
eventRepo,
eventService,
userId,
now.getFullYear(),
now.getMonth(),
@@ -541,7 +540,6 @@ async function getTestResponse(
export class ChatService {
constructor(
private chatRepo: ChatRepository,
private eventRepo: EventRepository,
private eventService: EventService,
private aiProvider: AIProvider,
) {}
@@ -566,11 +564,14 @@ export class ChatService {
if (process.env.USE_TEST_RESPONSES === "true") {
// Test mode: use static responses
response = await getTestResponse(responseIndex, this.eventRepo, userId);
response = await getTestResponse(
responseIndex,
this.eventService,
userId,
);
responseIndex++;
} else {
// Production mode: use real AI
const events = await this.eventRepo.findByUserId(userId);
const history = await this.chatRepo.getMessages(conversationId, {
limit: 20,
});
@@ -578,8 +579,16 @@ export class ChatService {
response = await this.aiProvider.processMessage(data.content, {
userId,
conversationHistory: history,
existingEvents: events,
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);
},
});
}
@@ -616,10 +625,14 @@ export class ChatService {
let content: string;
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.`;
} 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
? `Der Termin "${updatedEvent.title}" wurde aktualisiert.`
: "Termin nicht gefunden.";
@@ -713,6 +726,56 @@ export class ChatService {
proposalId: string,
event: CreateEventDTO,
): Promise<ChatMessage | null> {
return this.chatRepo.updateProposalEvent(messageId, proposalId, event);
// 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

@@ -24,10 +24,21 @@ export class EventService {
return event;
}
async findByCaldavUUID(
userId: string,
caldavUUID: string,
): Promise<CalendarEvent | null> {
return this.eventRepo.findByCaldavUUID(userId, caldavUUID);
}
async getAll(userId: string): Promise<CalendarEvent[]> {
return this.eventRepo.findByUserId(userId);
}
async searchByTitle(userId: string, query: string): Promise<CalendarEvent[]> {
return this.eventRepo.searchByTitle(userId, query);
}
async getByDateRange(
userId: string,
startDate: Date,
@@ -37,8 +48,8 @@ export class EventService {
const allEvents = await this.eventRepo.findByUserId(userId);
// Separate recurring and non-recurring events
const recurringEvents = allEvents.filter((e) => e.isRecurring);
const nonRecurringEvents = allEvents.filter((e) => !e.isRecurring);
const recurringEvents = allEvents.filter((e) => e.recurrenceRule);
const nonRecurringEvents = allEvents.filter((e) => !e.recurrenceRule);
// Expand all events (recurring get multiple instances, non-recurring stay as-is)
const expanded = expandRecurringEvents(
@@ -90,7 +101,7 @@ export class EventService {
}
// For non-recurring events, always delete completely
if (!event.isRecurring || !event.recurrenceRule) {
if (!event.recurrenceRule) {
await this.eventRepo.delete(id);
return null;
}

View File

@@ -1,14 +1,24 @@
import {
CalendarEvent,
ChatMessage,
ProposedEventChange,
ExpandedEvent,
CalendarEvent,
} from "@calchat/shared";
export interface AIContext {
userId: string;
conversationHistory: ChatMessage[];
existingEvents: CalendarEvent[];
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 {

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

@@ -5,11 +5,13 @@ import {
CreateEventDTO,
GetMessagesOptions,
UpdateMessageDTO,
ConflictingEvent,
} from "@calchat/shared";
export interface ChatRepository {
// Conversations
getConversationsByUser(userId: string): Promise<Conversation[]>;
getConversationById(conversationId: string): Promise<Conversation | null>;
createConversation(userId: string): Promise<Conversation>;
// Messages (cursor-based pagination)
@@ -38,5 +40,8 @@ export interface ChatRepository {
messageId: string,
proposalId: string,
event: CreateEventDTO,
conflictingEvents?: ConflictingEvent[],
): Promise<ChatMessage | null>;
getMessageById(messageId: string): Promise<ChatMessage | null>;
}

View File

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

View File

@@ -6,7 +6,7 @@ import {
MONTH_TO_GERMAN,
ExpandedEvent,
} from "@calchat/shared";
import { EventRepository } from "../services/interfaces";
import { EventService } from "../services/EventService";
import { expandRecurringEvents } from "./recurrenceExpander";
// 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.
*/
export async function getWeeksOverview(
eventRepo: EventRepository,
eventService: EventService,
userId: string,
weeks: number,
): Promise<string> {
const now = new Date();
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);
return formatWeeksText(expanded, weeks);
}
@@ -123,14 +123,14 @@ export async function getWeeksOverview(
* Recurring events are expanded to show all occurrences within the month.
*/
export async function getMonthOverview(
eventRepo: EventRepository,
eventService: EventService,
userId: string,
year: number,
month: number,
): Promise<string> {
const startOfMonth = new Date(year, month, 1);
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 monthName = MONTH_TO_GERMAN[MONTHS[month]];
return formatMonthText(expanded, monthName);

View File

@@ -1,2 +1 @@
export * from "./jwt";
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 { CalendarEvent, ExpandedEvent } from "@calchat/shared";
import { CalendarEvent, ExpandedEvent, formatDateKey } from "@calchat/shared";
// Convert local time to "fake UTC" for rrule
// rrule interprets all dates as UTC internally, so we need to trick it
@@ -48,7 +48,7 @@ export function expandRecurringEvents(
// to find events that start before rangeStart but extend into the range
const adjustedRangeStart = new Date(rangeStart.getTime() - duration);
if (!event.isRecurring || !event.recurrenceRule) {
if (!event.recurrenceRule) {
// Non-recurring event: add if it overlaps with the range
if (endTime >= rangeStart && startTime <= rangeEnd) {
expanded.push({
@@ -133,11 +133,3 @@ function formatRRuleDateString(date: Date): string {
const seconds = String(date.getSeconds()).padStart(2, "0");
return `${year}${month}${day}T${hours}${minutes}${seconds}`;
}
// Format date as YYYY-MM-DD for exception date comparison
function formatDateKey(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -66,7 +66,7 @@ Backend & Express.js & Web-App Framework \\
& MongoDB & Datenbank \\
& Mongoose & ODM \\
& Claude (Anthropic) & KI / LLM \\
& JWT & Authentifizierung \\
& X-User-Id Header & Authentifizierung \\
\hline
Geplant & iCalendar & Event-Export \\
\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
generiert daraus strukturierte Event-Vorschläge.
Die Authentifizierung läuft über \textbf{JSON Web Tokens} (JWT). Der Vorteil:
zustandslose Sessions, bei denen der Server keine Session-Daten speichern muss.
Die Authentifizierung erfolgt über einen \textbf{X-User-Id Header}, der bei
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)
für den Export von Kalender-Events.
@@ -157,7 +158,7 @@ Notiz-Feld) und ChatMessage.
Der Controller Layer bildet die Schnittstelle zwischen Frontend und
Backend-Logik. Die Routes definieren die API-Endpunkte, die Controller
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}

6143
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,8 @@
"packages/*"
],
"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": {
"eslint": "^9.25.0",

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

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

View File

@@ -2,11 +2,14 @@
"name": "@calchat/shared",
"version": "1.0.0",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": "./src/index.ts",
"./*": "./src/*"
".": "./dist/index.js",
"./*": "./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,19 +1,22 @@
export interface CalendarEvent {
id: string;
userId: string;
caldavUUID?: string;
etag?: string;
title: string;
description?: string;
startTime: Date;
endTime: Date;
note?: string;
isRecurring?: boolean;
recurrenceRule?: string;
exceptionDates?: string[]; // ISO date strings (YYYY-MM-DD) for excluded occurrences
createdAt?: Date;
updatedAt?: Date;
caldavSyncStatus?: CaldavSyncStatus;
}
export type RecurringDeleteMode = "single" | "future" | "all";
export type CaldavSyncStatus = "synced" | "error";
export interface DeleteRecurringEventDTO {
mode: RecurringDeleteMode;
@@ -21,6 +24,8 @@ export interface DeleteRecurringEventDTO {
}
export interface CreateEventDTO {
caldavUUID?: string;
etag?: string;
title: string;
description?: string;
startTime: Date;
@@ -28,9 +33,12 @@ export interface CreateEventDTO {
note?: string;
recurrenceRule?: string;
exceptionDates?: string[]; // For display in proposals
caldavSyncStatus?: CaldavSyncStatus;
}
export interface UpdateEventDTO {
caldavUUID?: string;
etag?: string;
title?: string;
description?: string;
startTime?: Date;
@@ -38,6 +46,7 @@ export interface UpdateEventDTO {
note?: string;
recurrenceRule?: string;
exceptionDates?: string[];
caldavSyncStatus?: CaldavSyncStatus;
}
export interface ExpandedEvent extends CalendarEvent {

View File

@@ -10,6 +10,12 @@ export type EventAction = "create" | "update" | "delete";
export type RespondedAction = "confirm" | "reject";
export interface ConflictingEvent {
title: string;
startTime: Date;
endTime: Date;
}
export interface ProposedEventChange {
id: string; // Unique ID for each proposal
action: EventAction;
@@ -19,6 +25,7 @@ export interface ProposedEventChange {
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 {

View File

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

View File

@@ -69,3 +69,11 @@ export function formatDateWithWeekdayShort(date: Date): string {
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

@@ -11,6 +11,24 @@ const REPEAT_TYPE_TO_FREQ: Record<RepeatType, string> = {
Jahr: "YEARLY",
};
const FREQ_TO_REPEAT_TYPE: Record<string, RepeatType> = Object.fromEntries(
Object.entries(REPEAT_TYPE_TO_FREQ).map(([k, v]) => [v, k as RepeatType]),
);
export const REPEAT_TYPE_LABELS: Record<RepeatType, string> = {
Tag: "Tage",
Woche: "Wochen",
Monat: "Monate",
Jahr: "Jahre",
};
const REPEAT_TYPE_SINGULAR: Record<RepeatType, string> = {
Tag: "Jeden Tag",
Woche: "Jede Woche",
Monat: "Jeden Monat",
Jahr: "Jedes Jahr",
};
/**
* Build an RRULE string from repeat count and type.
*
@@ -18,7 +36,10 @@ const REPEAT_TYPE_TO_FREQ: Record<RepeatType, string> = {
* @param interval - The interval between repetitions (default: 1)
* @returns RRULE string like "FREQ=WEEKLY;INTERVAL=2"
*/
export function buildRRule(repeatType: RepeatType, interval: number = 1): string {
export function buildRRule(
repeatType: RepeatType,
interval: number = 1,
): string {
const freq = REPEAT_TYPE_TO_FREQ[repeatType];
if (interval <= 1) {
@@ -27,3 +48,30 @@ export function buildRRule(repeatType: RepeatType, interval: number = 1): string
return `FREQ=${freq};INTERVAL=${interval}`;
}
/**
* Format an RRULE string into a human-readable German description.
*
* @param rrule - RRULE string like "FREQ=WEEKLY;INTERVAL=2"
* @returns German description like "Alle 2 Wochen" or "Wöchentlich"
*/
export function formatRecurrenceRule(rrule: string): string {
const rule = rrule.replace(/^RRULE:/, "");
const parts = Object.fromEntries(
rule.split(";").map((p) => p.split("=") as [string, string]),
);
const freq = parts.FREQ;
const interval = parts.INTERVAL ? parseInt(parts.INTERVAL, 10) : 1;
const repeatType = FREQ_TO_REPEAT_TYPE[freq];
if (!repeatType) {
return "Wiederkehrend";
}
if (interval <= 1) {
return REPEAT_TYPE_SINGULAR[repeatType];
}
return `Alle ${interval} ${REPEAT_TYPE_LABELS[repeatType]}`;
}

View File

@@ -1,10 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"module": "ESNext",
"outDir": "dist",
"rootDir": "src",
"module": "CommonJS",
"target": "ES2020",
"moduleResolution": "Node",
"esModuleInterop": true,