Compare commits

...

107 Commits

Author SHA1 Message Date
65dfe857bf added docker-compose with mongo and calchat-server
Some checks failed
continuous-integration/drone/push Build was killed
2026-02-28 22:32:51 +01:00
b2e889a4cd formatting
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-28 17:50:48 +01:00
5a74bcf81b deploy
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-28 17:37:44 +01:00
0de8d9faa1 theme
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-28 12:12:54 +01:00
fbfb939841 done!
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-27 23:47:27 +01:00
7ce0591288 :(((((
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-27 23:10:25 +01:00
2c9237a81f fixed dockerfile
Some checks failed
continuous-integration/drone/push Build was killed
2026-02-27 22:57:17 +01:00
7d3e3a7e5d fixes
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-27 22:46:29 +01:00
3104eb7388 formatting
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-27 22:38:41 +01:00
302cd96267 :(
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-27 22:34:13 +01:00
7b6f454151 fix
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-27 22:24:51 +01:00
6df3595bb7 color
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-27 22:20:34 +01:00
0c67ffe106 done? 2026-02-27 22:18:43 +01:00
ae8ee89abc kill
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-27 22:11:47 +01:00
ceb3ea2bf8 kill
Some checks failed
continuous-integration/drone/push Build was killed
2026-02-27 22:03:02 +01:00
886dc275e6 fixing
Some checks failed
continuous-integration/drone/push Build was killed
2026-02-27 21:51:15 +01:00
53d8103c2f more power to the clone
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-27 21:39:57 +01:00
4de7759485 more power to the clone
Some checks failed
continuous-integration/drone/push Build was killed
2026-02-27 21:39:35 +01:00
7aabf7fae3 fixing
Some checks failed
continuous-integration/drone/push Build was killed
2026-02-27 21:33:06 +01:00
77bd61ecec debugging
Some checks failed
continuous-integration/drone/push Build was killed
2026-02-27 21:25:48 +01:00
6987509187 debugging
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-27 21:21:01 +01:00
e308a2aaca debugging
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-27 21:11:56 +01:00
f74b8a1546 debugging
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-27 21:09:17 +01:00
97dfea517f debugging
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-27 21:05:51 +01:00
95f249a401 debugging
Some checks failed
continuous-integration/drone/push Build encountered an error
2026-02-27 20:59:11 +01:00
74bdc3ad91 debugging
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-27 20:55:46 +01:00
bcafd06141 debugging
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-27 20:43:20 +01:00
733fa7d4e2 debugging
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-27 20:40:44 +01:00
ecf638642d debugging
Some checks failed
continuous-integration/drone/push Build encountered an error
2026-02-27 20:40:05 +01:00
bc5f5b314a debugging
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-27 20:31:07 +01:00
c5edbdaf38 more debugging
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-27 20:27:58 +01:00
783d02f2e8 echo statements
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-27 20:23:30 +01:00
18f722aa30 hopefully fixed template
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-27 20:18:33 +01:00
c0b3835cfd fixed some secrets
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-27 19:42:56 +01:00
417d85488d added e2e opentofu files
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-27 19:41:00 +01:00
d7b9f3d70b added kubernetes manifest
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-27 19:38:34 +01:00
641ecebf5a E2E CI pipeline mit ephemerer Infrastruktur
Some checks failed
continuous-integration/drone/push Build is failing
- drone.yml: deploy_latest Pipeline mit k3s test-backend, OpenTofu
  E2E-VMs, E2E-Test-Ausführung, Email-Notification und Cleanup
- Alte tag/promote Pipelines auskommentiert
- APK build/upload vorerst auskommentiert
- E2E test runner script (scripts/e2e-test.sh)
- tsconfig: expo/tsconfig.base Extension
- CLAUDE.md an neue CI/CD-Struktur angepasst
2026-02-27 19:35:06 +01:00
f25feb97da real apk again
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-27 00:30:16 +01:00
ba788a2a5e garage
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-27 00:27:52 +01:00
7be5ea42e3 'Die Fähigkeit zu sprechen macht dich noch nicht intelligent'
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-27 00:26:11 +01:00
ae8a770a8c :\
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-27 00:18:24 +01:00
2f65a76deb :/
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-27 00:16:26 +01:00
e6d680f140 alpine image instead of minio/mc
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-27 00:13:10 +01:00
924522cff8 minio image is weird
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-27 00:01:11 +01:00
6471c4d266 testing
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-26 23:50:23 +01:00
392f14709e testing
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-26 23:20:04 +01:00
fc338718d2 test apk build and upload
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-26 22:39:54 +01:00
5b4eece66d trying minio instead for upload to s3 storage
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-26 22:34:09 +01:00
e95df8a708 testing
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-26 21:54:22 +01:00
e8e2badc97 Merge branch 'main' of https://gitea.gilmour109.de/Gilmour109/calchat 2026-02-26 21:52:47 +01:00
2a3fbaf672 Update .drone.yml 2026-02-26 21:52:33 +01:00
79f59300c3 Update .drone.yml 2026-02-26 21:52:33 +01:00
be4f79453f push on main branch leads to loading up apk into garage storage 2026-02-26 21:52:33 +01:00
27602aee4c Add E2E testing infrastructure with WebdriverIO + Appium
Set up E2E test framework for Android using WebdriverIO, Appium, and
UiAutomator2. Add testID props to key components (AuthButton, BaseButton,
ChatBubble, CustomTextInput, ProposedEventCard) and apply testIDs to
login screen, chat screen, tab bar, and settings. Include initial tests
for app launch detection and login flow validation. Update CLAUDE.md
with E2E docs.
2026-02-26 21:37:40 +01:00
758808e4d0 Update .drone.yml
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-26 18:12:37 +00:00
30d7fd881e Update .drone.yml
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-26 17:48:18 +00:00
9935adbcbd push on main branch leads to loading up apk into garage storage
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-26 17:38:19 +01:00
4f5737d27e prepare step for shared
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-26 15:52:59 +01:00
3492d5bdc8 MMMMMOOOOOORRRRREEEEE... meh
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-26 14:52:04 +01:00
f5ed9a77c3 event more meh
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-26 14:38:52 +01:00
fd896eb380 more meh
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-26 14:34:51 +01:00
93077eb39c meh
Some checks failed
continuous-integration/drone/push Build encountered an error
2026-02-26 14:34:02 +01:00
56af2f25f6 trigger pipeline
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-26 14:13:19 +01:00
f155ff88c8 added APK build and Gitea release to CI pipelines 2026-02-26 13:59:22 +01:00
d29b8df9e3 longer timeout
Some checks failed
continuous-integration/drone/push Build was killed
2026-02-25 22:05:32 +01:00
ad7d846604 formatting
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-25 21:44:40 +01:00
15804a5605 added version endpoint
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-25 21:42:21 +01:00
d7902deeb4 pipeline
Some checks failed
continuous-integration/drone/push Build was killed
continuous-integration/drone/tag Build is passing
2026-02-25 20:09:52 +01:00
7fefb9a153 pino shoudn't be a dev dependency; hopefully fixed pipeline
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/tag Build is passing
2026-02-25 19:08:32 +01:00
565cb0a044 sanitize tag-names for kubernetes
Some checks failed
continuous-integration/drone/push Build was killed
continuous-integration/drone/tag Build is failing
2026-02-25 18:39:00 +01:00
6463100fbd feat: restore CI pipelines and add k3s deployment
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/tag Build is failing
Re-enable build/test/format pipelines, rename deploy_server to
deploy_latest, add upload_tag (tag-triggered k3s deploy) and
upload_commit (promote-triggered k3s deploy). Update CLAUDE.md.
2026-02-25 17:58:03 +01:00
b088e380a4 typo
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone Build is passing
2026-02-24 18:11:56 +01:00
54936f1b96 added tsconfig.json in Dockerfile
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-24 18:08:59 +01:00
e732305d99 feat: add deploy pipeline and switch Dockerfile to COPY-based build
Some checks failed
continuous-integration/drone/push Build is failing
Add deploy_server Drone pipeline that builds and pushes the Docker image
to Gitea Container Registry, then deploys to VPS via SSH. Switch
Dockerfile from git clone to COPY-based build for CI compatibility and
better layer caching. Change exposed port to 3001.
2026-02-24 17:52:48 +01:00
93a0928928 hopefully final pipeline fix for now
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-24 12:57:35 +01:00
68a49712bc another pipeline fix
Some checks failed
continuous-integration/drone/push Build encountered an error
2026-02-24 12:52:09 +01:00
602e4e1413 added types in pipeline
Some checks failed
continuous-integration/drone/push Build encountered an error
2026-02-24 12:47:14 +01:00
bf8bb3cfb8 feat: add Drone CI pipelines, Jest unit tests, and Prettier check
Some checks failed
continuous-integration/drone/push Build encountered an error
Add Drone CI with server build/test and format check pipelines.
Add unit tests for password utils and recurrenceExpander.
Add check_format script, fix Jest config to ignore dist/,
remove dead CaldavService.test.ts, apply Prettier formatting.
2026-02-24 12:43:31 +01:00
16848bfdf0 refactor: clone repo from Gitea in Dockerfile instead of COPY
Replace local COPY with git clone --depth 1 so the image can be built
without a local source context. Adds BRANCH build arg (default: main).
2026-02-18 20:12:05 +01:00
a3e7f0288e feat: add Docker support and compile shared package to dist
- Add multi-stage Dockerfile for server containerization
- Add .dockerignore to exclude unnecessary files from build context
- Switch shared package from source to compiled CommonJS output (dist/)
- Server dev/build scripts now build shared package first
- Fix deep imports to use @calchat/shared barrel export
- Update CLAUDE.md with Docker and shared package documentation
2026-02-18 19:37:27 +01:00
0c157da817 update README 2026-02-10 01:10:44 +01:00
e5cd64367d feat: add sync and logout toolbar to calendar screen
- Add CalendarToolbar component between header and weekdays in calendar.tsx
- Sync button with CalDAV sync, spinner during sync, green checkmark on success, red X on error (3s feedback)
- Sync button disabled/greyed out when no CalDAV config present
- Logout button with redirect to login screen
- Buttons styled with border and shadow
- Update CLAUDE.md with CalendarToolbar documentation
2026-02-09 23:51:43 +01:00
b9ffc6c908 refactor: reduce CalDAV sync to login and manual sync button only
- Remove auto-login sync in AuthGuard
- Remove 10s interval sync and syncAndReload in calendar tab
- Remove lazy syncOnce pattern in ChatService AI callbacks
- Remove CaldavService dependency from ChatService constructor
2026-02-09 23:32:04 +01:00
5a9485acfc fix: use pino err key for proper Error serialization in controllers
Error objects logged as { error } were serialized as {} because pino
only applies its error serializer to the err key.
2026-02-09 22:41:46 +01:00
189c38dc2b docs: clean up frontend class diagram layout
Comment out service methods for consistency with stores and switch to
left-to-right direction for a more vertical package arrangement.
2026-02-09 22:00:13 +01:00
73e768a0ad refactor: remove all JWT-related code and references
JWT was never used - auth uses X-User-Id header. Removes jwt.ts utility,
jsonwebtoken dependency, stubbed refresh/logout endpoints, and updates
all docs (PUML diagrams, api-routes, tex, CLAUDE.md) accordingly.
2026-02-09 20:02:05 +01:00
cb32bd23ca docs: add .env.example files for client and server 2026-02-09 19:57:55 +01:00
cbf123ddd6 feat: add visual feedback for CalDAV save & sync actions
- Show spinner + loading text while request is in progress
- Display success (green) or error (red) message, auto-clears after 3s
- Save and Sync have independent feedback rows (both visible at once)
- Fix CaldavTextInput theming and add secureTextEntry for password
- Reset CustomTextInput cursor to start when unfocused
2026-02-09 19:53:51 +01:00
3ad4a77951 fix: chat starts scrolled to bottom instead of visibly scrolling down
- Use onContentSizeChange to scroll after FlashList renders content
- Scroll without animation on initial load via needsInitialScroll ref
- Remove unreliable 100ms timeout scrollToEnd from message loading
2026-02-09 19:23:45 +01:00
aabce1a5b0 refactor: use CustomTextInput in login and register screens
- Replace raw TextInput with CustomTextInput in login and register
  for consistent focus border effect across the app
- Add placeholder, secureTextEntry, autoCapitalize, keyboardType
  props to CustomTextInput
- Remove hardcoded default padding (px-3 py-2) and h-11/12 from
  CustomTextInput, callers now set padding via className
- Add explicit px-3 py-2 to existing callers (settings, editEvent)
- Update CLAUDE.md with new CustomTextInput usage and props
2026-02-09 19:15:41 +01:00
868e1ba68d perf: preload events and CalDAV config to avoid empty screens
Add CaldavConfigStore and preloadAppData() to load events (current month)
and CalDAV config into stores before dismissing the auth loading spinner.
This prevents the brief empty flash when first navigating to Calendar or
Settings tabs. Also applies Prettier formatting across codebase.
2026-02-09 18:59:03 +01:00
0e406e4dca perf: load calendar events instantly, sync CalDAV in background
Split loadEvents into two functions: loadEvents (instant DB read) and
syncAndReload (background CalDAV sync + reload). Events now appear
immediately when switching to the Calendar tab instead of waiting for
the CalDAV sync to complete.
2026-02-09 18:37:14 +01:00
b94b5f5ed8 Merge branch 'main' of https://gitea.gilmour109.de/Gilmour109/calchat 2026-02-09 18:18:25 +01:00
0a2aef2098 fix: recurring event display and AI query improvements
- Use occurrenceStart instead of startTime in getEventsInRange so
  recurring events show their actual occurrence date to the AI
- Add lazy CalDAV sync in ChatService (syncOnce before first DB access)
- Add CaldavService.sync() with internal config check (silent no-op)
- Show German recurrence description (e.g. "Jede Woche") instead of
  generic "Wiederkehrend" in EventCardBase via formatRecurrenceRule()
- Move RepeatType and REPEAT_TYPE_LABELS from editEvent to shared
- Separate calendar overlay useFocusEffect from event loading
2026-02-09 18:17:39 +01:00
325246826a feat: add CalDAV synchronization with automatic sync
- Add CaldavService with tsdav/ical.js for CalDAV server communication
- Add CaldavController, CaldavRepository, and caldav routes
- Add client-side CaldavConfigService with sync(), config CRUD
- Add CalDAV settings UI with config load/save in settings screen
- Sync on login, auto-login (AuthGuard), periodic timer (calendar), and sync button
- Push single events to CalDAV on server-side create/update/delete
- Push all events to CalDAV after chat event confirmation
- Refactor ChatService to use EventService instead of direct EventRepository
- Rename CalDav/calDav to Caldav/caldav for consistent naming
- Add Radicale Docker setup for local CalDAV testing
- Update PlantUML diagrams and CLAUDE.md with CalDAV architecture
2026-02-08 19:24:59 +01:00
81221d8b70 refactor: remove redundant isRecurring property, use recurrenceRule instead
isRecurring was redundant since recurrenceRule as truthy/falsy check suffices.
Removed from shared CalendarEvent type, Mongoose virtual, and all usages.
2026-02-07 16:16:35 +01:00
be9d1c5b6d updated eas.json 2026-02-02 22:49:25 +01:00
1092ff2648 refactor: improve AI event handling and conflict display in chat
- AI fetches events on-demand via callbacks for better efficiency
- Add conflict detection with warning display when proposing overlapping events
- Improve event search and display in chat interface
- Load full chat history for display while limiting AI context
2026-02-02 22:44:08 +01:00
387bb2d1ee fix: auto-scroll to typing indicator in chat 2026-01-31 18:51:02 +01:00
6f0d172bf2 feat: add EditEventScreen with calendar and chat mode support
Add a unified event editor that works in two modes:
- Calendar mode: Create/edit events directly via EventService API
- Chat mode: Edit AI-proposed events before confirming them

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

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

Changes include:
- Add exceptionDates field to CalendarEvent model
- Add RecurringDeleteMode type and DeleteRecurringEventDTO
- EventService.deleteRecurring() with mode-based logic using rrule library
- EventController DELETE endpoint accepts mode/occurrenceDate query params
- recurrenceExpander filters out exception dates during expansion
- AI tools support deleteMode and occurrenceDate for proposed deletions
- ChatService.confirmEvent() handles recurring delete modes
- New DeleteEventModal component for unified delete confirmation UI
- Calendar screen integrates modal for both recurring and non-recurring events
2026-01-25 15:19:31 +01:00
a42e2a7c1c refactor: add AuthGuard component and fix API client empty response handling
- Add reusable AuthGuard component for protected routes
- Move auth logic from index.tsx to (tabs)/_layout.tsx via AuthGuard
- Fix ApiClient to handle empty responses (204 No Content)
- Use useFocusEffect in chat.tsx to load messages after auth is ready
- Extract getDateKey() helper function in calendar.tsx
2026-01-25 13:03:17 +01:00
43d40b46d7 feat: add theme system with light/dark mode support
- Add ThemeStore (Zustand) for reactive theme switching
- Add Themes.tsx with THEMES object (defaultLight, defaultDark)
- Add Settings screen with theme switcher and logout button
- Add BaseButton component for reusable themed buttons
- Migrate all components from static currentTheme to useThemeStore()
- Add shadowColor to theme (iOS only, Android uses elevation)
- All text elements now use theme colors (textPrimary, textSecondary, etc.)
- Update tab navigation to include Settings tab
- Move logout from Header to Settings screen
2026-01-24 16:57:33 +01:00
1dbca79edd feat: add typing indicator with ChatBubble component
- Add ChatBubble component for reusable chat bubble styling
- Add TypingIndicator component with animated dots (. .. ...)
- Show typing indicator after 500ms delay while waiting for AI response
- Refactor ChatMessage to use ChatBubble component
- Add isWaitingForResponse state to ChatStore
2026-01-12 22:49:21 +01:00
126 changed files with 24997 additions and 1281 deletions

8
.dockerignore Normal file
View File

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

334
.drone.yml Normal file
View File

@@ -0,0 +1,334 @@
kind: pipeline
type: docker
name: server_build_and_test
trigger:
branch:
- main
event:
- push
steps:
- name: build_server
image: node
commands:
- npm ci
- npm run build -w @calchat/shared
- 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
- ${DRONE_COMMIT_SHA:0:8}
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: 10m
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
- name: deploy_test_backend
image: gitea.gilmour109.de/gilmour109/e2e-tools:latest
environment:
K3S_SSH_PASSWORD:
from_secret: k3s_ssh_password
commands:
- export NAME=e2e$(echo $DRONE_COMMIT_SHA | head -c 8)
- export TAG=$(echo $DRONE_COMMIT_SHA | head -c 8)
- export COMMIT=$DRONE_COMMIT_SHA
- envsubst < kubernetes/manifest.yml > /tmp/e2e-manifest.yml
- sshpass -p "$K3S_SSH_PASSWORD" scp /tmp/e2e-manifest.yml debian@192.168.178.201:/tmp/e2e-manifest.yml
- sshpass -p "$K3S_SSH_PASSWORD" ssh debian@192.168.178.201 "sudo kubectl apply -f /tmp/e2e-manifest.yml"
- sshpass -p "$K3S_SSH_PASSWORD" ssh debian@192.168.178.201 "sudo kubectl wait --for=condition=available --timeout=120s deployment/calchat-server-$NAME"
- name: create_e2e_vm
image: gitea.gilmour109.de/gilmour109/e2e-tools:latest
environment:
TF_VAR_run_id: ${DRONE_BUILD_NUMBER}
TF_VAR_proxmox_password:
from_secret: proxmox_password
TF_VAR_clone_vm_password:
from_secret: e2e_vm_password
AWS_ACCESS_KEY_ID:
from_secret: tofu_garage_access_key
AWS_SECRET_ACCESS_KEY:
from_secret: tofu_garage_secret_key
commands:
- cd tofu/e2e
- tofu init
- tofu apply -auto-approve
- name: run_e2e_tests
image: gitea.gilmour109.de/gilmour109/e2e-tools:latest
environment:
E2E_VM_PASSWORD:
from_secret: e2e_vm_password
commands:
- export VM_IP=192.168.178.$((211 + DRONE_BUILD_NUMBER % 44))
- export RUN_ID=$(echo $DRONE_COMMIT_SHA | head -c 8)
- export API_URL="http://e2e$${RUN_ID}.192.168.178.201.nip.io"
- bash scripts/run-e2e.sh "$VM_IP" "$API_URL" "https://gitea.gilmour109.de/gilmour109/calchat.git" "$DRONE_COMMIT_SHA" "$E2E_VM_PASSWORD"
- name: destroy_e2e_vm
image: gitea.gilmour109.de/gilmour109/e2e-tools:latest
environment:
TF_VAR_run_id: ${DRONE_BUILD_NUMBER}
TF_VAR_proxmox_password:
from_secret: proxmox_password
TF_VAR_clone_vm_password:
from_secret: e2e_vm_password
AWS_ACCESS_KEY_ID:
from_secret: tofu_garage_access_key
AWS_SECRET_ACCESS_KEY:
from_secret: tofu_garage_secret_key
commands:
- cd tofu/e2e
- tofu init
- tofu destroy -auto-approve
when:
status:
- success
- failure
- name: cleanup_k3s
image: gitea.gilmour109.de/gilmour109/e2e-tools:latest
environment:
K3S_SSH_PASSWORD:
from_secret: k3s_ssh_password
commands:
- export NAME=e2e$(echo $DRONE_COMMIT_SHA | head -c 8)
- sshpass -p "$K3S_SSH_PASSWORD" ssh debian@192.168.178.201 "sudo kubectl delete all,ingress -l deploy-name=$NAME --ignore-not-found"
when:
status:
- success
- failure
- name: build_apk
image: gitea.gilmour109.de/gilmour109/eas-build:latest
environment:
EXPO_TOKEN:
from_secret: expo_token
commands:
- npm ci
- npm run build -w @calchat/shared
- npm run -w @calchat/client build:apk
when:
status:
- success
- name: upload_apk
image: plugins/s3
settings:
endpoint: https://garage.gilmour109.de
bucket: calchat-releases
access_key:
from_secret: calchat_drone_garage_access_key
secret_key:
from_secret: calchat_drone_garage_secret_key
source: apps/client/calchat.apk
target: /
region: garage
path_style: true
when:
status:
- success
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
- drone_commit_sha
port: 22
command_timeout: 10m
script:
- export TAG=$DRONE_TAG
- export NAME=$(echo $DRONE_TAG | tr -d '.')
- export COMMIT=$DRONE_COMMIT_SHA
- envsubst < /home/debian/manifest.yml | sudo kubectl apply -f -
- name: create_e2e_vm
image: gitea.gilmour109.de/gilmour109/e2e-tools:latest
environment:
TF_VAR_run_id: ${DRONE_BUILD_NUMBER}
TF_VAR_proxmox_password:
from_secret: proxmox_password
TF_VAR_clone_vm_password:
from_secret: e2e_vm_password
AWS_ACCESS_KEY_ID:
from_secret: tofu_garage_access_key
AWS_SECRET_ACCESS_KEY:
from_secret: tofu_garage_secret_key
commands:
- cd tofu/e2e
- tofu init
- tofu apply -auto-approve
- name: run_e2e_tests
image: gitea.gilmour109.de/gilmour109/e2e-tools:latest
environment:
E2E_VM_PASSWORD:
from_secret: e2e_vm_password
commands:
- export VM_IP=192.168.178.$((211 + DRONE_BUILD_NUMBER % 44))
- export TAG_NAME=$(echo $DRONE_TAG | tr -d '.')
- export API_URL="http://$${TAG_NAME}.192.168.178.201.nip.io"
- bash scripts/run-e2e.sh "$VM_IP" "$API_URL" "https://gitea.gilmour109.de/gilmour109/calchat.git" "$DRONE_COMMIT_SHA" "$E2E_VM_PASSWORD"
- name: notify_failure
image: drillster/drone-email
settings:
host:
from_secret: smtp_host
username:
from_secret: smtp_username
password:
from_secret: smtp_password
from: drone@gilmour109.de
recipients:
- liwa7755@bht-berlin.de
subject: "E2E Tests failed: ${DRONE_REPO} ${DRONE_TAG} #${DRONE_BUILD_NUMBER}"
body: |
E2E tests failed for tag ${DRONE_TAG} (commit ${DRONE_COMMIT_SHA:0:8}).
Build: ${DRONE_BUILD_LINK}
when:
status:
- failure
- name: destroy_e2e_vm
image: gitea.gilmour109.de/gilmour109/e2e-tools:latest
environment:
TF_VAR_run_id: ${DRONE_BUILD_NUMBER}
TF_VAR_proxmox_password:
from_secret: proxmox_password
TF_VAR_clone_vm_password:
from_secret: e2e_vm_password
AWS_ACCESS_KEY_ID:
from_secret: tofu_garage_access_key
AWS_SECRET_ACCESS_KEY:
from_secret: tofu_garage_secret_key
commands:
- cd tofu/e2e
- tofu init
- tofu destroy -auto-approve
when:
status:
- success
- failure
- name: build_apk
image: gitea.gilmour109.de/gilmour109/eas-build:latest
environment:
EXPO_TOKEN:
from_secret: expo_token
commands:
- npm ci
- npm run build -w @calchat/shared
- npm run -w @calchat/client build:apk
when:
status:
- success
- name: release_apk
image: plugins/gitea-release
settings:
api_key:
from_secret: gitea_token
base_url: https://gitea.gilmour109.de
files:
- calchat.apk
title: ${DRONE_TAG}
when:
status:
- success

2
.gitignore vendored
View File

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

523
CLAUDE.md
View File

@@ -14,6 +14,7 @@ This is a fullstack TypeScript monorepo with npm workspaces.
```bash ```bash
npm install # Install all dependencies for all workspaces npm install # Install all dependencies for all workspaces
npm run format # Format all TypeScript files with Prettier npm run format # Format all TypeScript files with Prettier
npm run check_format # Check formatting without modifying files (used in CI)
``` ```
### Client (apps/client) - Expo React Native app ### Client (apps/client) - Expo React Native app
@@ -24,13 +25,20 @@ npm run ios -w @calchat/client # Start on iOS
npm run web -w @calchat/client # Start web version npm run web -w @calchat/client # Start web version
npm run lint -w @calchat/client # Run ESLint npm run lint -w @calchat/client # Run ESLint
npm run build:apk -w @calchat/client # Build APK locally with EAS npm run build:apk -w @calchat/client # Build APK locally with EAS
npm run test:e2e -w @calchat/client # Run E2E tests (requires Appium server running)
```
### Shared (packages/shared)
```bash
npm run build -w @calchat/shared # Compile shared types to dist/
``` ```
### Server (apps/server) - Express.js backend ### Server (apps/server) - Express.js backend
```bash ```bash
npm run dev -w @calchat/server # Start dev server with hot reload (tsx watch) npm run dev -w @calchat/server # Build shared + start dev server with hot reload (tsx watch)
npm run build -w @calchat/server # Compile TypeScript npm run build -w @calchat/server # Build shared + compile TypeScript
npm run start -w @calchat/server # Run compiled server (port 3000) npm run start -w @calchat/server # Run compiled server (port 3000)
npm run test -w @calchat/server # Run Jest unit tests
``` ```
## Technology Stack ## Technology Stack
@@ -48,9 +56,18 @@ npm run start -w @calchat/server # Run compiled server (port 3000)
| | MongoDB | Database | | | MongoDB | Database |
| | Mongoose | ODM | | | Mongoose | ODM |
| | GPT (OpenAI) | AI/LLM for chat | | | GPT (OpenAI) | AI/LLM for chat |
| | X-User-Id Header | Authentication (simple, no JWT yet) | | | X-User-Id Header | Authentication |
| | pino / pino-http | Structured logging | | | pino / pino-http | Structured logging |
| | react-native-logs | Client-side logging | | | react-native-logs | Client-side logging |
| | tsdav | CalDAV client library |
| | ical.js | iCalendar parsing/generation |
| Testing | Jest / ts-jest | Server unit tests |
| | WebdriverIO + Appium | E2E tests (Android) |
| | UiAutomator2 | Android UI automation driver |
| Deployment | Docker | Server containerization (multi-stage build) |
| | Drone CI | CI/CD pipelines (build, test, format check, deploy, E2E) |
| | OpenTofu | Infrastructure as Code for ephemeral E2E VMs (Proxmox) |
| | Kubernetes (k3s) | Test backend deployments for E2E |
| Planned | iCalendar | Event export/import | | Planned | iCalendar | Event export/import |
## Architecture ## Architecture
@@ -60,6 +77,9 @@ npm run start -w @calchat/server # Run compiled server (port 3000)
apps/client - @calchat/client - Expo React Native app apps/client - @calchat/client - Expo React Native app
apps/server - @calchat/server - Express.js backend apps/server - @calchat/server - Express.js backend
packages/shared - @calchat/shared - Shared TypeScript types and models packages/shared - @calchat/shared - Shared TypeScript types and models
scripts/ - CI/E2E helper scripts
tofu/e2e/ - OpenTofu config for ephemeral E2E VMs (Proxmox)
kubernetes/ - k3s manifest templates for test deployments
``` ```
### Frontend Architecture (apps/client) ### Frontend Architecture (apps/client)
@@ -72,40 +92,173 @@ src/
│ ├── login.tsx # Login screen │ ├── login.tsx # Login screen
│ ├── register.tsx # Registration screen │ ├── register.tsx # Registration screen
│ ├── (tabs)/ # Tab navigation group │ ├── (tabs)/ # Tab navigation group
│ │ ├── _layout.tsx # Tab bar configuration │ │ ├── _layout.tsx # Tab bar configuration (themed)
│ │ ├── chat.tsx # Chat screen (AI conversation) │ │ ├── chat.tsx # Chat screen (AI conversation)
│ │ ── calendar.tsx # Calendar overview │ │ ── calendar.tsx # Calendar overview (with CalendarToolbar: sync + logout)
│ │ └── settings.tsx # Settings screen (theme switcher, logout, CalDAV config with feedback)
│ ├── editEvent.tsx # Event edit screen (dual-mode: calendar/chat)
│ ├── event/ │ ├── event/
│ │ └── [id].tsx # Event detail screen (dynamic route) │ │ └── [id].tsx # Event detail screen (dynamic route)
│ └── note/ │ └── note/
│ └── [id].tsx # Note editor for event (dynamic route) │ └── [id].tsx # Note editor for event (dynamic route)
├── components/ ├── components/
│ ├── BaseBackground.tsx # Common screen wrapper │ ├── AuthGuard.tsx # Auth wrapper: loads user, preloads app data (events + CalDAV config), CalDAV sync, shows loading, redirects if unauthenticated. Exports preloadAppData()
│ ├── Header.tsx # Header component with logout button │ ├── BaseBackground.tsx # Common screen wrapper (themed)
│ ├── AuthButton.tsx # Reusable button for auth screens (with shadow) │ ├── BaseButton.tsx # Reusable button component (themed, supports children)
│ ├── EventCardBase.tsx # Shared event card layout with icons (used by EventCard & ProposedEventCard) │ ├── Header.tsx # Header component (themed)
│ ├── AuthButton.tsx # Reusable button for auth screens (themed, with shadow)
│ ├── CardBase.tsx # Reusable card component (header + content + optional footer)
│ ├── ModalBase.tsx # Reusable modal with backdrop (uses CardBase, click-outside-to-close)
│ ├── ChatBubble.tsx # Reusable chat bubble component (used by ChatMessage & TypingIndicator)
│ ├── TypingIndicator.tsx # Animated typing indicator (. .. ...) shown while waiting for AI response
│ ├── EventCardBase.tsx # Event card layout with icons (uses CardBase)
│ ├── EventCard.tsx # Calendar event card (uses EventCardBase + edit/delete buttons) │ ├── EventCard.tsx # Calendar event card (uses EventCardBase + edit/delete buttons)
│ ├── EventConfirmDialog.tsx # AI-proposed event confirmation modal │ ├── EventConfirmDialog.tsx # AI-proposed event confirmation modal (skeleton)
── ProposedEventCard.tsx # Chat event proposal (uses EventCardBase + confirm/reject buttons) ── ProposedEventCard.tsx # Chat event proposal (uses EventCardBase + confirm/reject/edit buttons)
├── Themes.tsx # Centralized color/theme definitions │ ├── 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
├── logging/ ├── logging/
│ ├── index.ts # Re-exports │ ├── index.ts # Re-exports
│ └── logger.ts # react-native-logs config (apiLogger, storeLogger) │ └── logger.ts # react-native-logs config (apiLogger, storeLogger)
├── services/ ├── services/
│ ├── index.ts # Re-exports all services │ ├── index.ts # Re-exports all services
│ ├── ApiClient.ts # HTTP client with X-User-Id header injection, request logging │ ├── ApiClient.ts # HTTP client with X-User-Id header injection, request logging, handles empty responses (204)
│ ├── AuthService.ts # login(), register(), logout() - calls API and updates AuthStore │ ├── AuthService.ts # login(), register(), logout() - calls API and updates AuthStore
│ ├── EventService.ts # getAll(), getById(), getByDateRange(), create(), update(), delete() │ ├── EventService.ts # getAll(), getById(), getByDateRange(), create(), update(), delete(mode, occurrenceDate)
── ChatService.ts # sendMessage(), confirmEvent(), rejectEvent(), getConversations(), getConversation() ── ChatService.ts # sendMessage(), confirmEvent(deleteMode, occurrenceDate), rejectEvent(), getConversations(), getConversation(), updateProposalEvent()
└── stores/ # Zustand state management │ └── CaldavConfigService.ts # saveConfig(), getConfig(), deleteConfig(), pull(), pushAll(), sync()
├── index.ts # Re-exports all stores ├── stores/ # Zustand state management
├── AuthStore.ts # user, isAuthenticated, isLoading, login(), logout(), loadStoredUser() ├── index.ts # Re-exports all stores
# Uses expo-secure-store (native) / localStorage (web) │ ├── AuthStore.ts # user, isAuthenticated, isLoading, login(), logout(), loadStoredUser()
├── ChatStore.ts # messages[], addMessage(), addMessages(), updateMessage(), clearMessages(), chatMessageToMessageData() │ │ # Uses expo-secure-store (native) / localStorage (web)
── EventsStore.ts # events[], setEvents(), addEvent(), updateEvent(), deleteEvent() ── ChatStore.ts # messages[], isWaitingForResponse, addMessage(), addMessages(), updateMessage(), clearMessages(), setWaitingForResponse(), chatMessageToMessageData()
│ ├── 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
e2e/ # E2E tests (WebdriverIO + Appium)
├── jest.config.ts # Jest config (ts-jest, 120s timeout)
├── tsconfig.json # TypeScript config (ES2020, CommonJS)
├── .env # Test credentials, device name, Appium host/port
├── config/
│ └── capabilities.ts # Appium capabilities (Dev Client vs APK mode)
├── helpers/
│ ├── driver.ts # WebdriverIO driver singleton (init, get, quit)
│ ├── selectors.ts # testID constants for all screens
│ └── utils.ts # Helpers (waitForTestId, performLogin, ensureLoggedIn, ensureOnLoginScreen)
└── tests/
├── 01-app-launch.test.ts # App startup & screen detection
└── 02-login.test.ts # Login flow (empty fields, invalid creds, success)
``` ```
**Routing:** Tab-based navigation with Chat and Calendar as main screens. Auth screens (login, register) outside tabs. Dynamic routes for event detail and note editing. **Routing:** Tab-based navigation with Chat, Calendar, and Settings as main screens. Auth screens (login, register) outside tabs. Dynamic routes for event detail and note editing.
**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
The app supports multiple themes (light/dark) via a reactive Zustand store.
**Theme Structure (`Themes.tsx`):**
```typescript
export type Theme = {
chatBot, primeFg, primeBg, secondaryBg, messageBorderBg, placeholderBg,
calenderBg, confirmButton, rejectButton, disabledButton, buttonText,
textPrimary, textSecondary, textMuted, eventIndicator, borderPrimary, shadowColor
};
export const THEMES = {
defaultLight: { ... },
defaultDark: { ... }
} as const satisfies Record<string, Theme>;
```
**Usage in Components:**
```typescript
import { useThemeStore } from "../stores/ThemeStore";
const MyComponent = () => {
const { theme } = useThemeStore();
return <View style={{ backgroundColor: theme.primeBg }} />;
};
```
**Theme Switching:**
```typescript
const { setTheme } = useThemeStore();
setTheme("defaultDark"); // or "defaultLight"
```
**Note:** `shadowColor` only works on iOS. Android uses `elevation` with system-defined shadow colors.
### Base Components (CardBase & ModalBase)
Reusable base components for cards and modals with consistent styling.
**CardBase** - Card structure with header, content, and optional footer:
```typescript
<CardBase
title="Title"
subtitle="Optional subtitle"
footer={{ label: "Button", onPress: () => {} }}
// Styling props (all optional):
headerPadding={4} // p-{n}, default: px-3 py-2
contentPadding={4} // p-{n}, default: px-3 py-2
headerTextSize="text-lg" // "text-sm" | "text-base" | "text-lg" | "text-xl"
borderWidth={2} // outer border, default: 2
headerBorderWidth={3} // header bottom border, default: borderWidth
contentBg={theme.primeBg} // content background color, default: theme.secondaryBg
scrollable={true} // wrap content in ScrollView
maxContentHeight={400} // for scrollable content
>
{children}
</CardBase>
```
**ModalBase** - Modal with backdrop using CardBase internally:
```typescript
<ModalBase
visible={isVisible}
onClose={() => setVisible(false)}
title="Modal Title"
subtitle="Optional"
footer={{ label: "Close", onPress: onClose }}
scrollable={true}
maxContentHeight={400}
>
{children}
</ModalBase>
```
ModalBase provides: transparent Modal + backdrop (click-outside-to-close) + Android back button support.
**ModalBase Architecture Note:** Uses absolute-positioned backdrop behind the card content (not nested Pressables). This approach:
- Fixes modal stacking issues on web (React Native Web renders modals as DOM portals)
- Allows proper scrolling on Android (no touch event conflicts)
- Card naturally blocks touches from reaching backdrop due to z-order
**Component Hierarchy:**
```
CardBase
├── ModalBase (uses CardBase)
│ ├── DeleteEventModal
│ └── EventOverlay (in calendar.tsx)
└── EventCardBase (uses CardBase)
├── EventCard
└── ProposedEventCard
```
### Backend Architecture (apps/server) ### Backend Architecture (apps/server)
@@ -113,9 +266,10 @@ src/
src/ src/
├── app.ts # Entry point, DI setup, Express config ├── app.ts # Entry point, DI setup, Express config
├── controllers/ # Request handlers + middleware (per architecture diagram) ├── controllers/ # Request handlers + middleware (per architecture diagram)
│ ├── AuthController.ts # login(), register(), refresh(), logout() │ ├── AuthController.ts # login(), register()
│ ├── ChatController.ts # sendMessage(), confirmEvent(), rejectEvent(), getConversations(), getConversation() │ ├── ChatController.ts # sendMessage(), confirmEvent() + CalDAV push, rejectEvent(), getConversations(), getConversation(), updateProposalEvent()
│ ├── EventController.ts # create(), getById(), getAll(), getByDateRange(), update(), delete() │ ├── EventController.ts # create(), getById(), getAll(), getByDateRange(), update(), delete() - pushes/deletes to CalDAV on mutations
│ ├── CaldavController.ts # saveConfig(), loadConfig(), deleteConfig(), pullEvents(), pushEvents(), pushEvent()
│ ├── AuthMiddleware.ts # authenticate() - X-User-Id header validation │ ├── AuthMiddleware.ts # authenticate() - X-User-Id header validation
│ └── LoggingMiddleware.ts # httpLogger - pino-http request logging │ └── LoggingMiddleware.ts # httpLogger - pino-http request logging
├── logging/ ├── logging/
@@ -126,16 +280,19 @@ src/
│ ├── index.ts # Combines all routes under /api │ ├── index.ts # Combines all routes under /api
│ ├── auth.routes.ts # /api/auth/* │ ├── auth.routes.ts # /api/auth/*
│ ├── chat.routes.ts # /api/chat/* (protected) │ ├── chat.routes.ts # /api/chat/* (protected)
── event.routes.ts # /api/events/* (protected) ── event.routes.ts # /api/events/* (protected)
│ └── caldav.routes.ts # /api/caldav/* (protected)
├── services/ # Business logic ├── services/ # Business logic
│ ├── interfaces/ # DB-agnostic interfaces (for dependency injection) │ ├── interfaces/ # DB-agnostic interfaces (for dependency injection)
│ │ ├── AIProvider.ts # processMessage() │ │ ├── AIProvider.ts # processMessage()
│ │ ├── UserRepository.ts # findById, findByEmail, findByUserName, create + CreateUserData │ │ ├── UserRepository.ts # findById, findByEmail, findByUserName, create + CreateUserData
│ │ ├── EventRepository.ts │ │ ├── EventRepository.ts
│ │ ── ChatRepository.ts │ │ ── ChatRepository.ts
│ │ └── CaldavRepository.ts
│ ├── AuthService.ts │ ├── AuthService.ts
│ ├── ChatService.ts │ ├── ChatService.ts
── EventService.ts ── EventService.ts
│ └── CaldavService.ts # connect(), pullEvents(), pushEvent(), pushAll(), deleteEvent(), sync logic
├── repositories/ # Data access (DB-specific implementations) ├── repositories/ # Data access (DB-specific implementations)
│ ├── index.ts # Re-exports from ./mongo │ ├── index.ts # Re-exports from ./mongo
│ └── mongo/ # MongoDB implementation │ └── mongo/ # MongoDB implementation
@@ -143,21 +300,22 @@ src/
│ │ ├── types.ts # Shared types (IdVirtual interface) │ │ ├── types.ts # Shared types (IdVirtual interface)
│ │ ├── UserModel.ts │ │ ├── UserModel.ts
│ │ ├── EventModel.ts │ │ ├── EventModel.ts
│ │ ── ChatModel.ts │ │ ── ChatModel.ts
│ │ └── CaldavConfigModel.ts
│ ├── MongoUserRepository.ts # findById, findByEmail, findByUserName, create │ ├── MongoUserRepository.ts # findById, findByEmail, findByUserName, create
│ ├── MongoEventRepository.ts │ ├── MongoEventRepository.ts
── MongoChatRepository.ts ── MongoChatRepository.ts
│ └── MongoCaldavRepository.ts
├── ai/ ├── ai/
│ ├── GPTAdapter.ts # Implements AIProvider using OpenAI GPT │ ├── GPTAdapter.ts # Implements AIProvider using OpenAI GPT
│ ├── index.ts # Re-exports GPTAdapter │ ├── index.ts # Re-exports GPTAdapter
│ └── utils/ # Shared AI utilities (provider-agnostic) │ └── utils/ # Shared AI utilities (provider-agnostic)
│ ├── index.ts # Re-exports │ ├── index.ts # Re-exports
│ ├── eventFormatter.ts # formatExistingEvents() for system prompt │ ├── eventFormatter.ts # Re-exports formatDate/Time/DateTime from shared
│ ├── systemPrompt.ts # buildSystemPrompt() - German calendar assistant prompt │ ├── systemPrompt.ts # buildSystemPrompt() - German calendar assistant prompt
│ ├── toolDefinitions.ts # TOOL_DEFINITIONS - provider-agnostic tool specs │ ├── toolDefinitions.ts # TOOL_DEFINITIONS - provider-agnostic tool specs
│ └── toolExecutor.ts # executeToolCall() - handles getDay, proposeCreate/Update/Delete, searchEvents │ └── toolExecutor.ts # executeToolCall() - handles getDay, proposeCreate/Update/Delete, searchEvents, getEventsInRange
├── utils/ ├── utils/
│ ├── jwt.ts # signToken(), verifyToken() - NOT USED YET (no JWT)
│ ├── password.ts # hash(), compare() using bcrypt │ ├── password.ts # hash(), compare() using bcrypt
│ ├── eventFormatters.ts # getWeeksOverview(), getMonthOverview() - formatted event listings │ ├── eventFormatters.ts # getWeeksOverview(), getMonthOverview() - formatted event listings
│ └── recurrenceExpander.ts # expandRecurringEvents() - expand recurring events into occurrences │ └── recurrenceExpander.ts # expandRecurringEvents() - expand recurring events into occurrences
@@ -168,31 +326,39 @@ src/
**API Endpoints:** **API Endpoints:**
- `POST /api/auth/login` - User login - `POST /api/auth/login` - User login
- `POST /api/auth/register` - User registration - `POST /api/auth/register` - User registration
- `POST /api/auth/refresh` - Refresh JWT token
- `POST /api/auth/logout` - User logout
- `GET /api/events` - Get all events (protected) - `GET /api/events` - Get all events (protected)
- `GET /api/events/range` - Get events by date range (protected) - `GET /api/events/range` - Get events by date range (protected)
- `GET /api/events/:id` - Get single event (protected) - `GET /api/events/:id` - Get single event (protected)
- `POST /api/events` - Create event (protected) - `POST /api/events` - Create event (protected)
- `PUT /api/events/:id` - Update event (protected) - `PUT /api/events/:id` - Update event (protected)
- `DELETE /api/events/:id` - Delete event (protected) - `DELETE /api/events/:id` - Delete event (protected, query params: mode, occurrenceDate for recurring)
- `POST /api/chat/message` - Send message to AI (protected) - `POST /api/chat/message` - Send message to AI (protected)
- `POST /api/chat/confirm/:conversationId/:messageId` - Confirm proposed event (protected) - `POST /api/chat/confirm/:conversationId/:messageId` - Confirm proposed event (protected)
- `POST /api/chat/reject/:conversationId/:messageId` - Reject proposed event (protected) - `POST /api/chat/reject/:conversationId/:messageId` - Reject proposed event (protected)
- `GET /api/chat/conversations` - Get all conversations (protected) - `GET /api/chat/conversations` - Get all conversations (protected)
- `GET /api/chat/conversations/:id` - Get messages of a conversation with cursor-based pagination (protected) - `GET /api/chat/conversations/:id` - Get messages of a conversation with cursor-based pagination (protected)
- `PUT /api/chat/messages/:messageId/proposal` - Update proposal event data before confirming (protected)
- `PUT /api/caldav/config` - Save CalDAV config (protected)
- `GET /api/caldav/config` - Load CalDAV config (protected)
- `DELETE /api/caldav/config` - Delete CalDAV config (protected)
- `POST /api/caldav/pull` - Pull events from CalDAV server (protected)
- `POST /api/caldav/pushAll` - Push all unsynced events (protected)
- `POST /api/caldav/push/:caldavUUID` - Push single event (protected)
- `GET /health` - Health check - `GET /health` - Health check
- `POST /api/ai/test` - AI test endpoint (development only) - `POST /api/ai/test` - AI test endpoint (development only)
### Shared Package (packages/shared) ### Shared Package (packages/shared)
The shared package is compiled to `dist/` (CommonJS). All imports must use `@calchat/shared` (NOT `@calchat/shared/src/...`). Server `dev` and `build` scripts automatically build shared first.
``` ```
src/ src/
├── index.ts ├── index.ts
├── models/ ├── models/
│ ├── index.ts │ ├── index.ts
│ ├── User.ts # User, CreateUserDTO, LoginDTO, AuthResponse │ ├── User.ts # User, CreateUserDTO, LoginDTO, AuthResponse
│ ├── CalendarEvent.ts # CalendarEvent, CreateEventDTO, UpdateEventDTO, ExpandedEvent │ ├── CalendarEvent.ts # CalendarEvent, CreateEventDTO, UpdateEventDTO, ExpandedEvent, CaldavSyncStatus
│ ├── CaldavConfig.ts # CaldavConfig
│ ├── ChatMessage.ts # ChatMessage, Conversation, SendMessageDTO, CreateMessageDTO, │ ├── ChatMessage.ts # ChatMessage, Conversation, SendMessageDTO, CreateMessageDTO,
│ │ # GetMessagesOptions, ChatResponse, ConversationSummary, │ │ # GetMessagesOptions, ChatResponse, ConversationSummary,
│ │ # ProposedEventChange, EventAction, RespondedAction, UpdateMessageDTO │ │ # ProposedEventChange, EventAction, RespondedAction, UpdateMessageDTO
@@ -200,21 +366,30 @@ src/
│ # DAY_TO_GERMAN, DAY_TO_GERMAN_SHORT, MONTH_TO_GERMAN │ # DAY_TO_GERMAN, DAY_TO_GERMAN_SHORT, MONTH_TO_GERMAN
└── utils/ └── utils/
├── index.ts ├── index.ts
── dateHelpers.ts # getDay() - get date for specific weekday relative to today ── dateHelpers.ts # getDay() - get date for specific weekday relative to today
├── formatters.ts # formatDate(), formatTime(), formatDateTime(), formatDateWithWeekday() - German locale
└── rruleHelpers.ts # parseRRule(), buildRRule(), formatRecurrenceRule() - RRULE parsing, building, and German formatting
``` ```
**Key Types:** **Key Types:**
- `User`: id, email, userName, passwordHash?, createdAt?, updatedAt? - `User`: id, email, userName, passwordHash?, createdAt?, updatedAt?
- `CalendarEvent`: id, userId, title, description?, startTime, endTime, note?, isRecurring?, recurrenceRule? - `CalendarEvent`: id, userId, caldavUUID?, etag?, title, description?, startTime, endTime, note?, recurrenceRule?, exceptionDates?, caldavSyncStatus?
- `CaldavConfig`: userId, serverUrl, username, password, syncIntervalSeconds?
- `CaldavSyncStatus`: 'synced' | 'error'
- `ExpandedEvent`: Extends CalendarEvent with occurrenceStart, occurrenceEnd (for recurring event instances) - `ExpandedEvent`: Extends CalendarEvent with occurrenceStart, occurrenceEnd (for recurring event instances)
- `ChatMessage`: id, conversationId, sender ('user' | 'assistant'), content, proposedChanges? - `ChatMessage`: id, conversationId, sender ('user' | 'assistant'), content, proposedChanges?
- `ProposedEventChange`: id, action ('create' | 'update' | 'delete'), eventId?, event?, updates?, respondedAction? - `ProposedEventChange`: id, action ('create' | 'update' | 'delete'), eventId?, event?, updates?, respondedAction?, deleteMode?, occurrenceDate?, conflictingEvents?
- Each proposal has unique `id` (e.g., "proposal-0") for individual confirm/reject - Each proposal has unique `id` (e.g., "proposal-0") for individual confirm/reject
- `respondedAction` tracks user response per proposal (not per message) - `respondedAction` tracks user response per proposal (not per message)
- `deleteMode` ('single' | 'future' | 'all') and `occurrenceDate` for recurring event deletion
- `conflictingEvents` contains events that overlap with the proposed time (for conflict warnings)
- `ConflictingEvent`: title, startTime, endTime - simplified event info for conflict display
- `RecurringDeleteMode`: 'single' | 'future' | 'all' - delete modes for recurring events
- `DeleteRecurringEventDTO`: mode, occurrenceDate? - DTO for recurring event deletion
- `Conversation`: id, userId, createdAt?, updatedAt? (messages loaded separately via lazy loading) - `Conversation`: id, userId, createdAt?, updatedAt? (messages loaded separately via lazy loading)
- `CreateUserDTO`: email, userName, password (for registration) - `CreateUserDTO`: email, userName, password (for registration)
- `LoginDTO`: identifier (email OR userName), password - `LoginDTO`: identifier (email OR userName), password
- `CreateEventDTO`: Used for creating events AND for AI-proposed events - `CreateEventDTO`: Used for creating events AND for AI-proposed events, includes optional `exceptionDates` for proposals
- `GetMessagesOptions`: Cursor-based pagination with `before?: string` and `limit?: number` - `GetMessagesOptions`: Cursor-based pagination with `before?: string` and `limit?: number`
- `ConversationSummary`: id, lastMessage?, createdAt? (for conversation list) - `ConversationSummary`: id, lastMessage?, createdAt? (for conversation list)
- `UpdateMessageDTO`: proposalId?, respondedAction? (for marking individual proposals as confirmed/rejected) - `UpdateMessageDTO`: proposalId?, respondedAction? (for marking individual proposals as confirmed/rejected)
@@ -222,6 +397,69 @@ src/
- `Day`: "Monday" | "Tuesday" | ... | "Sunday" - `Day`: "Monday" | "Tuesday" | ... | "Sunday"
- `Month`: "January" | "February" | ... | "December" - `Month`: "January" | "February" | ... | "December"
### AI Context Architecture
The AI assistant fetches calendar data on-demand rather than receiving pre-loaded events. This reduces token usage significantly.
**AIContext Interface:**
```typescript
interface AIContext {
userId: string;
conversationHistory: ChatMessage[]; // Last 20 messages for context
currentDate: Date;
// Callbacks for on-demand data fetching:
fetchEventsInRange: (start: Date, end: Date) => Promise<ExpandedEvent[]>;
searchEvents: (query: string) => Promise<CalendarEvent[]>;
fetchEventById: (eventId: string) => Promise<CalendarEvent | null>;
}
```
**Available AI Tools:**
- `getDay` - Calculate relative dates (e.g., "next Friday")
- `getCurrentDateTime` - Get current timestamp
- `proposeCreateEvent` - Propose new event (includes automatic conflict detection)
- `proposeUpdateEvent` - Propose event modification
- `proposeDeleteEvent` - Propose event deletion (supports recurring delete modes)
- `searchEvents` - Search events by title (returns IDs for update/delete)
- `getEventsInRange` - Load events for a date range (for "what's today?" queries)
**Conflict Detection:**
When creating events, `toolExecutor` automatically:
1. Fetches events for the target day via `fetchEventsInRange`
2. Checks for time overlaps using `occurrenceStart/occurrenceEnd` (important for recurring events)
3. Returns `conflictingEvents` array in the proposal for UI display
4. Adds ⚠️ warning to tool result so AI can inform user
### CalDAV Synchronization
CalDAV sync with external calendar servers (e.g., Radicale) using `tsdav` and `ical.js`.
**Naming Convention:** All CalDAV-related identifiers use `Caldav` (PascalCase) / `caldav` (camelCase), NOT `CalDav`. The only exception is the protocol name "CalDAV" in comments and log messages.
**Sync Triggers (client-side via `CaldavConfigService.sync()`):**
- **Login** (`login.tsx`): After successful authentication
- **Auto-login** (`AuthGuard.tsx`): After `loadStoredUser()` if authenticated
- **Calendar timer** (`calendar.tsx`): Events load instantly from DB on focus (`loadEvents`), CalDAV sync runs in background (`syncAndReload`) and reloads events after. Repeats every 10s via `setInterval`
- **Sync button** (`settings.tsx`): Manual trigger in CaldavSettings
**Lazy sync (server-side in ChatService):**
- AI data access callbacks (`fetchEventsInRange`, `searchEvents`, `fetchEventById`) trigger `syncOnce()` before the first DB query
- Uses `CaldavService.sync()` which checks config internally (silent no-op without config)
**Single-event sync (server-side in controllers):**
- `EventController`: `pushToCaldav()` after create/update, `deleteFromCaldav()` after delete
- `ChatController`: `pushAll()` after confirming an event proposal
**Sync Flow:**
1. `sync()` calls `pushAll` (push unsynced local events) then `pull` (fetch remote events)
2. `pullEvents`: Compares etags to skip unchanged events, creates/updates local events, deletes locally if removed remotely
3. `pushEvent`: Creates or updates remote event, fetches new etag after push
**Architecture:**
- `CaldavService` depends on `CaldavRepository` (config storage) and `EventService` (event CRUD)
- `ChatService` depends on `EventService` and `CaldavService` (lazy CalDAV sync on AI data access)
- `EventController` and `ChatController` both receive `CaldavService` for CalDAV push on mutations
### Database Abstraction ### Database Abstraction
The repository pattern allows swapping databases: The repository pattern allows swapping databases:
@@ -282,7 +520,6 @@ The decorator uses a Proxy to intercept method calls lazily, preserves sync/asyn
**Log Summarization:** **Log Summarization:**
The `@Logged` decorator automatically summarizes large arguments to keep logs readable: The `@Logged` decorator automatically summarizes large arguments to keep logs readable:
- `conversationHistory``"[5 messages]"` - `conversationHistory``"[5 messages]"`
- `existingEvents``"[3 events]"`
- `proposedChanges` → logged in full (for debugging AI issues) - `proposedChanges` → logged in full (for debugging AI issues)
- Long strings (>100 chars) → truncated - Long strings (>100 chars) → truncated
- Arrays → `"[Array(n)]"` - Arrays → `"[Array(n)]"`
@@ -306,7 +543,7 @@ The `@Logged` decorator automatically summarizes large arguments to keep logs re
### Nice-to-Have ### Nice-to-Have
- iCalendar import/export - iCalendar import/export
- Multiple calendars - Multiple calendars
- CalDAV synchronization with external services - ~~CalDAV synchronization with external services~~ (implemented)
## Development Environment ## Development Environment
@@ -319,11 +556,24 @@ docker compose down # Stop services
- MongoDB: `localhost:27017` (root/mongoose) - MongoDB: `localhost:27017` (root/mongoose)
- Mongo Express UI: `localhost:8083` (admin/admin) - Mongo Express UI: `localhost:8083` (admin/admin)
### Radicale CalDAV Server (Docker)
```bash
cd apps/server/docker/radicale
docker compose up -d # Start Radicale CalDAV server
```
- Radicale: `localhost:5232`
### Server Docker Image
```bash
# Build (requires local build context):
docker build -f apps/server/docker/Dockerfile -t calchat-server .
docker run -p 3001:3001 --env-file apps/server/.env calchat-server
```
Multi-stage COPY-based build: copies `package.json` files first for layer caching, then source code. Compiles shared + server, then copies only `dist/` and production dependencies to the runtime stage. Exposes port 3001. In CI, the `plugins/docker` Drone plugin builds and pushes the image automatically.
### Environment Variables ### Environment Variables
Server requires `.env` file in `apps/server/`: Server requires `.env` file in `apps/server/`:
``` ```
JWT_SECRET=your-secret-key
JWT_EXPIRES_IN=1h
MONGODB_URI=mongodb://root:mongoose@localhost:27017/calchat?authSource=admin MONGODB_URI=mongodb://root:mongoose@localhost:27017/calchat?authSource=admin
OPENAI_API_KEY=sk-proj-... OPENAI_API_KEY=sk-proj-...
USE_TEST_RESPONSES=false # true = static test responses, false = real GPT AI USE_TEST_RESPONSES=false # true = static test responses, false = real GPT AI
@@ -344,40 +594,59 @@ NODE_ENV=development # development = pretty logs, production = JSON
- `dotenv` integration for environment variables - `dotenv` integration for environment variables
- `ChatController`: sendMessage(), confirmEvent(), rejectEvent() - `ChatController`: sendMessage(), confirmEvent(), rejectEvent()
- `ChatService`: processMessage() with test responses (create, update, delete actions), confirmEvent() handles all CRUD actions - `ChatService`: processMessage() with test responses (create, update, delete actions), confirmEvent() handles all CRUD actions
- `MongoEventRepository`: Full CRUD implemented (findById, findByUserId, findByDateRange, create, update, delete) - `MongoEventRepository`: Full CRUD implemented (findById, findByUserId, findByDateRange, create, update, delete, addExceptionDate)
- `EventController`: Full CRUD (create, getById, getAll, getByDateRange, update, delete) - `EventController`: Full CRUD (create, getById, getAll, getByDateRange, update, delete)
- `EventService`: Full CRUD with recurring event expansion via recurrenceExpander - `EventService`: Full CRUD with recurring event expansion via recurrenceExpander, deleteRecurring() with three modes (single/future/all)
- `utils/eventFormatters`: getWeeksOverview(), getMonthOverview() with German localization - `utils/eventFormatters`: getWeeksOverview(), getMonthOverview() with German localization
- `utils/recurrenceExpander`: expandRecurringEvents() using rrule library for RRULE parsing - `utils/recurrenceExpander`: expandRecurringEvents() using rrule library for RRULE parsing
- `ChatController`: getConversations(), getConversation() with cursor-based pagination support - `ChatController`: getConversations(), getConversation() with cursor-based pagination support
- `ChatService`: getConversations(), getConversation(), processMessage() uses real AI or test responses (via USE_TEST_RESPONSES), confirmEvent()/rejectEvent() update respondedAction and persist response messages - `ChatService`: getConversations(), getConversation(), processMessage() uses real AI or test responses (via USE_TEST_RESPONSES), confirmEvent()/rejectEvent() update respondedAction and persist response messages
- `MongoChatRepository`: Full CRUD implemented (getConversationsByUser, createConversation, getMessages with cursor pagination, createMessage, updateMessage, updateProposalResponse) - `MongoChatRepository`: Full CRUD implemented (getConversationsByUser, createConversation, getMessages with cursor pagination, createMessage, updateMessage, updateProposalResponse, updateProposalEvent)
- `ChatRepository` interface: updateMessage() and updateProposalResponse() for per-proposal respondedAction tracking - `ChatRepository` interface: updateMessage(), updateProposalResponse(), updateProposalEvent() for per-proposal tracking
- `GPTAdapter`: Full implementation with OpenAI GPT (gpt-4o-mini model), function calling for calendar operations, collects multiple proposals per response - `GPTAdapter`: Full implementation with OpenAI GPT (gpt-4o-mini model), function calling for calendar operations, collects multiple proposals per response
- `ai/utils/`: Provider-agnostic shared utilities (systemPrompt, toolDefinitions, toolExecutor, eventFormatter) - `ai/utils/`: Provider-agnostic shared utilities (systemPrompt, toolDefinitions, toolExecutor)
- `ai/utils/systemPrompt`: Includes RRULE documentation - AI knows to create separate events when times differ by day - `ai/utils/systemPrompt`: AI fetches events on-demand (no pre-loaded context), includes RRULE documentation, warns AI not to put RRULE in description field, instructs AI not to show event IDs to users
- `utils/recurrenceExpander`: Handles RRULE parsing, strips `RRULE:` prefix if present (AI may include it) - `ai/utils/toolDefinitions`: proposeUpdateEvent supports `recurrenceRule` parameter, getEventsInRange tool for on-demand event loading
- `ai/utils/toolExecutor`: Async execution, conflict detection uses `occurrenceStart/occurrenceEnd` for recurring events, returns `conflictingEvents` in proposals
- `MongoEventRepository`: Includes `searchByTitle()` for case-insensitive title search
- `utils/recurrenceExpander`: Handles RRULE parsing, strips `RRULE:` prefix if present (AI may include it), filters out exceptionDates
- `logging/`: Structured logging with pino, pino-http middleware, @Logged decorator - `logging/`: Structured logging with pino, pino-http middleware, @Logged decorator
- All repositories and GPTAdapter decorated with @Logged for automatic method logging - All repositories and GPTAdapter decorated with @Logged for automatic method logging
- `CaldavService`: Full CalDAV sync (connect, pullEvents, pushEvent, pushAll, deleteEvent, sync, getConfig, saveConfig, deleteConfig). `sync()` checks config internally and is a silent no-op without config.
- `CaldavController`: REST endpoints for config CRUD, pull, push
- `MongoCaldavRepository`: Config persistence with createOrUpdate, findByUserId, deleteByUserId
- `EventController`: CalDAV push on create/update, CalDAV delete on delete (via pushToCaldav/deleteFromCaldav helpers)
- `ChatController`: CalDAV pushAll after confirmEvent (ensures chat-created events sync)
- `ChatService`: Uses EventService + CaldavService (lazy sync on AI data access via syncOnce pattern)
- `EventService`: Extended with searchByTitle(), findByCaldavUUID()
- `utils/eventFormatters`: Refactored to use EventService instead of EventRepository
- CORS configured to allow X-User-Id header - CORS configured to allow X-User-Id header
- **Stubbed (TODO):**
- `AuthController`: refresh(), logout()
- `AuthService`: refreshToken()
- JWT authentication (currently using simple X-User-Id header)
**Shared:** Types, DTOs, constants (Day, Month with German translations), ExpandedEvent type, and date utilities defined and exported. **Shared:**
- Types, DTOs, constants (Day, Month with German translations), ExpandedEvent type, CaldavConfig, CaldavSyncStatus defined and exported
- `rruleHelpers.ts`: `parseRRule()` parses RRULE strings using rrule library, returns `ParsedRRule` with freq, until, count, interval, byDay. `buildRRule()` builds RRULE from RepeatType + interval. `formatRecurrenceRule()` formats RRULE into German description (e.g., "Jede Woche", "Alle 2 Monate"). Exports `REPEAT_TYPE_LABELS` and `RepeatType`.
- `formatters.ts`: German date/time formatters (`formatDate`, `formatTime`, `formatDateTime`, `formatDateWithWeekday`, `formatDateKey`) used by both client and server
- rrule library added as dependency for RRULE parsing
**Frontend:** **Frontend:**
- **Authentication fully implemented:** - **Authentication fully implemented:**
- `AuthStore`: Manages user state with expo-secure-store (native) / localStorage (web) - `AuthStore`: Manages user state with expo-secure-store (native) / localStorage (web)
- `AuthService`: login(), register(), logout() - calls backend API - `AuthService`: login(), register(), logout() - calls backend API
- `ApiClient`: Automatically injects X-User-Id header for authenticated requests - `ApiClient`: Automatically injects X-User-Id header for authenticated requests, handles empty responses (204)
- Login screen: Supports email OR userName login - `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`)
- Register screen: Email validation, checks for existing email/userName - Login screen: Supports email OR userName login, uses CustomTextInput with focus border, preloads app data + triggers CalDAV sync after successful login
- `AuthButton`: Reusable button component with shadow effect - Register screen: Email validation, checks for existing email/userName, uses CustomTextInput with focus border
- `Header`: Contains logout button on all screens - `AuthButton`: Reusable button component with themed shadow
- `index.tsx`: Auth redirect - checks stored user on app start - `Header`: Themed header component (logout moved to Settings)
- Tab navigation (Chat, Calendar) implemented with basic UI - `(tabs)/_layout.tsx`: Wraps tabs with AuthGuard for protected access
- `index.tsx`: Simple redirect to chat (AuthGuard handles auth)
- **Theme system fully implemented:**
- `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) 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: - Calendar screen fully functional:
- Month navigation with grid display and Ionicons (chevron-back/forward) - Month navigation with grid display and Ionicons (chevron-back/forward)
- MonthSelector dropdown with infinite scroll (dynamically loads months, lazy-loaded when modal opens, cleared on close for memory efficiency) - MonthSelector dropdown with infinite scroll (dynamically loads months, lazy-loaded when modal opens, cleared on close for memory efficiency)
@@ -385,26 +654,52 @@ NODE_ENV=development # development = pretty logs, production = JSON
- Orange dot indicator for days with events - Orange dot indicator for days with events
- Tap-to-open modal overlay showing EventCards for selected day - Tap-to-open modal overlay showing EventCards for selected day
- Supports events from adjacent months visible in grid - Supports events from adjacent months visible in grid
- Uses `useFocusEffect` for automatic reload on tab focus - Events load instantly from local DB on tab focus, CalDAV sync runs non-blocking in background (`syncAndReload`) with 10s interval
- DeleteEventModal integration for recurring event deletion with three modes
- EventOverlay hides when DeleteEventModal is open (fixes modal stacking on web)
- Chat screen fully functional with FlashList, message sending, and event confirm/reject - Chat screen fully functional with FlashList, message sending, and event confirm/reject
- **Multiple event proposals**: AI can propose multiple events in one response - **Multiple event proposals**: AI can propose multiple events in one response
- Arrow navigation between proposals with "Event X von Y" counter - Arrow navigation between proposals with "Event X von Y" counter
- Each proposal individually confirmable/rejectable - Each proposal individually confirmable/rejectable
- Messages persisted to database via ChatService and loaded on mount - **Typing indicator**: Animated dots (. .. ...) shown after 500ms delay while waiting for AI response
- Messages persisted to database via ChatService, loaded via `useFocusEffect` when screen gains focus
- Tracks conversationId for message continuity across sessions - Tracks conversationId for message continuity across sessions
- ChatStore with addMessages() for bulk loading, chatMessageToMessageData() helper - ChatStore with addMessages() for bulk loading, chatMessageToMessageData() helper
- KeyboardAvoidingView for proper keyboard handling (iOS padding, Android height) - KeyboardAvoidingView for proper keyboard handling (iOS padding, Android height)
- Auto-scroll to end on new messages and keyboard show - Auto-scroll to end on new messages and keyboard show; initial load uses `onContentSizeChange` with `animated: false` to start at bottom without visible scrolling
- keyboardDismissMode="interactive" and keyboardShouldPersistTaps="handled" - keyboardDismissMode="interactive" and keyboardShouldPersistTaps="handled"
- `EventService`: getAll(), getById(), getByDateRange(), create(), update(), delete() - fully implemented - `EventService`: getAll(), getById(), getByDateRange(), create(), update(), delete(mode, occurrenceDate) - fully implemented with recurring delete modes
- `ChatService`: sendMessage(), confirmEvent(), rejectEvent(), getConversations(), getConversation() - fully implemented with cursor pagination - `ChatService`: sendMessage(), confirmEvent(deleteMode, occurrenceDate), rejectEvent(), getConversations(), getConversation(), updateProposalEvent() - fully implemented with cursor pagination, recurring delete support, and proposal editing
- `EventCardBase`: Shared base component with event layout (header, date/time/recurring icons, description) - used by both EventCard and ProposedEventCard - `CaldavConfigService`: saveConfig(), getConfig(), deleteConfig(), pull(), pushAll(), sync() - CalDAV config management and sync trigger
- `EventCard`: Uses EventCardBase + edit/delete buttons for calendar display - `CustomTextInput`: Themed text input with focus border highlight. Props: `text`, `onValueChange`, `placeholder`, `placeholderTextColor`, `secureTextEntry`, `autoCapitalize`, `keyboardType`, `className`, `multiline`. No default padding — callers must set padding via `className` (e.g., `px-3 py-2` or `p-4`). When not focused, cursor is reset to start (`selection={{ start: 0 }}`) to avoid text appearing scrolled to the end.
- `ProposedEventCard`: Uses EventCardBase + confirm/reject buttons for chat proposals (supports create/update/delete actions) - `CardBase`: Reusable card component with header (title/subtitle), content area, and optional footer button - configurable padding, border, text size via props, ScrollView uses `nestedScrollEnabled` for Android
- `Themes.tsx`: Centralized color definitions including textPrimary, borderPrimary, eventIndicator, secondaryBg - `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
- `EventsStore`: Zustand store with setEvents(), addEvent(), updateEvent(), deleteEvent() - stores ExpandedEvent[] - `EventCardBase`: Event card with date/time/recurring icons - uses CardBase for structure. Accepts `recurrenceRule` string (not boolean) and displays German-formatted recurrence via `formatRecurrenceRule()`
- `ChatStore`: Zustand store with addMessage(), addMessages(), updateMessage(), clearMessages() - loads from server on mount and persists across tab switches - `EventCard`: Uses EventCardBase + edit/delete buttons (TouchableOpacity with delayPressIn for scroll-friendly touch handling)
- `ProposedEventCard`: Uses EventCardBase + confirm/reject/edit buttons for chat proposals, displays green highlighted text for new changes ("Neue Ausnahme: [date]" for single delete, "Neues Ende: [date]" for UNTIL updates), shows yellow conflict warnings when proposed time overlaps with existing events. Edit button allows modifying proposals before confirming.
- `DeleteEventModal`: Delete confirmation modal using ModalBase - shows three options for recurring events (single/future/all), simple confirm for non-recurring
- `CalendarToolbar` (in calendar.tsx): Toolbar between header and weekdays with Sync button (CalDAV sync with spinner/green checkmark/red X feedback, disabled without config) and Logout button
- `EventOverlay` (in calendar.tsx): Day events overlay using ModalBase - shows EventCards for selected day
- `Themes.tsx`: Theme definitions with THEMES object (defaultLight, defaultDark) including all color tokens (textPrimary, borderPrimary, eventIndicator, secondaryBg, shadowColor, etc.)
- `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
- `TypingIndicator`: Animated typing indicator component showing `. → .. → ...` loop while waiting for AI response
- Event Detail and Note screens exist as skeletons - Event Detail and Note screens exist as skeletons
- `editEvent.tsx`: Dual-mode event editor screen
- **Calendar mode**: Edit existing events, create new events - calls EventService API
- **Chat mode**: Edit AI-proposed events before confirming - updates ChatStore locally and persists to server via ChatService.updateProposalEvent()
- Route params: `mode` ('calendar' | 'chat'), `id?`, `date?`, `eventData?` (JSON), `proposalContext?` (JSON with messageId, proposalId, conversationId)
- Supports recurring events with RRULE configuration (daily/weekly/monthly/yearly)
- **E2E testing infrastructure:**
- WebdriverIO + Appium with UiAutomator2 driver for Android
- testID props added to key components (`AuthButton`, `BaseButton`, `ChatBubble`, `CustomTextInput`, `ProposedEventCard`)
- testIDs applied to login screen, chat screen, tab bar, settings logout button, and event proposal buttons
- `app.json`: `usesCleartextTraffic: true` for Android (allows HTTP connections needed by Appium)
- Two execution modes: Dev Client (local) and APK (CI)
- Tests: app launch detection, login flow validation (empty fields, invalid creds, success)
## Building ## Building
@@ -417,8 +712,9 @@ This uses the `preview` profile from `eas.json` which builds an APK with:
- `arm64-v8a` architecture only (smaller APK size) - `arm64-v8a` architecture only (smaller APK size)
- No credentials required (`withoutCredentials: true`) - No credentials required (`withoutCredentials: true`)
- Internal distribution - Internal distribution
- Non-interactive mode with fixed output path (`calchat.apk`) for CI compatibility
**Requirements:** Android SDK and Java must be installed locally. **Requirements:** Android SDK and Java must be installed locally. In CI, the `eas-build` Docker image (`gitea.gilmour109.de/gilmour109/eas-build:latest`) provides the build environment with `EXPO_TOKEN` for authentication.
**EAS Configuration:** `apps/client/eas.json` contains build profiles: **EAS Configuration:** `apps/client/eas.json` contains build profiles:
- `development`: Development client with internal distribution - `development`: Development client with internal distribution
@@ -429,6 +725,81 @@ This uses the `preview` profile from `eas.json` which builds an APK with:
- Package name: `com.gilmour109.calchat` - Package name: `com.gilmour109.calchat`
- EAS Project ID: `b722dde6-7d89-48ff-9095-e007e7c7da87` - EAS Project ID: `b722dde6-7d89-48ff-9095-e007e7c7da87`
## CI/CD (Drone)
The project uses Drone CI (`.drone.yml`). Note: `server_build_and_test` and `check_for_formatting` pipelines are currently commented out.
**On push to main:**
1. **`deploy_latest`**: Runs E2E testing pipeline with ephemeral infrastructure:
- **`deploy_test_backend`**: Deploys server to k3s cluster (`192.168.178.201`) using `kubernetes/manifest.yml` with commit-based naming (`e2e<sha8>`)
- **`create_e2e_vm`**: Provisions ephemeral Android emulator VM on Proxmox via OpenTofu (`tofu/e2e/`)
- **`run_e2e_tests`**: SSHs into VM, runs `scripts/e2e-test.sh` (clones repo, starts emulator + Expo + Appium, executes E2E tests). VM IP derived from build number: `192.168.178.$((211 + BUILD_NUMBER % 44))`
- **`notify_failure`**: Sends email notification on E2E failure
- **`destroy_e2e_vm`**: Tears down VM via `tofu destroy` (runs on success and failure)
- **`cleanup_k3s`**: Deletes test backend resources from k3s (runs on success and failure)
- Docker image build/push and APK build/upload are currently commented out
- Uses `e2e-tools` Docker image (`gitea.gilmour109.de/gilmour109/e2e-tools:latest`)
**On tag** (`upload_tag`) and **on promote** (`upload_commit`): Currently commented out. Previously deployed to k3s and built APK releases.
### E2E CI Infrastructure
```
scripts/
└── e2e-test.sh # E2E test runner script (emulator + Expo + Appium)
tofu/e2e/ # OpenTofu config for ephemeral Proxmox VMs
kubernetes/manifest.yml # k3s manifest template for test backend (uses envsubst: $NAME, $TAG, $COMMIT)
```
**`scripts/e2e-test.sh`**: Orchestrates E2E test execution inside an ephemeral VM. Supports two modes:
- **CI mode**: Clones repo, installs deps, starts Android emulator, Expo, Appium, runs tests
- **Local mode** (`--local`): Uses existing repo checkout, optional `--api-url` override
## Testing
### Server Unit Tests
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)
### E2E Tests (Client)
WebdriverIO + Appium for Android E2E testing. Tests run sequentially (`--runInBand`) sharing a singleton Appium driver.
**Two execution modes:**
- **Dev Client mode** (local): Connects to running Expo app (`host.exp.exponent`), `noReset: true`
- **APK mode** (CI): Installs APK via `APK_PATH` env var, `noReset: false`
**Running locally:**
```bash
# Terminal 1: Start Appium server
appium
# Terminal 2: Start Expo dev server on Android emulator
npm run android -w @calchat/client
# Terminal 3: Run E2E tests
npm run test:e2e -w @calchat/client
```
**Environment variables** (`apps/client/e2e/.env`):
```
TEST_USER=test # Login credentials for tests
TEST_PASSWORD=test
DEVICE_NAME=emulator-5554 # Android device/emulator
APPIUM_HOST=localhost
APPIUM_PORT=4723
```
**Element selection:** Uses Android UiAutomator2 with `resource-id` selectors (React Native maps `testID``resource-id` on Android).
**testID conventions:** Components with testID support: `AuthButton`, `BaseButton`, `ChatBubble`, `CustomTextInput`, `ProposedEventCard`. Key testIDs: `login-title`, `login-identifier-input`, `login-password-input`, `login-button`, `login-error-text`, `tab-chat`, `tab-calendar`, `tab-settings`, `chat-message-input`, `chat-send-button`, `chat-bubble-left`, `chat-bubble-right`, `settings-logout-button`, `event-accept-button`, `event-reject-button`.
**Existing E2E tests:**
- `01-app-launch.test.ts` - App startup, detects login or auto-logged-in state
- `02-login.test.ts` - Empty field validation, invalid credentials error, successful login
## Documentation ## Documentation
Detailed architecture diagrams are in `docs/`: Detailed architecture diagrams are in `docs/`:

145
README.md
View File

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

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

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

View File

@@ -2,7 +2,7 @@
"expo": { "expo": {
"jsEngine": "hermes", "jsEngine": "hermes",
"name": "CalChat", "name": "CalChat",
"slug": "calchat", "slug": "caldav",
"version": "1.0.0", "version": "1.0.0",
"orientation": "portrait", "orientation": "portrait",
"scheme": "calchat", "scheme": "calchat",
@@ -14,7 +14,8 @@
"android": { "android": {
"package": "com.gilmour109.calchat", "package": "com.gilmour109.calchat",
"edgeToEdgeEnabled": true, "edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false "predictiveBackGestureEnabled": false,
"usesCleartextTraffic": true
}, },
"web": { "web": {
"output": "static", "output": "static",
@@ -32,6 +33,7 @@
"eas": { "eas": {
"projectId": "b722dde6-7d89-48ff-9095-e007e7c7da87" "projectId": "b722dde6-7d89-48ff-9095-e007e7c7da87"
} }
} },
"owner": "gilmour109"
} }
} }

BIN
apps/client/calchat.apk Normal file

Binary file not shown.

View File

@@ -0,0 +1,49 @@
export type AppiumCapabilities = Record<string, unknown>;
const COMMON_CAPABILITIES: AppiumCapabilities = {
platformName: "Android",
"appium:automationName": "UiAutomator2",
"appium:deviceName": process.env.DEVICE_NAME || "emulator-5554",
"appium:autoGrantPermissions": true,
"appium:newCommandTimeout": 300,
};
/**
* Dev Client mode: app is already installed via `expo start --android`.
* Appium connects to the running app without reinstalling.
*/
const DEV_CLIENT_CAPABILITIES: AppiumCapabilities = {
...COMMON_CAPABILITIES,
"appium:appPackage": "host.exp.exponent",
"appium:appActivity": ".experience.ExperienceActivity",
"appium:noReset": true,
};
/**
* APK mode: Appium installs the APK directly.
* Used in CI where the APK is built via `eas build --profile e2e`.
*/
function getApkCapabilities(apkPath: string): AppiumCapabilities {
return {
...COMMON_CAPABILITIES,
"appium:app": apkPath,
"appium:noReset": false,
};
}
/**
* Returns the appropriate capabilities based on the APK_PATH env variable.
* - APK_PATH set → APK mode (CI)
* - APK_PATH not set → Dev Client mode (local development)
*/
export function getCapabilities(): AppiumCapabilities {
const apkPath = process.env.APK_PATH;
if (apkPath) {
console.log(`[Appium] APK mode: ${apkPath}`);
return getApkCapabilities(apkPath);
}
console.log("[Appium] Dev Client mode");
return DEV_CLIENT_CAPABILITIES;
}

View File

@@ -0,0 +1,41 @@
import { remote, type Browser } from "webdriverio";
import { getCapabilities } from "../config/capabilities";
let driver: Browser | null = null;
const APPIUM_HOST = process.env.APPIUM_HOST || "localhost";
const APPIUM_PORT = parseInt(process.env.APPIUM_PORT || "4723", 10);
/**
* Initialize the Appium driver. If already initialized, returns the existing instance.
* With --runInBand, all test files share this singleton.
*/
export async function initDriver(): Promise<Browser> {
if (driver) return driver;
const capabilities = getCapabilities();
driver = await remote({
hostname: APPIUM_HOST,
port: APPIUM_PORT,
path: "/",
capabilities,
logLevel: "warn",
});
return driver;
}
export function getDriver(): Browser {
if (!driver) {
throw new Error("Driver not initialized. Call initDriver() first.");
}
return driver;
}
export async function quitDriver(): Promise<void> {
if (driver) {
await driver.deleteSession();
driver = null;
}
}

View File

@@ -0,0 +1,30 @@
/**
* All testID strings used in the CalChat app.
* Appium finds them via accessibility id selector: `~testId`
*/
export const TestIDs = {
// Login screen
LOGIN_TITLE: "login-title",
LOGIN_ERROR_TEXT: "login-error-text",
LOGIN_IDENTIFIER_INPUT: "login-identifier-input",
LOGIN_PASSWORD_INPUT: "login-password-input",
LOGIN_BUTTON: "login-button",
// Tab navigation
TAB_CHAT: "tab-chat",
TAB_CALENDAR: "tab-calendar",
TAB_SETTINGS: "tab-settings",
// Chat screen
CHAT_MESSAGE_INPUT: "chat-message-input",
CHAT_SEND_BUTTON: "chat-send-button",
CHAT_BUBBLE_LEFT: "chat-bubble-left",
CHAT_BUBBLE_RIGHT: "chat-bubble-right",
// Settings screen
SETTINGS_LOGOUT_BUTTON: "settings-logout-button",
// Event proposal
EVENT_ACCEPT_BUTTON: "event-accept-button",
EVENT_REJECT_BUTTON: "event-reject-button",
} as const;

View File

@@ -0,0 +1,105 @@
import type { Browser } from "webdriverio";
import { TestIDs } from "./selectors";
const DEFAULT_TIMEOUT = 15_000;
/**
* Build a UiAutomator selector for a resource-id.
* React Native on Android maps testID to resource-id (not content-desc).
*/
function byTestId(testId: string): string {
return `android=new UiSelector().resourceId("${testId}")`;
}
/**
* Wait for an element with the given testID to exist and be displayed.
*/
export async function waitForTestId(
driver: Browser,
testId: string,
timeout = DEFAULT_TIMEOUT,
) {
const element = driver.$(byTestId(testId));
await element.waitForDisplayed({ timeout });
return element;
}
/**
* Check if an element with the given testID is currently visible.
*/
export async function isTestIdVisible(
driver: Browser,
testId: string,
): Promise<boolean> {
try {
const element = driver.$(byTestId(testId));
return await element.isDisplayed();
} catch {
return false;
}
}
/**
* Navigate to the login screen. If already logged in (auto-login),
* go to Settings and tap Logout first.
*/
export async function ensureOnLoginScreen(driver: Browser): Promise<void> {
const onLogin = await isTestIdVisible(driver, TestIDs.LOGIN_TITLE);
if (onLogin) return;
// We're on the main app — navigate to Settings and logout
const settingsTab = driver.$(byTestId(TestIDs.TAB_SETTINGS));
await settingsTab.waitForDisplayed({ timeout: DEFAULT_TIMEOUT });
await settingsTab.click();
const logoutButton = await waitForTestId(
driver,
TestIDs.SETTINGS_LOGOUT_BUTTON,
);
await logoutButton.click();
// Wait for login screen to appear
await waitForTestId(driver, TestIDs.LOGIN_TITLE);
}
/**
* Perform login with the given credentials.
*/
export async function performLogin(
driver: Browser,
identifier: string,
password: string,
): Promise<void> {
const identifierInput = await waitForTestId(
driver,
TestIDs.LOGIN_IDENTIFIER_INPUT,
);
await identifierInput.clearValue();
await identifierInput.setValue(identifier);
const passwordInput = await waitForTestId(
driver,
TestIDs.LOGIN_PASSWORD_INPUT,
);
await passwordInput.clearValue();
await passwordInput.setValue(password);
const loginButton = await waitForTestId(driver, TestIDs.LOGIN_BUTTON);
await loginButton.click();
}
/**
* Ensure the user is logged in. If on the login screen, perform login
* with test credentials.
*/
export async function ensureLoggedIn(driver: Browser): Promise<void> {
const testUser = process.env.TEST_USER || "test";
const testPassword = process.env.TEST_PASSWORD || "test";
const onLogin = await isTestIdVisible(driver, TestIDs.LOGIN_TITLE);
if (onLogin) {
await performLogin(driver, testUser, testPassword);
// Wait for chat screen to appear after login
await waitForTestId(driver, TestIDs.CHAT_MESSAGE_INPUT, 30_000);
}
}

View File

@@ -0,0 +1,14 @@
import type { Config } from "jest";
const config: Config = {
preset: "ts-jest",
testEnvironment: "node",
testMatch: ["<rootDir>/tests/**/*.test.ts"],
testTimeout: 120_000,
transform: {
"^.+\\.ts$": "ts-jest",
},
moduleFileExtensions: ["ts", "js", "json"],
};
export default config;

View File

@@ -0,0 +1,49 @@
import { initDriver, getDriver } from "../helpers/driver";
import { TestIDs } from "../helpers/selectors";
import { waitForTestId } from "../helpers/utils";
describe("App Launch", () => {
beforeAll(async () => {
await initDriver();
const driver = getDriver();
// Dismiss Expo Go banner by tapping near the top of the screen
await driver.pause(3000);
const { width } = await driver.getWindowSize();
await driver.touchAction({ action: "tap", x: Math.round(width / 2), y: 100 });
await driver.pause(500);
});
it("should launch the app and show login or chat screen", async () => {
const driver = getDriver();
// Wait for either the login screen or the chat screen to appear
// Try login first with a long timeout (app needs to fully load)
try {
await waitForTestId(driver, TestIDs.LOGIN_TITLE, 30_000);
// On login screen — verify elements
const identifierInput = await waitForTestId(
driver,
TestIDs.LOGIN_IDENTIFIER_INPUT,
);
expect(await identifierInput.isDisplayed()).toBe(true);
const passwordInput = await waitForTestId(
driver,
TestIDs.LOGIN_PASSWORD_INPUT,
);
expect(await passwordInput.isDisplayed()).toBe(true);
const loginButton = await waitForTestId(driver, TestIDs.LOGIN_BUTTON);
expect(await loginButton.isDisplayed()).toBe(true);
} catch {
// Not on login — should be on chat screen (auto-login)
const chatInput = await waitForTestId(
driver,
TestIDs.CHAT_MESSAGE_INPUT,
30_000,
);
expect(await chatInput.isDisplayed()).toBe(true);
}
});
});

View File

@@ -0,0 +1,73 @@
import { initDriver, getDriver, quitDriver } from "../helpers/driver";
import { TestIDs } from "../helpers/selectors";
import {
waitForTestId,
ensureOnLoginScreen,
performLogin,
} from "../helpers/utils";
describe("Login", () => {
beforeAll(async () => {
await initDriver();
const driver = getDriver();
// Dismiss Expo Go banner by tapping near the top of the screen
await driver.pause(3000);
const { width } = await driver.getWindowSize();
await driver.touchAction({ action: "tap", x: Math.round(width / 2), y: 100 });
await driver.pause(500);
});
beforeEach(async () => {
const driver = getDriver();
await ensureOnLoginScreen(driver);
});
it("should show error when fields are empty", async () => {
const driver = getDriver();
// Tap login without entering credentials
const loginButton = await waitForTestId(driver, TestIDs.LOGIN_BUTTON);
await loginButton.click();
// Error message should appear
const errorText = await waitForTestId(driver, TestIDs.LOGIN_ERROR_TEXT);
const text = await errorText.getText();
expect(text).toContain("Bitte alle Felder");
});
it("should show error for invalid credentials", async () => {
const driver = getDriver();
await performLogin(driver, "invalid_user", "wrong_password");
// Wait for error message
const errorText = await waitForTestId(
driver,
TestIDs.LOGIN_ERROR_TEXT,
30_000,
);
const text = await errorText.getText();
expect(text).toContain("Anmeldung fehlgeschlagen");
});
it("should login successfully with valid credentials", async () => {
const driver = getDriver();
const testUser = process.env.TEST_USER || "test";
const testPassword = process.env.TEST_PASSWORD || "test";
await performLogin(driver, testUser, testPassword);
// Chat screen should appear after successful login
const chatInput = await waitForTestId(
driver,
TestIDs.CHAT_MESSAGE_INPUT,
30_000,
);
expect(await chatInput.isDisplayed()).toBe(true);
});
afterAll(async () => {
await quitDriver();
});
});

View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"outDir": "dist",
"rootDir": "."
},
"include": ["**/*.ts"],
"exclude": ["dist"]
}

View File

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

View File

@@ -9,11 +9,13 @@
"ios": "expo start --ios", "ios": "expo start --ios",
"web": "expo start --web", "web": "expo start --web",
"lint": "expo lint", "lint": "expo lint",
"build:apk": "eas build --platform android --profile preview --local" "build:apk": "eas build --platform android --profile preview --local --non-interactive --output ./calchat.apk",
"test:e2e": "NODE_OPTIONS=--experimental-vm-modules jest --config e2e/jest.config.ts --runInBand"
}, },
"dependencies": { "dependencies": {
"@calchat/shared": "*", "@calchat/shared": "*",
"@expo/vector-icons": "^15.0.3", "@expo/vector-icons": "^15.0.3",
"@react-native-community/datetimepicker": "8.4.4",
"@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3", "@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8", "@react-navigation/native": "^7.1.8",
@@ -43,13 +45,21 @@
"react-native-screens": "~4.16.0", "react-native-screens": "~4.16.0",
"react-native-web": "~0.21.0", "react-native-web": "~0.21.0",
"react-native-worklets": "0.5.1", "react-native-worklets": "0.5.1",
"rrule": "^2.8.1",
"zustand": "^5.0.9" "zustand": "^5.0.9"
}, },
"devDependencies": { "devDependencies": {
"@types/jest": "^29.5.14",
"@types/react": "~19.1.0", "@types/react": "~19.1.0",
"appium": "^2.17.1",
"appium-uiautomator2-driver": "^3.8.0",
"eslint-config-expo": "~10.0.0", "eslint-config-expo": "~10.0.0",
"jest": "^29.7.0",
"prettier-plugin-tailwindcss": "^0.5.11", "prettier-plugin-tailwindcss": "^0.5.11",
"tailwindcss": "^3.4.17" "tailwindcss": "^3.4.17",
"ts-jest": "^29.2.5",
"ts-node": "^10.9.2",
"webdriverio": "^9.14.2"
}, },
"private": true "private": true
} }

View File

@@ -1,4 +1,4 @@
type Theme = { export type Theme = {
chatBot: string; chatBot: string;
primeFg: string; primeFg: string;
primeBg: string; primeBg: string;
@@ -15,10 +15,13 @@ type Theme = {
textMuted: string; textMuted: string;
eventIndicator: string; eventIndicator: string;
borderPrimary: string; borderPrimary: string;
shadowColor: string;
}; };
const defaultLight: Theme = { export const THEMES = {
defaultLight: {
chatBot: "#DE6C20", chatBot: "#DE6C20",
// chatBot: "#324121",
primeFg: "#3B3329", primeFg: "#3B3329",
primeBg: "#FFEEDE", primeBg: "#FFEEDE",
secondaryBg: "#FFFFFF", secondaryBg: "#FFFFFF",
@@ -34,7 +37,25 @@ const defaultLight: Theme = {
textMuted: "#888", textMuted: "#888",
eventIndicator: "#DE6C20", eventIndicator: "#DE6C20",
borderPrimary: "#000000", borderPrimary: "#000000",
}; shadowColor: "#000000",
},
let currentTheme: Theme = defaultLight; defaultDark: {
export default currentTheme; chatBot: "#DE6C20",
primeFg: "#F5E6D3",
primeBg: "#1A1512",
secondaryBg: "#2A2420",
messageBorderBg: "#3A3430",
placeholderBg: "#4A4440",
calenderBg: "#3D2A1A",
confirmButton: "#136e34",
rejectButton: "#bd1010",
disabledButton: "#555",
buttonText: "#FFFFFF",
textPrimary: "#FFFFFF",
textSecondary: "#AAA",
textMuted: "#777",
eventIndicator: "#DE6C20",
borderPrimary: "#FFFFFF",
shadowColor: "#FFFFFF",
},
} as const satisfies Record<string, Theme>;

View File

@@ -1,9 +1,12 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { Tabs } from "expo-router"; import { Tabs } from "expo-router";
import theme from "../../Themes"; import { useThemeStore } from "../../stores/ThemeStore";
import { AuthGuard } from "../../components/AuthGuard";
export default function TabLayout() { export default function TabLayout() {
const { theme } = useThemeStore();
return ( return (
<AuthGuard>
<Tabs <Tabs
screenOptions={{ screenOptions={{
headerShown: false, headerShown: false,
@@ -16,6 +19,7 @@ export default function TabLayout() {
name="chat" name="chat"
options={{ options={{
title: "Chat", title: "Chat",
tabBarTestID: "tab-chat",
tabBarIcon: ({ color }) => ( tabBarIcon: ({ color }) => (
<Ionicons size={28} name="chatbubble" color={color} /> <Ionicons size={28} name="chatbubble" color={color} />
), ),
@@ -25,11 +29,23 @@ export default function TabLayout() {
name="calendar" name="calendar"
options={{ options={{
title: "Calendar", title: "Calendar",
tabBarTestID: "tab-calendar",
tabBarIcon: ({ color }) => ( tabBarIcon: ({ color }) => (
<Ionicons size={28} name="calendar" color={color} /> <Ionicons size={28} name="calendar" color={color} />
), ),
}} }}
/> />
<Tabs.Screen
name="settings"
options={{
title: "Settings",
tabBarTestID: "tab-settings",
tabBarIcon: ({ color }) => (
<Ionicons size={28} name="settings" color={color} />
),
}}
/>
</Tabs> </Tabs>
</AuthGuard>
); );
} }

View File

@@ -1,29 +1,26 @@
import { ActivityIndicator, Pressable, Text, View } from "react-native";
import { import {
Animated, DAYS,
Modal, MONTHS,
Pressable, Month,
Text, ExpandedEvent,
View, RecurringDeleteMode,
ScrollView, } from "@calchat/shared";
Alert,
} from "react-native";
import { DAYS, MONTHS, Month, ExpandedEvent } from "@calchat/shared";
import Header from "../../components/Header"; import Header from "../../components/Header";
import { EventCard } from "../../components/EventCard"; import { EventCard } from "../../components/EventCard";
import React, { import { DeleteEventModal } from "../../components/DeleteEventModal";
useCallback, import { ModalBase } from "../../components/ModalBase";
useEffect, import { ScrollableDropdown } from "../../components/ScrollableDropdown";
useMemo, import React, { useCallback, useEffect, useMemo, useState } from "react";
useRef, import { router, useFocusEffect } from "expo-router";
useState,
} from "react";
import { useFocusEffect } from "expo-router";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import currentTheme from "../../Themes"; import { useThemeStore } from "../../stores/ThemeStore";
import BaseBackground from "../../components/BaseBackground"; import BaseBackground from "../../components/BaseBackground";
import { FlashList } from "@shopify/flash-list"; import { AuthService, EventService } from "../../services";
import { EventService } from "../../services";
import { useEventsStore } from "../../stores"; import { useEventsStore } from "../../stores";
import { useDropdownPosition } from "../../hooks/useDropdownPosition";
import { CaldavConfigService } from "../../services/CaldavConfigService";
import { useCaldavConfigStore } from "../../stores/CaldavConfigStore";
// MonthSelector types and helpers // MonthSelector types and helpers
type MonthItem = { type MonthItem = {
@@ -33,6 +30,14 @@ type MonthItem = {
label: string; // e.g. "January 2024" label: string; // e.g. "January 2024"
}; };
/**
* Formats a Date object to a string key in YYYY-MM-DD format.
* Used for grouping and looking up events by date.
*/
const getDateKey = (date: Date): string => {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
};
const generateMonths = ( const generateMonths = (
centerYear: number, centerYear: number,
centerMonth: number, centerMonth: number,
@@ -66,14 +71,18 @@ const Calendar = () => {
const [monthIndex, setMonthIndex] = useState(new Date().getMonth()); const [monthIndex, setMonthIndex] = useState(new Date().getMonth());
const [currentYear, setCurrentYear] = useState(new Date().getFullYear()); const [currentYear, setCurrentYear] = useState(new Date().getFullYear());
const [selectedDate, setSelectedDate] = useState<Date | null>(null); const [selectedDate, setSelectedDate] = useState<Date | null>(null);
const [overlayVisible, setOverlayVisible] = useState(false);
// State for delete modal
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const [eventToDelete, setEventToDelete] = useState<ExpandedEvent | null>(
null,
);
const { events, setEvents, deleteEvent } = useEventsStore(); const { events, setEvents, deleteEvent } = useEventsStore();
// Load events when tab gains focus or month/year changes // Load events from local DB (fast, no network sync)
// Include days from prev/next month that are visible in the grid const loadEvents = useCallback(async () => {
useFocusEffect(
useCallback(() => {
const loadEvents = async () => {
try { try {
// Calculate first visible day (up to 6 days before month start) // Calculate first visible day (up to 6 days before month start)
const firstOfMonth = new Date(currentYear, monthIndex, 1); const firstOfMonth = new Date(currentYear, monthIndex, 1);
@@ -98,19 +107,41 @@ const Calendar = () => {
} catch (error) { } catch (error) {
console.error("Failed to load events:", error); console.error("Failed to load events:", error);
} }
}; }, [monthIndex, currentYear, setEvents]);
// Load events from DB on focus
useFocusEffect(
useCallback(() => {
loadEvents(); loadEvents();
}, [monthIndex, currentYear, setEvents]), }, [loadEvents]),
);
// Re-open overlay after back navigation from editEvent
useFocusEffect(
useCallback(() => {
if (selectedDate) {
setOverlayVisible(true);
}
}, [selectedDate]),
); );
// Group events by date (YYYY-MM-DD format) // Group events by date (YYYY-MM-DD format)
// Multi-day events are added to all days they span
const eventsByDate = useMemo(() => { const eventsByDate = useMemo(() => {
const map = new Map<string, ExpandedEvent[]>(); const map = new Map<string, ExpandedEvent[]>();
events.forEach((e) => { events.forEach((e) => {
const date = new Date(e.occurrenceStart); const start = new Date(e.occurrenceStart);
const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; const end = new Date(e.occurrenceEnd);
// Iterate through each day the event spans
const current = new Date(start);
current.setHours(0, 0, 0, 0);
while (current <= end) {
const key = getDateKey(current);
if (!map.has(key)) map.set(key, []); if (!map.has(key)) map.set(key, []);
map.get(key)!.push(e); map.get(key)!.push(e);
current.setDate(current.getDate() + 1);
}
}); });
return map; return map;
}, [events]); }, [events]);
@@ -130,52 +161,74 @@ const Calendar = () => {
}); });
}; };
const handleDayPress = (date: Date, hasEvents: boolean) => { const handleDayPress = (date: Date) => {
if (hasEvents) {
setSelectedDate(date); setSelectedDate(date);
} setOverlayVisible(true);
}; };
const handleCloseOverlay = () => { const handleCloseOverlay = () => {
setSelectedDate(null); setSelectedDate(null);
setOverlayVisible(false);
}; };
const handleEditEvent = (event: ExpandedEvent) => { const handleCreateEvent = () => {
console.log("Edit event:", event.id); setOverlayVisible(false);
// TODO: Navigate to event edit screen router.push({
pathname: "/editEvent",
params: { date: selectedDate?.toISOString() },
});
}; };
const handleDeleteEvent = async (event: ExpandedEvent) => { const handleEditEvent = (event?: ExpandedEvent) => {
Alert.alert("Event löschen", `"${event.title}" wirklich löschen?`, [ router.push({
{ text: "Abbrechen", style: "cancel" }, pathname: "/editEvent",
{ params: {
text: "Löschen", mode: "calendar",
style: "destructive", id: event?.id,
onPress: async () => { },
});
};
const handleDeleteEvent = (event: ExpandedEvent) => {
// Show delete modal for both recurring and non-recurring events
setEventToDelete(event);
setDeleteModalVisible(true);
};
const handleDeleteConfirm = async (mode: RecurringDeleteMode) => {
if (!eventToDelete) return;
setDeleteModalVisible(false);
const event = eventToDelete;
const occurrenceDate = getDateKey(new Date(event.occurrenceStart));
try { try {
if (event.recurrenceRule) {
// Recurring event: use mode and occurrenceDate
await EventService.delete(event.id, mode, occurrenceDate);
// Reload events to reflect changes
await loadEvents();
} else {
// Non-recurring event: simple delete
await EventService.delete(event.id); await EventService.delete(event.id);
deleteEvent(event.id); deleteEvent(event.id);
// Close overlay if no more events for this date
if (selectedDate) {
const dateKey = `${selectedDate.getFullYear()}-${String(selectedDate.getMonth() + 1).padStart(2, "0")}-${String(selectedDate.getDate()).padStart(2, "0")}`;
const remainingEvents = eventsByDate.get(dateKey) || [];
if (remainingEvents.length <= 1) {
setSelectedDate(null);
}
} }
} catch (error) { } catch (error) {
console.error("Failed to delete event:", error); console.error("Failed to delete event:", error);
Alert.alert("Fehler", "Event konnte nicht gelöscht werden");
} }
}, // Note: Don't clear eventToDelete here - it will be overwritten when opening a new modal.
}, // Clearing it during fade-out animation causes the modal content to flash from recurring to single.
]); };
const handleDeleteCancel = () => {
setDeleteModalVisible(false);
// Note: Don't clear eventToDelete - keeps modal content stable during fade-out animation
}; };
// Get events for selected date // Get events for selected date
const selectedDateEvents = useMemo(() => { const selectedDateEvents = useMemo(() => {
if (!selectedDate) return []; if (!selectedDate) return [];
const key = `${selectedDate.getFullYear()}-${String(selectedDate.getMonth() + 1).padStart(2, "0")}-${String(selectedDate.getDate()).padStart(2, "0")}`; const key = getDateKey(selectedDate);
return eventsByDate.get(key) || []; return eventsByDate.get(key) || [];
}, [selectedDate, eventsByDate]); }, [selectedDate, eventsByDate]);
@@ -188,6 +241,7 @@ const Calendar = () => {
setMonthIndex={setMonthIndex} setMonthIndex={setMonthIndex}
setYear={setCurrentYear} setYear={setCurrentYear}
/> />
<CalendarToolbar loadEvents={loadEvents} />
<WeekDaysLine /> <WeekDaysLine />
<CalendarGrid <CalendarGrid
month={MONTHS[monthIndex]} month={MONTHS[monthIndex]}
@@ -195,15 +249,21 @@ const Calendar = () => {
eventsByDate={eventsByDate} eventsByDate={eventsByDate}
onDayPress={handleDayPress} onDayPress={handleDayPress}
/> />
{/* Event Overlay Modal */}
<EventOverlay <EventOverlay
visible={selectedDate !== null} visible={overlayVisible && !deleteModalVisible}
date={selectedDate} date={selectedDate}
events={selectedDateEvents} events={selectedDateEvents}
onClose={handleCloseOverlay} onClose={handleCloseOverlay}
onEditEvent={handleEditEvent} onEditEvent={handleEditEvent}
onDeleteEvent={handleDeleteEvent} onDeleteEvent={handleDeleteEvent}
onCreateEvent={handleCreateEvent}
/>
<DeleteEventModal
visible={deleteModalVisible}
eventTitle={eventToDelete?.title || ""}
isRecurring={!!eventToDelete?.recurrenceRule}
onConfirm={handleDeleteConfirm}
onCancel={handleDeleteCancel}
/> />
</BaseBackground> </BaseBackground>
); );
@@ -214,8 +274,9 @@ type EventOverlayProps = {
date: Date | null; date: Date | null;
events: ExpandedEvent[]; events: ExpandedEvent[];
onClose: () => void; onClose: () => void;
onEditEvent: (event: ExpandedEvent) => void; onEditEvent: (event?: ExpandedEvent) => void;
onDeleteEvent: (event: ExpandedEvent) => void; onDeleteEvent: (event: ExpandedEvent) => void;
onCreateEvent: () => void;
}; };
const EventOverlay = ({ const EventOverlay = ({
@@ -225,7 +286,10 @@ const EventOverlay = ({
onClose, onClose,
onEditEvent, onEditEvent,
onDeleteEvent, onDeleteEvent,
onCreateEvent,
}: EventOverlayProps) => { }: EventOverlayProps) => {
const { theme } = useThemeStore();
if (!date) return null; if (!date) return null;
const dateString = date.toLocaleDateString("de-DE", { const dateString = date.toLocaleDateString("de-DE", {
@@ -235,44 +299,32 @@ const EventOverlay = ({
year: "numeric", year: "numeric",
}); });
return ( const subtitle = `${events.length} ${events.length === 1 ? "Termin" : "Termine"}`;
<Modal
visible={visible}
transparent={true}
animationType="fade"
onRequestClose={onClose}
>
<Pressable
className="flex-1 justify-center items-center"
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
onPress={onClose}
>
<Pressable
className="w-11/12 max-h-3/4 rounded-2xl overflow-hidden"
style={{
backgroundColor: currentTheme.primeBg,
borderWidth: 4,
borderColor: currentTheme.borderPrimary,
}}
onPress={(e) => e.stopPropagation()}
>
{/* Header */}
<View
className="px-4 py-3"
style={{
backgroundColor: currentTheme.chatBot,
borderBottomWidth: 3,
borderBottomColor: currentTheme.borderPrimary,
}}
>
<Text className="font-bold text-lg">{dateString}</Text>
<Text>
{events.length} {events.length === 1 ? "Termin" : "Termine"}
</Text>
</View>
{/* Events List */} const addEventAttachment = (
<ScrollView className="p-4" style={{ maxHeight: 400 }}> <Pressable
className="flex flex-row justify-center items-center py-3"
style={{ backgroundColor: theme.confirmButton }}
onPress={onCreateEvent}
>
<Ionicons name="add-outline" size={24} color={theme.buttonText} />
<Text style={{ color: theme.buttonText }} className="font-semibold ml-1">
Neuen Termin erstellen
</Text>
</Pressable>
);
return (
<ModalBase
visible={visible}
onClose={onClose}
title={dateString}
subtitle={subtitle}
attachment={addEventAttachment}
footer={{ label: "Schliessen", onPress: onClose }}
scrollable={true}
maxContentHeight={400}
>
{events.map((event, index) => ( {events.map((event, index) => (
<EventCard <EventCard
key={`${event.id}-${index}`} key={`${event.id}-${index}`}
@@ -281,24 +333,7 @@ const EventOverlay = ({
onDelete={() => onDeleteEvent(event)} onDelete={() => onDeleteEvent(event)}
/> />
))} ))}
</ScrollView> </ModalBase>
{/* Close button */}
<Pressable
onPress={onClose}
className="py-3 items-center"
style={{
borderTopWidth: 1,
borderTopColor: currentTheme.placeholderBg,
}}
>
<Text style={{ color: currentTheme.primeFg }} className="font-bold">
Schließen
</Text>
</Pressable>
</Pressable>
</Pressable>
</Modal>
); );
}; };
@@ -311,6 +346,8 @@ type MonthSelectorProps = {
onSelectMonth: (year: number, monthIndex: number) => void; onSelectMonth: (year: number, monthIndex: number) => void;
}; };
const INITIAL_RANGE = 12; // 12 months before and after current
const MonthSelector = ({ const MonthSelector = ({
modalVisible, modalVisible,
onClose, onClose,
@@ -319,13 +356,10 @@ const MonthSelector = ({
currentMonthIndex, currentMonthIndex,
onSelectMonth, onSelectMonth,
}: MonthSelectorProps) => { }: MonthSelectorProps) => {
const heightAnim = useRef(new Animated.Value(0)).current;
const listRef = useRef<React.ComponentRef<typeof FlashList<MonthItem>>>(null);
const INITIAL_RANGE = 12; // 12 months before and after current
const [monthSelectorData, setMonthSelectorData] = useState<MonthItem[]>([]); const [monthSelectorData, setMonthSelectorData] = useState<MonthItem[]>([]);
const appendMonths = (direction: "start" | "end", count: number) => { const appendMonths = useCallback(
(direction: "start" | "end", count: number) => {
setMonthSelectorData((prevData) => { setMonthSelectorData((prevData) => {
if (prevData.length === 0) return prevData; if (prevData.length === 0) return prevData;
@@ -365,86 +399,55 @@ const MonthSelector = ({
? [...newMonths, ...prevData] ? [...newMonths, ...prevData]
: [...prevData, ...newMonths]; : [...prevData, ...newMonths];
}); });
}; },
[],
);
// Generate fresh data when modal opens, clear when closes
useEffect(() => { useEffect(() => {
if (modalVisible) { if (modalVisible) {
// Generate fresh data centered on current month
setMonthSelectorData( setMonthSelectorData(
generateMonths(currentYear, currentMonthIndex, INITIAL_RANGE), generateMonths(currentYear, currentMonthIndex, INITIAL_RANGE),
); );
Animated.timing(heightAnim, {
toValue: 200,
duration: 200,
useNativeDriver: false,
}).start();
} else { } else {
heightAnim.setValue(0);
// Clear data when closing
setMonthSelectorData([]); setMonthSelectorData([]);
} }
}, [modalVisible, heightAnim, currentYear, currentMonthIndex]); }, [modalVisible, currentYear, currentMonthIndex]);
const renderItem = ({ item }: { item: MonthItem }) => ( const handleSelect = useCallback(
<Pressable (item: MonthItem) => {
onPress={() => {
onSelectMonth(item.year, item.monthIndex); onSelectMonth(item.year, item.monthIndex);
onClose(); onClose();
}} },
> [onSelectMonth, onClose],
);
return (
<ScrollableDropdown
visible={modalVisible}
onClose={onClose}
position={position}
data={monthSelectorData}
keyExtractor={(item) => item.id}
renderItem={(item, theme) => (
<View <View
className="w-full flex justify-center items-center py-2" className="w-full flex justify-center items-center py-2"
style={{ style={{
backgroundColor: backgroundColor:
item.monthIndex % 2 === 0 item.monthIndex % 2 === 0 ? theme.primeBg : theme.secondaryBg,
? currentTheme.primeBg
: currentTheme.secondaryBg,
}} }}
> >
<Text className="text-xl" style={{ color: currentTheme.primeFg }}> <Text className="text-xl" style={{ color: theme.primeFg }}>
{item.label} {item.label}
</Text> </Text>
</View> </View>
</Pressable> )}
); onSelect={handleSelect}
height={200}
return (
<Modal
visible={modalVisible}
transparent={true}
animationType="none"
onRequestClose={onClose}
>
<Pressable className="flex-1 rounded-lg" onPress={onClose}>
<Animated.View
className="absolute overflow-hidden"
style={{
top: position.top,
left: position.left,
width: position.width,
height: heightAnim,
backgroundColor: currentTheme.primeBg,
borderWidth: 2,
borderColor: currentTheme.borderPrimary,
borderRadius: 8,
}}
>
<FlashList
className="w-full"
style={{ borderRadius: 8 }}
ref={listRef}
keyExtractor={(item) => item.id}
data={monthSelectorData}
initialScrollIndex={INITIAL_RANGE} initialScrollIndex={INITIAL_RANGE}
onEndReachedThreshold={0.5}
onEndReached={() => appendMonths("end", 12)} onEndReached={() => appendMonths("end", 12)}
onStartReachedThreshold={0.5}
onStartReached={() => appendMonths("start", 12)} onStartReached={() => appendMonths("start", 12)}
renderItem={renderItem}
/> />
</Animated.View>
</Pressable>
</Modal>
); );
}; };
@@ -457,60 +460,44 @@ type CalendarHeaderProps = {
}; };
const CalendarHeader = (props: CalendarHeaderProps) => { const CalendarHeader = (props: CalendarHeaderProps) => {
const [modalVisible, setModalVisible] = useState(false); const { theme } = useThemeStore();
const [dropdownPosition, setDropdownPosition] = useState({ const dropdown = useDropdownPosition();
top: 0,
left: 0,
width: 0,
});
const containerRef = useRef<View>(null);
const prevMonth = () => props.changeMonth(-1); const prevMonth = () => props.changeMonth(-1);
const nextMonth = () => props.changeMonth(1); const nextMonth = () => props.changeMonth(1);
const measureAndOpen = () => {
containerRef.current?.measureInWindow((x, y, width, height) => {
setDropdownPosition({ top: y + height, left: x, width });
setModalVisible(true);
});
};
return ( return (
<Header className="flex flex-row items-center justify-between"> <Header className="flex flex-row items-center justify-between">
<ChangeMonthButton onPress={prevMonth} icon="chevron-back" /> <ChangeMonthButton onPress={prevMonth} icon="chevron-back" />
<View <View
ref={containerRef} ref={dropdown.ref}
className="relative flex flex-row items-center justify-around" className="relative flex flex-row items-center justify-around"
> >
<Text className="text-4xl px-1"> <Text className="text-4xl px-1" style={{ color: theme.textPrimary }}>
{MONTHS[props.monthIndex]} {props.currentYear} {MONTHS[props.monthIndex]} {props.currentYear}
</Text> </Text>
<Pressable <Pressable
className="flex justify-center items-center w-12 h-12 border rounded-lg" className="flex justify-center items-center w-12 h-12 border rounded-lg"
style={{ style={{
borderColor: currentTheme.primeFg, borderColor: theme.primeFg,
backgroundColor: currentTheme.chatBot, backgroundColor: theme.chatBot,
// iOS shadow // iOS shadow
shadowColor: "#000", shadowColor: theme.shadowColor,
shadowOffset: { width: 0, height: 3 }, shadowOffset: { width: 0, height: 3 },
shadowOpacity: 0.35, shadowOpacity: 0.35,
shadowRadius: 5, shadowRadius: 5,
// Android shadow // Android shadow
elevation: 6, elevation: 6,
}} }}
onPress={measureAndOpen} onPress={dropdown.open}
> >
<Ionicons <Ionicons name="chevron-down" size={28} color={theme.primeFg} />
name="chevron-down"
size={28}
color={currentTheme.primeFg}
/>
</Pressable> </Pressable>
</View> </View>
<MonthSelector <MonthSelector
modalVisible={modalVisible} modalVisible={dropdown.visible}
onClose={() => setModalVisible(false)} onClose={dropdown.close}
position={dropdownPosition} position={dropdown.position}
currentYear={props.currentYear} currentYear={props.currentYear}
currentMonthIndex={props.monthIndex} currentMonthIndex={props.monthIndex}
onSelectMonth={(year, month) => { onSelectMonth={(year, month) => {
@@ -528,15 +515,17 @@ type ChangeMonthButtonProps = {
icon: "chevron-back" | "chevron-forward"; icon: "chevron-back" | "chevron-forward";
}; };
const ChangeMonthButton = (props: ChangeMonthButtonProps) => ( const ChangeMonthButton = (props: ChangeMonthButtonProps) => {
const { theme } = useThemeStore();
return (
<Pressable <Pressable
onPress={props.onPress} onPress={props.onPress}
className="w-16 h-16 flex items-center justify-center mx-2 rounded-xl border border-solid" className="w-16 h-16 flex items-center justify-center mx-2 rounded-xl border border-solid"
style={{ style={{
backgroundColor: currentTheme.chatBot, backgroundColor: theme.chatBot,
borderColor: currentTheme.primeFg, borderColor: theme.primeFg,
// iOS shadow // iOS shadow
shadowColor: "#000", shadowColor: theme.shadowColor,
shadowOffset: { width: 0, height: 4 }, shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3, shadowOpacity: 0.3,
shadowRadius: 8, shadowRadius: 8,
@@ -547,7 +536,7 @@ const ChangeMonthButton = (props: ChangeMonthButtonProps) => (
<Ionicons <Ionicons
name={props.icon} name={props.icon}
size={48} size={48}
color={currentTheme.primeFg} color={theme.primeFg}
style={{ style={{
marginLeft: props.icon === "chevron-forward" ? 4 : 0, marginLeft: props.icon === "chevron-forward" ? 4 : 0,
marginRight: props.icon === "chevron-back" ? 4 : 0, marginRight: props.icon === "chevron-back" ? 4 : 0,
@@ -555,24 +544,156 @@ const ChangeMonthButton = (props: ChangeMonthButtonProps) => (
/> />
</Pressable> </Pressable>
); );
};
const WeekDaysLine = () => ( 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 className="flex flex-row items-center justify-around px-2 gap-2"> <View className="flex flex-row items-center justify-around px-2 gap-2">
{/* TODO: px and gap need fine tuning to perfectly align with the grid */} {/* TODO: px and gap need fine tuning to perfectly align with the grid */}
{DAYS.map((day, i) => ( {DAYS.map((day, i) => (
<Text key={i}>{day.substring(0, 2).toUpperCase()}</Text> <Text key={i} style={{ color: theme.textPrimary }}>
{day.substring(0, 2).toUpperCase()}
</Text>
))} ))}
</View> </View>
); );
};
type CalendarGridProps = { type CalendarGridProps = {
month: Month; month: Month;
year: number; year: number;
eventsByDate: Map<string, ExpandedEvent[]>; eventsByDate: Map<string, ExpandedEvent[]>;
onDayPress: (date: Date, hasEvents: boolean) => void; onDayPress: (date: Date) => void;
}; };
const CalendarGrid = (props: CalendarGridProps) => { const CalendarGrid = (props: CalendarGridProps) => {
const { theme } = useThemeStore();
const { baseDate, dateOffset } = useMemo(() => { const { baseDate, dateOffset } = useMemo(() => {
const monthIndex = MONTHS.indexOf(props.month); const monthIndex = MONTHS.indexOf(props.month);
const base = new Date(props.year, monthIndex, 1); const base = new Date(props.year, monthIndex, 1);
@@ -587,15 +708,11 @@ const CalendarGrid = (props: CalendarGridProps) => {
return date; return date;
}; };
const getDateKey = (date: Date): string => {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
};
return ( return (
<View <View
className="h-full flex-1 flex-col flex-wrap gap-2 p-2" className="h-full flex-1 flex-col flex-wrap gap-2 p-2"
style={{ style={{
backgroundColor: currentTheme.calenderBg, backgroundColor: theme.calenderBg,
}} }}
> >
{Array.from({ length: 6 }).map((_, i) => ( {Array.from({ length: 6 }).map((_, i) => (
@@ -613,7 +730,7 @@ const CalendarGrid = (props: CalendarGridProps) => {
date={date} date={date}
month={props.month} month={props.month}
hasEvents={hasEvents} hasEvents={hasEvents}
onPress={() => props.onDayPress(date, hasEvents)} onPress={() => props.onDayPress(date)}
/> />
); );
})} })}
@@ -631,6 +748,7 @@ type SingleDayProps = {
}; };
const SingleDay = (props: SingleDayProps) => { const SingleDay = (props: SingleDayProps) => {
const { theme } = useThemeStore();
const isSameMonth = MONTHS[props.date.getMonth()] === props.month; const isSameMonth = MONTHS[props.date.getMonth()] === props.month;
return ( return (
@@ -638,11 +756,12 @@ const SingleDay = (props: SingleDayProps) => {
onPress={props.onPress} onPress={props.onPress}
className="h-full flex-1 aspect-auto rounded-xl items-center justify-between py-1" className="h-full flex-1 aspect-auto rounded-xl items-center justify-between py-1"
style={{ style={{
backgroundColor: currentTheme.primeBg, backgroundColor: theme.primeBg,
}} }}
> >
<Text <Text
className={`text-xl ` + (isSameMonth ? "text-black" : "text-black/50")} className="text-xl"
style={{ color: theme.textPrimary, opacity: isSameMonth ? 1 : 0.5 }}
> >
{props.date.getDate()} {props.date.getDate()}
</Text> </Text>
@@ -651,7 +770,7 @@ const SingleDay = (props: SingleDayProps) => {
{props.hasEvents && ( {props.hasEvents && (
<View <View
className="w-2 h-2 rounded-full" className="w-2 h-2 rounded-full"
style={{ backgroundColor: currentTheme.eventIndicator }} style={{ backgroundColor: theme.eventIndicator }}
/> />
)} )}
</Pressable> </Pressable>

View File

@@ -7,20 +7,24 @@ import {
Platform, Platform,
Keyboard, Keyboard,
} from "react-native"; } from "react-native";
import currentTheme from "../../Themes"; import { useThemeStore } from "../../stores/ThemeStore";
import React, { useState, useRef, useEffect } from "react"; import React, { useState, useRef, useEffect, useCallback } from "react";
import { useFocusEffect, router } from "expo-router";
import Header from "../../components/Header"; import Header from "../../components/Header";
import BaseBackground from "../../components/BaseBackground"; import BaseBackground from "../../components/BaseBackground";
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import { ChatService } from "../../services"; import { ChatService } from "../../services";
import { import {
useChatStore, useChatStore,
useAuthStore,
chatMessageToMessageData, chatMessageToMessageData,
MessageData, MessageData,
} from "../../stores"; } from "../../stores";
import { ProposedEventChange } from "@calchat/shared"; import { ProposedEventChange, RespondedAction } from "@calchat/shared";
import { ProposedEventCard } from "../../components/ProposedEventCard"; import { ProposedEventCard } from "../../components/ProposedEventCard";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import TypingIndicator from "../../components/TypingIndicator";
import { ChatBubble } from "../../components/ChatBubble";
// TODO: better shadows for everything // TODO: better shadows for everything
// (maybe with extra library because of differences between android and ios) // (maybe with extra library because of differences between android and ios)
@@ -34,30 +38,47 @@ type ChatMessageProps = {
proposedChanges?: ProposedEventChange[]; proposedChanges?: ProposedEventChange[];
onConfirm?: (proposalId: string, proposal: ProposedEventChange) => void; onConfirm?: (proposalId: string, proposal: ProposedEventChange) => void;
onReject?: (proposalId: string) => void; onReject?: (proposalId: string) => void;
onEdit?: (proposalId: string, proposal: ProposedEventChange) => void;
}; };
type ChatInputProps = { type ChatInputProps = {
onSend: (text: string) => void; onSend: (text: string) => void;
}; };
const TYPING_INDICATOR_DELAY_MS = 500;
const Chat = () => { const Chat = () => {
const { messages, addMessage, addMessages, updateMessage } = useChatStore(); const { isAuthenticated, isLoading: isAuthLoading } = useAuthStore();
const {
messages,
addMessage,
addMessages,
updateMessage,
isWaitingForResponse,
setWaitingForResponse,
} = useChatStore();
const listRef = const listRef =
useRef<React.ComponentRef<typeof FlashList<MessageData>>>(null); useRef<React.ComponentRef<typeof FlashList<MessageData>>>(null);
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const [currentConversationId, setCurrentConversationId] = useState< const [currentConversationId, setCurrentConversationId] = useState<
string | undefined string | undefined
>(); >();
const [hasLoadedMessages, setHasLoadedMessages] = useState(false);
const needsInitialScroll = useRef(false);
useEffect(() => { useEffect(() => {
const keyboardDidShow = Keyboard.addListener( const keyboardDidShow = Keyboard.addListener("keyboardDidShow", () =>
"keyboardDidShow", scrollToEnd(),
scrollToEnd,
); );
return () => keyboardDidShow.remove(); return () => keyboardDidShow.remove();
}, []); }, []);
// Load existing messages from database on mount // Load existing messages from database only once (on initial mount)
useEffect(() => { // Skip on subsequent focus events to preserve local edits (e.g., edited proposals)
useFocusEffect(
useCallback(() => {
if (isAuthLoading || !isAuthenticated || hasLoadedMessages) return;
const fetchMessages = async () => { const fetchMessages = async () => {
try { try {
const conversationSummaries = await ChatService.getConversations(); const conversationSummaries = await ChatService.getConversations();
@@ -69,23 +90,26 @@ const Chat = () => {
await ChatService.getConversation(conversationId); await ChatService.getConversation(conversationId);
const clientMessages = serverMessages.map(chatMessageToMessageData); const clientMessages = serverMessages.map(chatMessageToMessageData);
addMessages(clientMessages); addMessages(clientMessages);
scrollToEnd(); needsInitialScroll.current = true;
} }
} catch (error) { } catch (error) {
console.error("Failed to load messages:", error); console.error("Failed to load messages:", error);
} finally {
setHasLoadedMessages(true);
} }
}; };
fetchMessages(); fetchMessages();
}, []); }, [isAuthLoading, isAuthenticated, hasLoadedMessages]),
);
const scrollToEnd = () => { const scrollToEnd = (animated = true) => {
setTimeout(() => { setTimeout(() => {
listRef.current?.scrollToEnd({ animated: true }); listRef.current?.scrollToEnd({ animated });
}, 100); }, 100);
}; };
const handleEventResponse = async ( const handleEventResponse = async (
action: "confirm" | "reject", action: RespondedAction,
messageId: string, messageId: string,
conversationId: string, conversationId: string,
proposalId: string, proposalId: string,
@@ -95,7 +119,7 @@ const Chat = () => {
const message = messages.find((m) => m.id === messageId); const message = messages.find((m) => m.id === messageId);
if (message?.proposedChanges) { if (message?.proposedChanges) {
const updatedProposals = message.proposedChanges.map((p) => const updatedProposals = message.proposedChanges.map((p) =>
p.id === proposalId ? { ...p, respondedAction: action as "confirm" | "reject" } : p, p.id === proposalId ? { ...p, respondedAction: action } : p,
); );
updateMessage(messageId, { proposedChanges: updatedProposals }); updateMessage(messageId, { proposedChanges: updatedProposals });
} }
@@ -111,8 +135,14 @@ const Chat = () => {
proposedChange.event, proposedChange.event,
proposedChange.eventId, proposedChange.eventId,
proposedChange.updates, proposedChange.updates,
proposedChange.deleteMode,
proposedChange.occurrenceDate,
) )
: await ChatService.rejectEvent(conversationId, messageId, proposalId); : await ChatService.rejectEvent(
conversationId,
messageId,
proposalId,
);
const botMessage: MessageData = { const botMessage: MessageData = {
id: response.message.id, id: response.message.id,
@@ -134,6 +164,26 @@ const Chat = () => {
} }
}; };
const handleEditProposal = (
messageId: string,
conversationId: string,
proposalId: string,
proposal: ProposedEventChange,
) => {
router.push({
pathname: "/editEvent",
params: {
mode: "chat",
eventData: JSON.stringify(proposal.event),
proposalContext: JSON.stringify({
messageId,
proposalId,
conversationId,
}),
},
});
};
const handleSend = async (text: string) => { const handleSend = async (text: string) => {
// Show user message immediately // Show user message immediately
const userMessage: MessageData = { const userMessage: MessageData = {
@@ -145,6 +195,12 @@ const Chat = () => {
addMessage(userMessage); addMessage(userMessage);
scrollToEnd(); scrollToEnd();
// Show typing indicator after delay
typingTimeoutRef.current = setTimeout(() => {
setWaitingForResponse(true);
scrollToEnd();
}, TYPING_INDICATOR_DELAY_MS);
try { try {
// Fetch server response (include conversationId for existing conversations) // Fetch server response (include conversationId for existing conversations)
const response = await ChatService.sendMessage({ const response = await ChatService.sendMessage({
@@ -169,6 +225,10 @@ const Chat = () => {
scrollToEnd(); scrollToEnd();
} catch (error) { } catch (error) {
console.error("Failed to send message:", error); console.error("Failed to send message:", error);
} finally {
// Hide typing indicator
clearTimeout(typingTimeoutRef.current);
setWaitingForResponse(false);
} }
}; };
@@ -197,13 +257,35 @@ const Chat = () => {
) )
} }
onReject={(proposalId) => onReject={(proposalId) =>
handleEventResponse("reject", item.id, item.conversationId!, proposalId) handleEventResponse(
"reject",
item.id,
item.conversationId!,
proposalId,
)
}
onEdit={(proposalId, proposal) =>
handleEditProposal(
item.id,
item.conversationId!,
proposalId,
proposal,
)
} }
/> />
)} )}
keyExtractor={(item) => item.id} keyExtractor={(item) => item.id}
keyboardDismissMode="interactive" keyboardDismissMode="interactive"
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
onContentSizeChange={() => {
if (needsInitialScroll.current) {
needsInitialScroll.current = false;
listRef.current?.scrollToEnd({ animated: false });
}
}}
ListFooterComponent={
isWaitingForResponse ? <TypingIndicator /> : null
}
/> />
<ChatInput onSend={handleSend} /> <ChatInput onSend={handleSend} />
</KeyboardAvoidingView> </KeyboardAvoidingView>
@@ -212,20 +294,23 @@ const Chat = () => {
}; };
const ChatHeader = () => { const ChatHeader = () => {
const { theme } = useThemeStore();
return ( return (
<Header className="flex flex-row items-center"> <Header className="flex flex-row items-center">
<View <View
className="ml-3 w-12 h-12 rounded-3xl border border-solid" className="ml-3 w-12 h-12 rounded-3xl border border-solid"
style={{ style={{
backgroundColor: currentTheme.placeholderBg, backgroundColor: theme.placeholderBg,
borderColor: currentTheme.primeFg, borderColor: theme.primeFg,
}} }}
></View> ></View>
<Text className="text-lg pl-3">CalChat</Text> <Text className="text-lg pl-3" style={{ color: theme.textPrimary }}>
CalChat
</Text>
<View <View
className="h-2 bg-black" className="h-2 bg-black"
style={{ style={{
shadowColor: "#000", shadowColor: theme.shadowColor,
shadowOffset: { shadowOffset: {
width: 0, width: 0,
height: 5, height: 5,
@@ -244,6 +329,7 @@ const MIN_INPUT_HEIGHT = 40;
const MAX_INPUT_HEIGHT = 150; const MAX_INPUT_HEIGHT = 150;
const ChatInput = ({ onSend }: ChatInputProps) => { const ChatInput = ({ onSend }: ChatInputProps) => {
const { theme } = useThemeStore();
const [text, setText] = useState(""); const [text, setText] = useState("");
const handleSend = () => { const handleSend = () => {
@@ -256,9 +342,11 @@ const ChatInput = ({ onSend }: ChatInputProps) => {
return ( return (
<View className="flex flex-row w-full items-end my-2 px-2"> <View className="flex flex-row w-full items-end my-2 px-2">
<TextInput <TextInput
testID="chat-message-input"
className="flex-1 border border-solid rounded-2xl px-3 py-2 mr-2" className="flex-1 border border-solid rounded-2xl px-3 py-2 mr-2"
style={{ style={{
backgroundColor: currentTheme.messageBorderBg, backgroundColor: theme.messageBorderBg,
color: theme.textPrimary,
minHeight: MIN_INPUT_HEIGHT, minHeight: MIN_INPUT_HEIGHT,
maxHeight: MAX_INPUT_HEIGHT, maxHeight: MAX_INPUT_HEIGHT,
textAlignVertical: "top", textAlignVertical: "top",
@@ -266,14 +354,14 @@ const ChatInput = ({ onSend }: ChatInputProps) => {
onChangeText={setText} onChangeText={setText}
value={text} value={text}
placeholder="Nachricht..." placeholder="Nachricht..."
placeholderTextColor="#999" placeholderTextColor={theme.textMuted}
multiline multiline
/> />
<Pressable onPress={handleSend}> <Pressable testID="chat-send-button" onPress={handleSend}>
<View <View
className="w-10 h-10 rounded-full items-center justify-center" className="w-10 h-10 rounded-full items-center justify-center"
style={{ style={{
backgroundColor: currentTheme.placeholderBg, backgroundColor: theme.placeholderBg,
}} }}
/> />
</Pressable> </Pressable>
@@ -287,42 +375,36 @@ const ChatMessage = ({
proposedChanges, proposedChanges,
onConfirm, onConfirm,
onReject, onReject,
onEdit,
}: ChatMessageProps) => { }: ChatMessageProps) => {
const { theme } = useThemeStore();
const [currentIndex, setCurrentIndex] = useState(0); const [currentIndex, setCurrentIndex] = useState(0);
const borderColor =
side === "left" ? currentTheme.chatBot : currentTheme.primeFg;
const selfSide =
side === "left"
? "self-start ml-2 rounded-bl-sm"
: "self-end mr-2 rounded-br-sm";
const hasProposals = proposedChanges && proposedChanges.length > 0; const hasProposals = proposedChanges && proposedChanges.length > 0;
const hasMultiple = proposedChanges && proposedChanges.length > 1; const hasMultiple = proposedChanges && proposedChanges.length > 1;
const currentProposal = proposedChanges?.[currentIndex]; const currentProposal = proposedChanges?.[currentIndex];
const goToPrev = () => setCurrentIndex((i) => Math.max(0, i - 1)); const goToPrev = () => setCurrentIndex((i) => Math.max(0, i - 1));
const goToNext = () => const goToNext = () =>
setCurrentIndex((i) => setCurrentIndex((i) => Math.min((proposedChanges?.length || 1) - 1, i + 1));
Math.min((proposedChanges?.length || 1) - 1, i + 1),
);
const canGoPrev = currentIndex > 0; const canGoPrev = currentIndex > 0;
const canGoNext = currentIndex < (proposedChanges?.length || 1) - 1; const canGoNext = currentIndex < (proposedChanges?.length || 1) - 1;
return ( return (
<View <ChatBubble
className={`bg-white border-2 border-solid rounded-xl my-2 ${selfSide}`} side={side}
testID={`chat-bubble-${side}`}
style={{ style={{
borderColor: borderColor,
maxWidth: "80%", maxWidth: "80%",
minWidth: hasProposals ? "75%" : undefined, minWidth: hasProposals ? "75%" : undefined,
elevation: 8,
}} }}
> >
<Text className="p-2">{content}</Text> <Text className="p-2" style={{ color: theme.textPrimary }}>
{content}
</Text>
{hasProposals && currentProposal && onConfirm && onReject && ( {hasProposals && currentProposal && onConfirm && onReject && onEdit && (
<View> <View>
{/* Event card with optional navigation arrows */} {/* Event card with optional navigation arrows */}
<View className="flex-row items-center"> <View className="flex-row items-center">
@@ -334,11 +416,7 @@ const ChatMessage = ({
className="p-1" className="p-1"
style={{ opacity: canGoPrev ? 1 : 0.3 }} style={{ opacity: canGoPrev ? 1 : 0.3 }}
> >
<Ionicons <Ionicons name="chevron-back" size={24} color={theme.primeFg} />
name="chevron-back"
size={24}
color={currentTheme.primeFg}
/>
</Pressable> </Pressable>
)} )}
@@ -346,8 +424,9 @@ const ChatMessage = ({
<View className="flex-1"> <View className="flex-1">
<ProposedEventCard <ProposedEventCard
proposedChange={currentProposal} proposedChange={currentProposal}
onConfirm={() => onConfirm(currentProposal.id, currentProposal)} onConfirm={(proposal) => onConfirm(proposal.id, proposal)}
onReject={() => onReject(currentProposal.id)} onReject={() => onReject(currentProposal.id)}
onEdit={(proposal) => onEdit(proposal.id, proposal)}
/> />
</View> </View>
@@ -362,7 +441,7 @@ const ChatMessage = ({
<Ionicons <Ionicons
name="chevron-forward" name="chevron-forward"
size={24} size={24}
color={currentTheme.primeFg} color={theme.primeFg}
/> />
</Pressable> </Pressable>
)} )}
@@ -372,14 +451,14 @@ const ChatMessage = ({
{hasMultiple && ( {hasMultiple && (
<Text <Text
className="text-center text-sm pb-2" className="text-center text-sm pb-2"
style={{ color: currentTheme.textSecondary || "#666" }} style={{ color: theme.textSecondary || "#666" }}
> >
Event {currentIndex + 1} von {proposedChanges.length} Event {currentIndex + 1} von {proposedChanges.length}
</Text> </Text>
)} )}
</View> </View>
)} )}
</View> </ChatBubble>
); );
}; };

View File

@@ -0,0 +1,256 @@
import { ActivityIndicator, Text, View } from "react-native";
import BaseBackground from "../../components/BaseBackground";
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
testID={props.testID}
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();
return (
<BaseBackground>
<SimpleHeader text="Settings" />
<View className="flex items-center mt-4">
<SettingsButton
testID="settings-logout-button"
onPress={handleLogout}
solid={true}
>
<Ionicons name="log-out-outline" size={24} color={theme.primeFg} />{" "}
Logout
</SettingsButton>
<View>
<Text
className="text-center text-2xl"
style={{ color: theme.textPrimary }}
>
Select Theme
</Text>
</View>
<SettingsButton
solid={theme == THEMES.defaultLight}
onPress={() => {
setTheme("defaultLight");
}}
>
Default Light
</SettingsButton>
<SettingsButton
solid={theme == THEMES.defaultDark}
onPress={() => {
setTheme("defaultDark");
}}
>
Default Dark
</SettingsButton>
</View>
<CaldavSettings />
</BaseBackground>
);
};
export default Settings;

View File

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

View File

@@ -0,0 +1,573 @@
import {
View,
Text,
TextInput,
Pressable,
ActivityIndicator,
} from "react-native";
import { Frequency, rrulestr } from "rrule";
import BaseBackground from "../components/BaseBackground";
import { useThemeStore } from "../stores/ThemeStore";
import { useCallback, useEffect, useState } from "react";
import { router, useLocalSearchParams } from "expo-router";
import Header, { HeaderButton } from "../components/Header";
import {
DatePickerButton,
TimePickerButton,
} from "../components/DateTimePicker";
import { Ionicons } from "@expo/vector-icons";
import { ScrollableDropdown } from "../components/ScrollableDropdown";
import { useDropdownPosition } from "../hooks/useDropdownPosition";
import { EventService, ChatService } from "../services";
import {
buildRRule,
CreateEventDTO,
REPEAT_TYPE_LABELS,
RepeatType,
} from "@calchat/shared";
import { useChatStore } from "../stores";
import CustomTextInput, {
CustomTextInputProps,
} from "../components/CustomTextInput";
type EditEventTextFieldProps = CustomTextInputProps & {
titel: string;
};
const EditEventTextField = (props: EditEventTextFieldProps) => {
const { theme } = useThemeStore();
return (
<View className={props.className}>
<Text className="text-xl" style={{ color: theme.textPrimary }}>
{props.titel}
</Text>
<CustomTextInput
className="flex-1 px-3 py-2"
text={props.text}
multiline={props.multiline}
onValueChange={props.onValueChange}
/>
</View>
);
};
type PickerRowProps = {
title: string;
showLabels?: boolean;
dateValue: Date;
onDateChange: (date: Date) => void;
onTimeChange: (date: Date) => void;
};
const PickerRow = ({
showLabels,
dateValue,
title,
onDateChange,
onTimeChange,
}: PickerRowProps) => {
const { theme } = useThemeStore();
return (
<View className="flex flex-row w-11/12 mt-4 items-end justify-between gap-x-2">
<Text className="text-xl pb-2" style={{ color: theme.textPrimary }}>
{title}
</Text>
<View className="flex flex-row w-10/12 gap-x-2">
<DatePickerButton
className="flex-1"
label={showLabels ? "Datum" : undefined}
value={dateValue}
onChange={onDateChange}
/>
<TimePickerButton
className="flex-1"
label={showLabels ? "Uhrzeit" : undefined}
value={dateValue}
onChange={onTimeChange}
/>
</View>
</View>
);
};
type RepeatPressableProps = {
focused: boolean;
repeatType: RepeatType;
setRepeatType: (repeatType: RepeatType) => void;
};
const RepeatPressable = ({
focused,
repeatType,
setRepeatType,
}: RepeatPressableProps) => {
const { theme } = useThemeStore();
return (
<Pressable
className="px-4 py-2 rounded-lg border"
style={{
backgroundColor: focused ? theme.chatBot : theme.secondaryBg,
borderColor: theme.borderPrimary,
}}
onPress={() => setRepeatType(repeatType)}
>
<Text style={{ color: focused ? theme.buttonText : theme.textPrimary }}>
{repeatType}
</Text>
</Pressable>
);
};
type RepeatSelectorProps = {
repeatCount: number;
onRepeatCountChange: (count: number) => void;
repeatType: RepeatType;
onRepeatTypeChange: (type: RepeatType) => void;
};
// Static data for repeat count dropdown (1-120)
const REPEAT_COUNT_DATA = Array.from({ length: 120 }, (_, i) => i + 1);
const RepeatSelector = ({
repeatCount,
onRepeatCountChange,
repeatType,
onRepeatTypeChange,
}: RepeatSelectorProps) => {
const { theme } = useThemeStore();
const dropdown = useDropdownPosition(2);
const handleSelectCount = useCallback(
(count: number) => {
onRepeatCountChange(count);
dropdown.close();
},
[onRepeatCountChange, dropdown],
);
const typeLabel = REPEAT_TYPE_LABELS[repeatType];
return (
<View className="mt-4">
{/* Repeat Type Selection */}
<View className="flex flex-row gap-2 mb-3">
<RepeatPressable
repeatType="Tag"
setRepeatType={onRepeatTypeChange}
focused={repeatType === "Tag"}
/>
<RepeatPressable
repeatType="Woche"
setRepeatType={onRepeatTypeChange}
focused={repeatType === "Woche"}
/>
<RepeatPressable
repeatType="Monat"
setRepeatType={onRepeatTypeChange}
focused={repeatType === "Monat"}
/>
<RepeatPressable
repeatType="Jahr"
setRepeatType={onRepeatTypeChange}
focused={repeatType === "Jahr"}
/>
</View>
{/* Repeat Count Selection */}
<View className="flex flex-row items-center">
<Text className="text-lg" style={{ color: theme.textPrimary }}>
Alle{" "}
</Text>
<Pressable
ref={dropdown.ref}
className="px-4 py-2 rounded-lg border"
style={{
backgroundColor: theme.secondaryBg,
borderColor: theme.borderPrimary,
}}
onPress={dropdown.open}
>
<Text className="text-lg" style={{ color: theme.textPrimary }}>
{repeatCount}
</Text>
</Pressable>
<Text className="text-lg" style={{ color: theme.textPrimary }}>
{" "}
{typeLabel}
</Text>
</View>
{/* Count Dropdown */}
<ScrollableDropdown
visible={dropdown.visible}
onClose={dropdown.close}
position={{
bottom: 12,
left: 10,
width: 100,
}}
data={REPEAT_COUNT_DATA}
keyExtractor={(n) => String(n)}
renderItem={(n, theme) => (
<View
className="w-full flex justify-center items-center py-2"
style={{
backgroundColor: n % 2 === 0 ? theme.primeBg : theme.secondaryBg,
}}
>
<Text className="text-xl" style={{ color: theme.textPrimary }}>
{n}
</Text>
</View>
)}
onSelect={handleSelectCount}
heightRatio={0.4}
initialScrollIndex={repeatCount - 1}
/>
</View>
);
};
type EditEventHeaderProps = {
id?: string;
mode?: "calendar" | "chat";
};
const EditEventHeader = ({ id, mode }: EditEventHeaderProps) => {
const getTitle = () => {
if (mode === "chat") return "Edit Proposal";
return id ? "Edit Meeting" : "New Meeting";
};
return (
<Header className="flex flex-row justify-center items-center">
<HeaderButton
className="absolute left-6"
iconName="arrow-back-outline"
iconSize={36}
onPress={router.back}
/>
<View className="h-full flex justify-center ml-4">
<Text className="text-center text-3xl font-bold">{getTitle()}</Text>
</View>
</Header>
);
};
type EditEventParams = {
id?: string;
date?: string;
mode?: "calendar" | "chat";
eventData?: string;
proposalContext?: string;
};
type ProposalContext = {
messageId: string;
proposalId: string;
conversationId: string;
};
const EditEventScreen = () => {
const { id, date, mode, eventData, proposalContext } =
useLocalSearchParams<EditEventParams>();
const { theme } = useThemeStore();
const updateMessage = useChatStore((state) => state.updateMessage);
// Only show loading if we need to fetch from API (calendar mode with id)
const [isLoading, setIsLoading] = useState(
mode !== "chat" && !!id && !eventData,
);
// Initialize dates from URL parameter or use current time
const initialDate = date ? new Date(date) : new Date();
const initialEndDate = new Date(initialDate.getTime() + 60 * 60 * 1000);
const [repeatVisible, setRepeatVisible] = useState(false);
const [repeatCount, setRepeatCount] = useState(1);
const [repeatType, setRepeatType] = useState<RepeatType>("Tag");
const [startDate, setStartDate] = useState(initialDate);
const [endDate, setEndDate] = useState(initialEndDate);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
// Helper to populate form from event data
const populateFormFromEvent = useCallback((event: CreateEventDTO) => {
setStartDate(new Date(event.startTime));
setEndDate(new Date(event.endTime));
setTitle(event.title);
if (event.description) {
setDescription(event.description);
}
if (event.recurrenceRule) {
setRepeatVisible(true);
const rrule = rrulestr(event.recurrenceRule);
if (rrule.options.interval) {
setRepeatCount(rrule.options.interval);
}
switch (rrule.options.freq) {
case Frequency.DAILY:
setRepeatType("Tag");
break;
case Frequency.WEEKLY:
setRepeatType("Woche");
break;
case Frequency.MONTHLY:
setRepeatType("Monat");
break;
case Frequency.YEARLY:
setRepeatType("Jahr");
break;
}
}
}, []);
// Load event data based on mode
useEffect(() => {
// Chat mode: load from eventData JSON parameter
if (mode === "chat" && eventData) {
try {
const event = JSON.parse(eventData) as CreateEventDTO;
populateFormFromEvent(event);
} catch (error) {
console.error("Failed to parse eventData:", error);
}
return;
}
// Calendar mode with id: fetch from API
if (id && !eventData) {
const fetchEvent = async () => {
try {
const event = await EventService.getById(id);
populateFormFromEvent({
title: event.title,
description: event.description,
startTime: event.startTime,
endTime: event.endTime,
recurrenceRule: event.recurrenceRule,
});
} catch (error) {
console.error("Failed to load event: ", error);
} finally {
setIsLoading(false);
}
};
fetchEvent();
}
}, [id, mode, eventData, populateFormFromEvent]);
if (isLoading) {
return (
<BaseBackground>
<EditEventHeader id={id} mode={mode} />
<View className="flex-1 justify-center items-center">
<ActivityIndicator size="large" color={theme.chatBot} />
</View>
</BaseBackground>
);
}
const handleStartDateChange = (date: Date) => {
// Keep the time from startDate, update the date part
const newStart = new Date(startDate);
newStart.setFullYear(date.getFullYear(), date.getMonth(), date.getDate());
setStartDate(newStart);
// If end date is before new start date, adjust it
if (endDate < newStart) {
const newEnd = new Date(newStart);
newEnd.setHours(newStart.getHours() + 1);
setEndDate(newEnd);
}
};
const handleStartTimeChange = (date: Date) => {
// Keep the date from startDate, update the time part
const newStart = new Date(startDate);
newStart.setHours(date.getHours(), date.getMinutes(), 0, 0);
setStartDate(newStart);
// If end time is before new start time on the same day, adjust it
if (endDate <= newStart) {
const newEnd = new Date(newStart);
newEnd.setHours(newStart.getHours() + 1);
setEndDate(newEnd);
}
};
const handleEndDateChange = (date: Date) => {
// Keep the time from endDate, update the date part
const newEnd = new Date(endDate);
newEnd.setFullYear(date.getFullYear(), date.getMonth(), date.getDate());
setEndDate(newEnd);
};
const handleEndTimeChange = (date: Date) => {
// Keep the date from endDate, update the time part
const newEnd = new Date(endDate);
newEnd.setHours(date.getHours(), date.getMinutes(), 0, 0);
setEndDate(newEnd);
};
const handleSave = async () => {
const eventObject: CreateEventDTO = {
title,
description: description === "" ? undefined : description,
startTime: startDate,
endTime: endDate,
recurrenceRule: repeatVisible
? buildRRule(repeatType, repeatCount)
: undefined,
};
// Chat mode: update proposal on server and sync response to local store
if (mode === "chat" && proposalContext) {
try {
const context = JSON.parse(proposalContext) as ProposalContext;
// Persist to server - returns updated message with recalculated conflictingEvents
const updatedMessage = await ChatService.updateProposalEvent(
context.messageId,
context.proposalId,
eventObject,
);
// Update local ChatStore with server response (includes updated conflicts)
if (updatedMessage?.proposedChanges) {
updateMessage(context.messageId, {
proposedChanges: updatedMessage.proposedChanges,
});
}
router.back();
} catch (error) {
console.error("Failed to update proposal:", error);
}
return;
}
// Calendar mode: call API
try {
if (id) {
await EventService.update(id, eventObject);
} else {
await EventService.create(eventObject);
}
router.back();
} catch (error) {
console.error("Creating/Updating event failed!", error);
}
};
const getButtonText = () => {
if (mode === "chat") {
return "Fertig";
}
return id ? "Aktualisiere Termin" : "Erstelle neuen Termin";
};
return (
<BaseBackground>
<EditEventHeader id={id} mode={mode} />
<View className="h-full flex items-center">
{/* Date and Time */}
<View className="w-11/12">
<EditEventTextField
className="h-16 mt-2"
titel="Titel"
text={title}
onValueChange={setTitle}
/>
<PickerRow
title="Von"
dateValue={startDate}
onDateChange={handleStartDateChange}
onTimeChange={handleStartTimeChange}
showLabels
/>
<PickerRow
title="Bis"
dateValue={endDate}
onDateChange={handleEndDateChange}
onTimeChange={handleEndTimeChange}
/>
{/* TODO: Reminder */}
{/* Notes */}
<EditEventTextField
className="h-64 mt-6"
titel="Notizen"
text={description}
onValueChange={setDescription}
multiline
/>
{/* Repeat Toggle Button */}
<Pressable
className="flex flex-row w-1/3 h-10 mt-4 rounded-lg items-center justify-evenly"
style={{
backgroundColor: repeatVisible
? theme.chatBot
: theme.secondaryBg,
borderWidth: 1,
borderColor: theme.borderPrimary,
}}
onPress={() => setRepeatVisible(!repeatVisible)}
>
<Ionicons
name="repeat"
size={24}
color={repeatVisible ? theme.buttonText : theme.textPrimary}
/>
<Text
style={{
color: repeatVisible ? theme.buttonText : theme.textPrimary,
}}
>
Wiederholen
</Text>
</Pressable>
{/* Repeat Selector (shown when toggle is active) */}
{repeatVisible && (
<RepeatSelector
repeatCount={repeatCount}
onRepeatCountChange={setRepeatCount}
repeatType={repeatType}
onRepeatTypeChange={setRepeatType}
/>
)}
</View>
</View>
{/* Send new or updated Event */}
<View className="absolute bottom-16 w-full h-16">
<Pressable
className="flex flex-row justify-center items-center py-3"
onPress={handleSave}
style={{
backgroundColor: theme.confirmButton,
}}
>
{mode !== "chat" && (
<Ionicons name="add-outline" size={24} color={theme.buttonText} />
)}
<Text
style={{ color: theme.buttonText }}
className="font-semibold ml-1"
>
{getButtonText()}
</Text>
</Pressable>
</View>
</BaseBackground>
);
};
export default EditEventScreen;

View File

@@ -1,9 +1,11 @@
import { View, Text, TextInput, Pressable } from "react-native"; import { View, Text, TextInput, Pressable } from "react-native";
import { useLocalSearchParams } from "expo-router"; import { useLocalSearchParams } from "expo-router";
import BaseBackground from "../../components/BaseBackground"; import BaseBackground from "../../components/BaseBackground";
import { useThemeStore } from "../../stores/ThemeStore";
const EventDetailScreen = () => { const EventDetailScreen = () => {
const { id } = useLocalSearchParams<{ id: string }>(); const { id } = useLocalSearchParams<{ id: string }>();
const { theme } = useThemeStore();
// TODO: Fetch event by id using EventService.getById() // TODO: Fetch event by id using EventService.getById()
// TODO: Display event details (title, description, start/end time) // TODO: Display event details (title, description, start/end time)
@@ -17,23 +19,49 @@ const EventDetailScreen = () => {
return ( return (
<BaseBackground> <BaseBackground>
<View className="flex-1 p-4"> <View className="flex-1 p-4">
<Text className="text-2xl mb-4">Event Detail</Text> <Text className="text-2xl mb-4" style={{ color: theme.textPrimary }}>
<Text className="text-gray-500 mb-4">ID: {id}</Text> Event Detail
</Text>
<Text className="mb-4" style={{ color: theme.textSecondary }}>
ID: {id}
</Text>
<TextInput <TextInput
placeholder="Title" placeholder="Title"
placeholderTextColor={theme.textMuted}
className="w-full border rounded p-2 mb-4" className="w-full border rounded p-2 mb-4"
style={{
color: theme.textPrimary,
borderColor: theme.borderPrimary,
backgroundColor: theme.secondaryBg,
}}
/> />
<TextInput <TextInput
placeholder="Description" placeholder="Description"
placeholderTextColor={theme.textMuted}
multiline multiline
className="w-full border rounded p-2 mb-4 h-24" className="w-full border rounded p-2 mb-4 h-24"
style={{
color: theme.textPrimary,
borderColor: theme.borderPrimary,
backgroundColor: theme.secondaryBg,
}}
/> />
<View className="flex-row gap-2"> <View className="flex-row gap-2">
<Pressable className="bg-blue-500 p-3 rounded flex-1"> <Pressable
<Text className="text-white text-center">Save</Text> className="p-3 rounded flex-1"
style={{ backgroundColor: theme.confirmButton }}
>
<Text className="text-center" style={{ color: theme.buttonText }}>
Save
</Text>
</Pressable> </Pressable>
<Pressable className="bg-red-500 p-3 rounded flex-1"> <Pressable
<Text className="text-white text-center">Delete</Text> className="p-3 rounded flex-1"
style={{ backgroundColor: theme.rejectButton }}
>
<Text className="text-center" style={{ color: theme.buttonText }}>
Delete
</Text>
</Pressable> </Pressable>
</View> </View>
</View> </View>

View File

@@ -1,34 +1,6 @@
import { useEffect } from "react";
import { View, ActivityIndicator } from "react-native";
import { Redirect } from "expo-router"; import { Redirect } from "expo-router";
import { useAuthStore } from "../stores";
import currentTheme from "../Themes";
export default function Index() { export default function Index() {
const { isAuthenticated, isLoading, loadStoredUser } = useAuthStore(); // AuthGuard in (tabs)/_layout.tsx handles authentication
useEffect(() => {
loadStoredUser();
}, [loadStoredUser]);
if (isLoading) {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: currentTheme.primeBg,
}}
>
<ActivityIndicator size="large" color={currentTheme.chatBot} />
</View>
);
}
if (isAuthenticated) {
return <Redirect href="/(tabs)/chat" />; return <Redirect href="/(tabs)/chat" />;
} }
return <Redirect href="/login" />;
}

View File

@@ -1,12 +1,16 @@
import { useState } from "react"; import { useState } from "react";
import { View, Text, TextInput, Pressable } from "react-native"; import { View, Text, Pressable } from "react-native";
import { Link, router } from "expo-router"; import { Link, router } from "expo-router";
import BaseBackground from "../components/BaseBackground"; import BaseBackground from "../components/BaseBackground";
import AuthButton from "../components/AuthButton"; import AuthButton from "../components/AuthButton";
import CustomTextInput from "../components/CustomTextInput";
import { AuthService } from "../services"; import { AuthService } from "../services";
import currentTheme from "../Themes"; import { CaldavConfigService } from "../services/CaldavConfigService";
import { preloadAppData } from "../components/AuthGuard";
import { useThemeStore } from "../stores/ThemeStore";
const LoginScreen = () => { const LoginScreen = () => {
const { theme } = useThemeStore();
const [identifier, setIdentifier] = useState(""); const [identifier, setIdentifier] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -23,6 +27,12 @@ const LoginScreen = () => {
setIsLoading(true); setIsLoading(true);
try { try {
await AuthService.login({ identifier, password }); await AuthService.login({ identifier, password });
await preloadAppData();
try {
await CaldavConfigService.sync();
} catch {
// No CalDAV config or sync failed — not critical
}
router.replace("/(tabs)/chat"); router.replace("/(tabs)/chat");
} catch { } catch {
setError("Anmeldung fehlgeschlagen. Überprüfe deine Zugangsdaten."); setError("Anmeldung fehlgeschlagen. Überprüfe deine Zugangsdaten.");
@@ -35,49 +45,45 @@ const LoginScreen = () => {
<BaseBackground> <BaseBackground>
<View className="flex-1 justify-center items-center p-8"> <View className="flex-1 justify-center items-center p-8">
<Text <Text
testID="login-title"
className="text-3xl font-bold mb-8" className="text-3xl font-bold mb-8"
style={{ color: currentTheme.textPrimary }} style={{ color: theme.textPrimary }}
> >
Anmelden Anmelden
</Text> </Text>
{error && ( {error && (
<Text className="mb-4 text-center" style={{ color: currentTheme.rejectButton }}> <Text
testID="login-error-text"
className="mb-4 text-center"
style={{ color: theme.rejectButton }}
>
{error} {error}
</Text> </Text>
)} )}
<TextInput <CustomTextInput
testID="login-identifier-input"
placeholder="E-Mail oder Benutzername" placeholder="E-Mail oder Benutzername"
placeholderTextColor={currentTheme.textMuted} placeholderTextColor={theme.textMuted}
value={identifier} text={identifier}
onChangeText={setIdentifier} onValueChange={setIdentifier}
autoCapitalize="none" autoCapitalize="none"
className="w-full rounded-lg p-4 mb-4" className="w-full rounded-lg p-4 mb-4"
style={{
backgroundColor: currentTheme.secondaryBg,
color: currentTheme.textPrimary,
borderWidth: 1,
borderColor: currentTheme.borderPrimary,
}}
/> />
<TextInput <CustomTextInput
testID="login-password-input"
placeholder="Passwort" placeholder="Passwort"
placeholderTextColor={currentTheme.textMuted} placeholderTextColor={theme.textMuted}
value={password} text={password}
onChangeText={setPassword} onValueChange={setPassword}
secureTextEntry secureTextEntry
className="w-full rounded-lg p-4 mb-6" className="w-full rounded-lg p-4 mb-6"
style={{
backgroundColor: currentTheme.secondaryBg,
color: currentTheme.textPrimary,
borderWidth: 1,
borderColor: currentTheme.borderPrimary,
}}
/> />
<AuthButton <AuthButton
testID="login-button"
title="Anmelden" title="Anmelden"
onPress={handleLogin} onPress={handleLogin}
isLoading={isLoading} isLoading={isLoading}
@@ -85,7 +91,7 @@ const LoginScreen = () => {
<Link href="/register" asChild> <Link href="/register" asChild>
<Pressable> <Pressable>
<Text style={{ color: currentTheme.chatBot }}> <Text style={{ color: theme.chatBot }}>
Noch kein Konto? Registrieren Noch kein Konto? Registrieren
</Text> </Text>
</Pressable> </Pressable>

View File

@@ -1,9 +1,11 @@
import { View, Text, TextInput, Pressable } from "react-native"; import { View, Text, TextInput, Pressable } from "react-native";
import { useLocalSearchParams } from "expo-router"; import { useLocalSearchParams } from "expo-router";
import BaseBackground from "../../components/BaseBackground"; import BaseBackground from "../../components/BaseBackground";
import { useThemeStore } from "../../stores/ThemeStore";
const NoteScreen = () => { const NoteScreen = () => {
const { id } = useLocalSearchParams<{ id: string }>(); const { id } = useLocalSearchParams<{ id: string }>();
const { theme } = useThemeStore();
// TODO: Fetch event by id using EventService.getById() // TODO: Fetch event by id using EventService.getById()
// TODO: Display and edit the event's note field // TODO: Display and edit the event's note field
@@ -15,16 +17,31 @@ const NoteScreen = () => {
return ( return (
<BaseBackground> <BaseBackground>
<View className="flex-1 p-4"> <View className="flex-1 p-4">
<Text className="text-2xl mb-4">Note</Text> <Text className="text-2xl mb-4" style={{ color: theme.textPrimary }}>
<Text className="text-gray-500 mb-4">Event ID: {id}</Text> Note
</Text>
<Text className="mb-4" style={{ color: theme.textSecondary }}>
Event ID: {id}
</Text>
<TextInput <TextInput
placeholder="Write your note here..." placeholder="Write your note here..."
placeholderTextColor={theme.textMuted}
multiline multiline
className="w-full border rounded p-2 flex-1 mb-4" className="w-full border rounded p-2 flex-1 mb-4"
textAlignVertical="top" textAlignVertical="top"
style={{
color: theme.textPrimary,
borderColor: theme.borderPrimary,
backgroundColor: theme.secondaryBg,
}}
/> />
<Pressable className="bg-blue-500 p-3 rounded"> <Pressable
<Text className="text-white text-center">Save Note</Text> className="p-3 rounded"
style={{ backgroundColor: theme.confirmButton }}
>
<Text className="text-center" style={{ color: theme.buttonText }}>
Save Note
</Text>
</Pressable> </Pressable>
</View> </View>
</BaseBackground> </BaseBackground>

View File

@@ -1,14 +1,16 @@
import { useState } from "react"; import { useState } from "react";
import { View, Text, TextInput, Pressable } from "react-native"; import { View, Text, Pressable } from "react-native";
import { Link, router } from "expo-router"; import { Link, router } from "expo-router";
import BaseBackground from "../components/BaseBackground"; import BaseBackground from "../components/BaseBackground";
import AuthButton from "../components/AuthButton"; import AuthButton from "../components/AuthButton";
import CustomTextInput from "../components/CustomTextInput";
import { AuthService } from "../services"; import { AuthService } from "../services";
import currentTheme from "../Themes"; import { useThemeStore } from "../stores/ThemeStore";
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const RegisterScreen = () => { const RegisterScreen = () => {
const { theme } = useThemeStore();
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [userName, setUserName] = useState(""); const [userName, setUserName] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
@@ -44,61 +46,46 @@ const RegisterScreen = () => {
<View className="flex-1 justify-center items-center p-8"> <View className="flex-1 justify-center items-center p-8">
<Text <Text
className="text-3xl font-bold mb-8" className="text-3xl font-bold mb-8"
style={{ color: currentTheme.textPrimary }} style={{ color: theme.textPrimary }}
> >
Registrieren Registrieren
</Text> </Text>
{error && ( {error && (
<Text className="mb-4 text-center" style={{ color: currentTheme.rejectButton }}> <Text
className="mb-4 text-center"
style={{ color: theme.rejectButton }}
>
{error} {error}
</Text> </Text>
)} )}
<TextInput <CustomTextInput
placeholder="E-Mail" placeholder="E-Mail"
placeholderTextColor={currentTheme.textMuted} placeholderTextColor={theme.textMuted}
value={email} text={email}
onChangeText={setEmail} onValueChange={setEmail}
autoCapitalize="none" autoCapitalize="none"
keyboardType="email-address" keyboardType="email-address"
className="w-full rounded-lg p-4 mb-4" className="w-full rounded-lg p-4 mb-4"
style={{
backgroundColor: currentTheme.secondaryBg,
color: currentTheme.textPrimary,
borderWidth: 1,
borderColor: currentTheme.borderPrimary,
}}
/> />
<TextInput <CustomTextInput
placeholder="Benutzername" placeholder="Benutzername"
placeholderTextColor={currentTheme.textMuted} placeholderTextColor={theme.textMuted}
value={userName} text={userName}
onChangeText={setUserName} onValueChange={setUserName}
autoCapitalize="none" autoCapitalize="none"
className="w-full rounded-lg p-4 mb-4" className="w-full rounded-lg p-4 mb-4"
style={{
backgroundColor: currentTheme.secondaryBg,
color: currentTheme.textPrimary,
borderWidth: 1,
borderColor: currentTheme.borderPrimary,
}}
/> />
<TextInput <CustomTextInput
placeholder="Passwort" placeholder="Passwort"
placeholderTextColor={currentTheme.textMuted} placeholderTextColor={theme.textMuted}
value={password} text={password}
onChangeText={setPassword} onValueChange={setPassword}
secureTextEntry secureTextEntry
className="w-full rounded-lg p-4 mb-6" className="w-full rounded-lg p-4 mb-6"
style={{
backgroundColor: currentTheme.secondaryBg,
color: currentTheme.textPrimary,
borderWidth: 1,
borderColor: currentTheme.borderPrimary,
}}
/> />
<AuthButton <AuthButton
@@ -109,7 +96,7 @@ const RegisterScreen = () => {
<Link href="/login" asChild> <Link href="/login" asChild>
<Pressable> <Pressable>
<Text style={{ color: currentTheme.chatBot }}> <Text style={{ color: theme.chatBot }}>
Bereits ein Konto? Anmelden Bereits ein Konto? Anmelden
</Text> </Text>
</Pressable> </Pressable>

View File

@@ -1,23 +1,29 @@
import { Pressable, Text, ActivityIndicator } from "react-native"; import { Pressable, Text, ActivityIndicator } from "react-native";
import currentTheme from "../Themes"; import { useThemeStore } from "../stores/ThemeStore";
interface AuthButtonProps { interface AuthButtonProps {
title: string; title: string;
onPress: () => void; onPress: () => void;
isLoading?: boolean; isLoading?: boolean;
testID?: string;
} }
const AuthButton = ({ title, onPress, isLoading = false }: AuthButtonProps) => { const AuthButton = ({
title,
onPress,
isLoading = false,
testID,
}: AuthButtonProps) => {
const { theme } = useThemeStore();
return ( return (
<Pressable <Pressable
testID={testID}
onPress={onPress} onPress={onPress}
disabled={isLoading} disabled={isLoading}
className="w-full rounded-lg p-4 mb-4 border-4" className="w-full rounded-lg p-4 mb-4 border-4"
style={{ style={{
backgroundColor: isLoading backgroundColor: isLoading ? theme.disabledButton : theme.chatBot,
? currentTheme.disabledButton shadowColor: theme.shadowColor,
: currentTheme.chatBot,
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 }, shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25, shadowOpacity: 0.25,
shadowRadius: 3.84, shadowRadius: 3.84,
@@ -25,11 +31,11 @@ const AuthButton = ({ title, onPress, isLoading = false }: AuthButtonProps) => {
}} }}
> >
{isLoading ? ( {isLoading ? (
<ActivityIndicator color={currentTheme.buttonText} /> <ActivityIndicator color={theme.buttonText} />
) : ( ) : (
<Text <Text
className="text-center font-semibold text-lg" className="text-center font-semibold text-lg"
style={{ color: currentTheme.buttonText }} style={{ color: theme.buttonText }}
> >
{title} {title}
</Text> </Text>

View File

@@ -0,0 +1,89 @@
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
*/
export const AuthGuard = ({ children }: AuthGuardProps) => {
const { theme } = useThemeStore();
const { isAuthenticated, isLoading, loadStoredUser } = useAuthStore();
const [dataReady, setDataReady] = useState(false);
useEffect(() => {
const init = async () => {
await loadStoredUser();
if (!useAuthStore.getState().isAuthenticated) return;
await preloadAppData();
setDataReady(true);
};
init();
}, [loadStoredUser]);
if (isLoading || (isAuthenticated && !dataReady)) {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: theme.primeBg,
}}
>
<ActivityIndicator size="large" color={theme.chatBot} />
</View>
);
}
if (!isAuthenticated) {
return <Redirect href="/login" />;
}
return <>{children}</>;
};

View File

@@ -1,5 +1,5 @@
import { View } from "react-native"; import { View } from "react-native";
import currentTheme from "../Themes"; import { useThemeStore } from "../stores/ThemeStore";
import { ReactNode } from "react"; import { ReactNode } from "react";
type BaseBackgroundProps = { type BaseBackgroundProps = {
@@ -8,11 +8,12 @@ type BaseBackgroundProps = {
}; };
const BaseBackground = (props: BaseBackgroundProps) => { const BaseBackground = (props: BaseBackgroundProps) => {
const { theme } = useThemeStore();
return ( return (
<View <View
className={`h-full ${props.className}`} className={`h-full ${props.className}`}
style={{ style={{
backgroundColor: currentTheme.primeBg, backgroundColor: theme.primeBg,
}} }}
> >
{props.children} {props.children}

View File

@@ -0,0 +1,46 @@
import { Pressable, Text } from "react-native";
import { useThemeStore } from "../stores/ThemeStore";
import { ReactNode } from "react";
export type BaseButtonProps = {
children?: ReactNode;
className?: string;
onPress: () => void;
solid?: boolean;
testID?: string;
};
const BaseButton = ({
className,
children,
onPress,
solid = false,
testID,
}: BaseButtonProps) => {
const { theme } = useThemeStore();
return (
<Pressable
testID={testID}
className={`rounded-lg p-4 mb-4 border-4 ${className}`}
onPress={onPress}
style={{
borderColor: theme.borderPrimary,
backgroundColor: solid ? theme.chatBot : theme.primeBg,
shadowColor: theme.shadowColor,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 3.84,
elevation: 5,
}}
>
<Text
className="text-center font-semibold text-lg"
style={{ color: theme.buttonText }}
>
{children}
</Text>
</Pressable>
);
};
export default BaseButton;

View File

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

View File

@@ -0,0 +1,40 @@
import { View, ViewStyle } from "react-native";
import { useThemeStore } from "../stores/ThemeStore";
type BubbleSide = "left" | "right";
type ChatBubbleProps = {
side: BubbleSide;
children: React.ReactNode;
className?: string;
style?: ViewStyle;
testID?: string;
};
export function ChatBubble({
side,
children,
className = "",
style,
testID,
}: ChatBubbleProps) {
const { theme } = useThemeStore();
const borderColor = side === "left" ? theme.chatBot : theme.primeFg;
const sideClass =
side === "left"
? "self-start ml-2 rounded-bl-sm"
: "self-end mr-2 rounded-br-sm";
return (
<View
testID={testID}
className={`border-2 border-solid rounded-xl my-2 ${sideClass} ${className}`}
style={[
{ borderColor, elevation: 8, backgroundColor: theme.secondaryBg },
style,
]}
>
{children}
</View>
);
}

View File

@@ -0,0 +1,48 @@
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"];
testID?: string;
};
const CustomTextInput = (props: CustomTextInputProps) => {
const { theme } = useThemeStore();
const [focused, setFocused] = useState(props.focused ?? false);
return (
<TextInput
testID={props.testID}
className={`border border-solid rounded-2xl ${props.className}`}
onChangeText={props.onValueChange}
value={props.text}
multiline={props.multiline}
placeholder={props.placeholder}
placeholderTextColor={props.placeholderTextColor}
secureTextEntry={props.secureTextEntry}
autoCapitalize={props.autoCapitalize}
keyboardType={props.keyboardType}
selection={!focused ? { start: 0 } : undefined}
style={{
backgroundColor: theme.messageBorderBg,
color: theme.textPrimary,
textAlignVertical: "top",
borderColor: focused ? theme.chatBot : theme.borderPrimary,
}}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
/>
);
};
export default CustomTextInput;

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { View, Text, Modal, Pressable } from "react-native"; import { View, Text, Modal, Pressable } from "react-native";
import { CreateEventDTO } from "@calchat/shared"; import { CreateEventDTO } from "@calchat/shared";
import { useThemeStore } from "../stores/ThemeStore";
type EventConfirmDialogProps = { type EventConfirmDialogProps = {
visible: boolean; visible: boolean;
@@ -16,6 +17,8 @@ const EventConfirmDialog = ({
onReject: _onReject, onReject: _onReject,
onClose: _onClose, onClose: _onClose,
}: EventConfirmDialogProps) => { }: EventConfirmDialogProps) => {
const { theme } = useThemeStore();
// TODO: Display proposed event details (title, time, description) // TODO: Display proposed event details (title, time, description)
// TODO: Confirm button calls onConfirm and closes dialog // TODO: Confirm button calls onConfirm and closes dialog
// TODO: Reject button calls onReject and closes dialog // TODO: Reject button calls onReject and closes dialog
@@ -26,7 +29,9 @@ const EventConfirmDialog = ({
<Modal visible={false} transparent animationType="fade"> <Modal visible={false} transparent animationType="fade">
<View> <View>
<Pressable> <Pressable>
<Text>EventConfirmDialog - Not Implemented</Text> <Text style={{ color: theme.textPrimary }}>
EventConfirmDialog - Not Implemented
</Text>
</Pressable> </Pressable>
</View> </View>
</Modal> </Modal>

View File

@@ -1,42 +1,29 @@
import { View, Pressable } from "react-native"; import { View, Text, Pressable } from "react-native";
import { useThemeStore } from "../stores/ThemeStore";
import { ComponentProps, ReactNode } from "react";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import currentTheme from "../Themes";
import { ReactNode } from "react";
import { AuthService } from "../services";
type HeaderProps = { type HeaderProps = {
children?: ReactNode; children?: ReactNode;
className?: string; className?: string;
}; };
const handleLogout = async () => {
await AuthService.logout();
router.replace("/login");
};
const Header = (props: HeaderProps) => { const Header = (props: HeaderProps) => {
const { theme } = useThemeStore();
return ( return (
<View> <View>
<View <View
className={`w-full h-32 pt-10 pb-4 ${props.className}`} className={`w-full h-32 pt-10 pb-4 ${props.className}`}
style={{ style={{
backgroundColor: currentTheme.chatBot, backgroundColor: theme.chatBot,
}} }}
> >
{props.children} {props.children}
<Pressable
onPress={handleLogout}
className="absolute left-1 bottom-0 p-2"
hitSlop={8}
>
<Ionicons name="log-out-outline" size={24} color={currentTheme.primeFg} />
</Pressable>
</View> </View>
<View <View
className="h-2 bg-black" className="h-2 bg-black"
style={{ style={{
shadowColor: "#000", shadowColor: theme.shadowColor,
shadowOffset: { shadowOffset: {
width: 0, width: 0,
height: 5, height: 5,
@@ -51,4 +38,54 @@ const Header = (props: HeaderProps) => {
); );
}; };
type HeaderButton = {
className?: string;
iconName: ComponentProps<typeof Ionicons>["name"];
iconSize: number;
onPress?: () => void;
};
export const HeaderButton = (props: HeaderButton) => {
const { theme } = useThemeStore();
return (
<Pressable
onPress={props.onPress}
className={
"w-16 h-16 flex items-center justify-center mx-2 rounded-xl border border-solid absolute left-6 " +
props.className
}
style={{
backgroundColor: theme.chatBot,
borderColor: theme.primeFg,
// iOS shadow
shadowColor: theme.shadowColor,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
// Android shadow
elevation: 6,
}}
>
<Ionicons
name={props.iconName}
size={props.iconSize}
color={theme.buttonText}
/>
</Pressable>
);
};
type SimpleHeaderProps = {
text: string;
};
export const SimpleHeader = ({ text }: SimpleHeaderProps) => (
<Header>
<View className="h-full flex justify-center">
<Text className="text-center text-3xl font-bold">{text}</Text>
</View>
</Header>
);
export default Header; export default Header;

View File

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

View File

@@ -1,70 +1,108 @@
import { View, Text, Pressable } from "react-native"; import { View, Text, Pressable } from "react-native";
import { ProposedEventChange } from "@calchat/shared"; import { Feather, Ionicons } from "@expo/vector-icons";
import currentTheme from "../Themes"; import { ProposedEventChange, formatDate, formatTime } from "@calchat/shared";
import { rrulestr } from "rrule";
import { useThemeStore } from "../stores/ThemeStore";
import { EventCardBase } from "./EventCardBase"; import { EventCardBase } from "./EventCardBase";
type ProposedEventCardProps = { type ProposedEventCardProps = {
proposedChange: ProposedEventChange; proposedChange: ProposedEventChange;
onConfirm: () => void; onConfirm: (proposal: ProposedEventChange) => void;
onReject: () => void; onReject: () => void;
onEdit?: (proposal: ProposedEventChange) => void;
}; };
const ConfirmRejectButtons = ({ const ActionButtons = ({
isDisabled, isDisabled,
respondedAction, respondedAction,
showEdit,
onConfirm, onConfirm,
onReject, onReject,
onEdit,
}: { }: {
isDisabled: boolean; isDisabled: boolean;
respondedAction?: "confirm" | "reject"; respondedAction?: "confirm" | "reject";
showEdit: boolean;
onConfirm: () => void; onConfirm: () => void;
onReject: () => void; onReject: () => void;
}) => ( onEdit?: () => void;
}) => {
const { theme } = useThemeStore();
return (
<View className="flex-row mt-3 gap-2"> <View className="flex-row mt-3 gap-2">
<Pressable <Pressable
testID="event-accept-button"
onPress={onConfirm} onPress={onConfirm}
disabled={isDisabled} disabled={isDisabled}
className="flex-1 py-2 rounded-lg items-center" className="flex-1 py-2 rounded-lg items-center"
style={{ style={{
backgroundColor: isDisabled backgroundColor: isDisabled
? currentTheme.disabledButton ? theme.disabledButton
: currentTheme.confirmButton, : theme.confirmButton,
borderWidth: respondedAction === "confirm" ? 2 : 0, borderWidth: respondedAction === "confirm" ? 2 : 0,
borderColor: currentTheme.confirmButton, borderColor: theme.confirmButton,
}} }}
> >
<Text style={{ color: currentTheme.buttonText }} className="font-medium"> <Text style={{ color: theme.buttonText }} className="font-medium">
Annehmen Annehmen
</Text> </Text>
</Pressable> </Pressable>
<Pressable <Pressable
testID="event-reject-button"
onPress={onReject} onPress={onReject}
disabled={isDisabled} disabled={isDisabled}
className="flex-1 py-2 rounded-lg items-center" className="flex-1 py-2 rounded-lg items-center"
style={{ style={{
backgroundColor: isDisabled backgroundColor: isDisabled
? currentTheme.disabledButton ? theme.disabledButton
: currentTheme.rejectButton, : theme.rejectButton,
borderWidth: respondedAction === "reject" ? 2 : 0, borderWidth: respondedAction === "reject" ? 2 : 0,
borderColor: currentTheme.rejectButton, borderColor: theme.rejectButton,
}} }}
> >
<Text style={{ color: currentTheme.buttonText }} className="font-medium"> <Text style={{ color: theme.buttonText }} className="font-medium">
Ablehnen Ablehnen
</Text> </Text>
</Pressable> </Pressable>
{showEdit && onEdit && (
<Pressable
onPress={onEdit}
className="py-2 px-3 rounded-lg items-center"
style={{
backgroundColor: theme.secondaryBg,
borderWidth: 1,
borderColor: theme.borderPrimary,
}}
>
<Feather name="edit-2" size={18} color={theme.textPrimary} />
</Pressable>
)}
</View> </View>
); );
};
export const ProposedEventCard = ({ export const ProposedEventCard = ({
proposedChange, proposedChange,
onConfirm, onConfirm,
onReject, onReject,
onEdit,
}: ProposedEventCardProps) => { }: ProposedEventCardProps) => {
const { theme } = useThemeStore();
const event = proposedChange.event; const event = proposedChange.event;
// respondedAction is now part of the proposedChange
const isDisabled = !!proposedChange.respondedAction; const isDisabled = !!proposedChange.respondedAction;
// For delete/single action, the occurrenceDate becomes a new exception
const newExceptionDate =
proposedChange.action === "delete" &&
proposedChange.deleteMode === "single" &&
proposedChange.occurrenceDate;
// For update actions, check if a new UNTIL date is being set
const newUntilDate =
proposedChange.action === "update" &&
event?.recurrenceRule &&
rrulestr(event.recurrenceRule).options.until;
if (!event) { if (!event) {
return null; return null;
} }
@@ -77,13 +115,73 @@ export const ProposedEventCard = ({
startTime={event.startTime} startTime={event.startTime}
endTime={event.endTime} endTime={event.endTime}
description={event.description} description={event.description}
isRecurring={event.isRecurring} recurrenceRule={event.recurrenceRule}
> >
<ConfirmRejectButtons {/* Show new exception date for delete/single actions */}
{newExceptionDate && (
<View className="flex-row items-center mb-2">
<Feather
name="plus-circle"
size={16}
color={theme.confirmButton}
style={{ marginRight: 8 }}
/>
<Text
style={{ color: theme.confirmButton }}
className="font-medium"
>
Neue Ausnahme:{" "}
{formatDate(new Date(proposedChange.occurrenceDate!))}
</Text>
</View>
)}
{/* Show new UNTIL date for update actions */}
{newUntilDate && (
<View className="flex-row items-center mb-2">
<Feather
name="plus-circle"
size={16}
color={theme.confirmButton}
style={{ marginRight: 8 }}
/>
<Text
style={{ color: theme.confirmButton }}
className="font-medium"
>
Neues Ende: {formatDate(newUntilDate)}
</Text>
</View>
)}
{/* Show conflicting events warning */}
{proposedChange.conflictingEvents &&
proposedChange.conflictingEvents.length > 0 && (
<View className="mb-2">
{proposedChange.conflictingEvents.map((conflict, index) => (
<View key={index} className="flex-row items-center mt-1">
<Ionicons
name="alert-circle"
size={16}
color={theme.rejectButton}
style={{ marginRight: 8 }}
/>
<Text
style={{ color: theme.rejectButton }}
className="text-sm flex-1"
>
Konflikt: {conflict.title} ({formatTime(conflict.startTime)}{" "}
- {formatTime(conflict.endTime)})
</Text>
</View>
))}
</View>
)}
<ActionButtons
isDisabled={isDisabled} isDisabled={isDisabled}
respondedAction={proposedChange.respondedAction} respondedAction={proposedChange.respondedAction}
onConfirm={onConfirm} showEdit={proposedChange.action !== "delete" && !isDisabled}
onConfirm={() => onConfirm(proposedChange)}
onReject={onReject} onReject={onReject}
onEdit={onEdit ? () => onEdit(proposedChange) : undefined}
/> />
</EventCardBase> </EventCardBase>
</View> </View>

View File

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

View File

@@ -0,0 +1,31 @@
import { useEffect, useState } from "react";
import { Text } from "react-native";
import { useThemeStore } from "../stores/ThemeStore";
import { ChatBubble } from "./ChatBubble";
const DOTS = [".", "..", "..."];
const INTERVAL_MS = 400;
export default function TypingIndicator() {
const { theme } = useThemeStore();
const [dotIndex, setDotIndex] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setDotIndex((prev) => (prev + 1) % DOTS.length);
}, INTERVAL_MS);
return () => clearInterval(interval);
}, []);
return (
<ChatBubble side="left" className="px-4 py-2">
<Text
className="text-lg font-bold tracking-widest"
style={{ color: theme.textMuted }}
>
{DOTS[dotIndex]}
</Text>
</ChatBubble>
);
}

View File

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

View File

@@ -50,12 +50,21 @@ async function request<T>(
const duration = Math.round(performance.now() - start); const duration = Math.round(performance.now() - start);
if (!response.ok) { if (!response.ok) {
apiLogger.error(`${method} ${endpoint} - ${response.status} (${duration}ms)`); apiLogger.error(
`${method} ${endpoint} - ${response.status} (${duration}ms)`,
);
throw new Error(`HTTP ${response.status}`); throw new Error(`HTTP ${response.status}`);
} }
apiLogger.debug(`${method} ${endpoint} - ${response.status} (${duration}ms)`); apiLogger.debug(
return response.json(); `${method} ${endpoint} - ${response.status} (${duration}ms)`,
);
const text = await response.text();
if (!text) {
return undefined as T;
}
return JSON.parse(text);
} catch (error) { } catch (error) {
const duration = Math.round(performance.now() - start); const duration = Math.round(performance.now() - start);
apiLogger.error(`${method} ${endpoint} failed (${duration}ms): ${error}`); apiLogger.error(`${method} ${endpoint} failed (${duration}ms): ${error}`);

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,14 +13,17 @@ export type MessageData = {
interface ChatState { interface ChatState {
messages: MessageData[]; messages: MessageData[];
isWaitingForResponse: boolean;
addMessages: (messages: MessageData[]) => void; addMessages: (messages: MessageData[]) => void;
addMessage: (message: MessageData) => void; addMessage: (message: MessageData) => void;
updateMessage: (id: string, updates: Partial<MessageData>) => void; updateMessage: (id: string, updates: Partial<MessageData>) => void;
clearMessages: () => void; clearMessages: () => void;
setWaitingForResponse: (waiting: boolean) => void;
} }
export const useChatStore = create<ChatState>((set) => ({ export const useChatStore = create<ChatState>((set) => ({
messages: [], messages: [],
isWaitingForResponse: false,
addMessages(messages) { addMessages(messages) {
set((state) => ({ messages: [...state.messages, ...messages] })); set((state) => ({ messages: [...state.messages, ...messages] }));
}, },
@@ -37,6 +40,9 @@ export const useChatStore = create<ChatState>((set) => ({
clearMessages: () => { clearMessages: () => {
set({ messages: [] }); set({ messages: [] });
}, },
setWaitingForResponse: (waiting: boolean) => {
set({ isWaitingForResponse: waiting });
},
})); }));
// Helper to convert server ChatMessage to client MessageData // Helper to convert server ChatMessage to client MessageData

View File

@@ -0,0 +1,12 @@
import { create } from "zustand";
import { Theme, THEMES } from "../Themes";
interface ThemeState {
theme: Theme;
setTheme: (themeName: keyof typeof THEMES) => void;
}
export const useThemeStore = create<ThemeState>((set) => ({
theme: THEMES.defaultLight,
setTheme: (themeName) => set({ theme: THEMES[themeName] }),
}));

View File

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

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

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

View File

@@ -0,0 +1,30 @@
FROM node:alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
COPY packages/shared/ ./packages/shared/
COPY apps/server/package.json ./apps/server/
RUN npm ci -w @calchat/server -w @calchat/shared --include-workspace-root
COPY apps/server/ apps/server/
RUN 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 --ignore-scripts -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,43 @@
services:
mongo:
image: mongo:8
restart: always
ports:
- "27017:27017"
environment:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: mongoose
volumes:
- mongo-data:/data/db
healthcheck:
test: mongosh --eval "db.adminCommand('ping')"
interval: 10s
timeout: 5s
retries: 5
mongo-express:
image: mongo-express:latest
restart: always
ports:
- "8083:8081"
environment:
ME_CONFIG_MONGODB_URL: mongodb://root:mongoose@mongo:27017/
ME_CONFIG_BASICAUTH_ENABLED: true
ME_CONFIG_BASICAUTH_USERNAME: admin
ME_CONFIG_BASICAUTH_PASSWORD: admin
depends_on:
mongo:
condition: service_healthy
calchat-server:
image: gitea.gilmour109.de/gilmour109/calchat-server:latest
restart: always
env_file: .env
ports:
- "3001:3001"
depends_on:
mongo:
condition: service_healthy
volumes:
mongo-data:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
{ {
"extends": "../../tsconfig.json",
"compilerOptions": { "compilerOptions": {
"target": "ES2020", "target": "ES2020",
"module": "CommonJS", "module": "CommonJS",
@@ -8,7 +7,8 @@
"esModuleInterop": true, "esModuleInterop": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"experimentalDecorators": true, "experimentalDecorators": true,
"emitDecoratorMetadata": true "emitDecoratorMetadata": true,
"skipLibCheck": true
}, },
"include": ["src"] "include": ["src"]
} }

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