Compare commits

...

132 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
489c0271c9 refactor: rename package scope from @caldav to @calchat
Rename all workspace packages to reflect the actual project name:
- @caldav/client -> @calchat/client
- @caldav/server -> @calchat/server
- @caldav/shared -> @calchat/shared
- Root package: caldav-mono -> calchat-mono

Update all import statements across client and server to use the new
package names. Update default MongoDB database name and logging service
identifier accordingly.
2026-01-12 19:46:53 +01:00
fef30d428d feat: add EAS build configuration for local APK builds
- Add eas.json with development, preview, and production profiles
- Configure preview profile for APK builds (arm64-v8a only)
- Add build:apk npm script for local builds
- Update app.json with app name and Android package
- Add expo-build-properties dependency
- Update CLAUDE.md with build documentation
2026-01-12 19:29:52 +01:00
e6b9dd9d34 feat: support multiple event proposals in single AI response
- Change proposedChange to proposedChanges array in ChatMessage type
- Add unique id and individual respondedAction to each ProposedEventChange
- Implement arrow navigation UI for multiple proposals with "Event X von Y" counter
- Add updateProposalResponse() method for per-proposal confirm/reject tracking
- GPTAdapter now collects multiple tool call results into proposals array
- Add RRULE documentation to system prompt (separate events for different times)
- Fix RRULE parsing to strip RRULE: prefix if present
- Add log summarization for large args (conversationHistory, existingEvents)
- Keep proposedChanges logged in full for debugging AI issues
2026-01-10 23:30:32 +01:00
8efe6c304e feat: implement user authentication with login and register
- Add login screen with email/username support
- Add register screen with email validation
- Implement AuthStore with expo-secure-store (native) / localStorage (web)
- Add X-User-Id header authentication (simple auth without JWT)
- Rename displayName to userName across codebase
- Add findByUserName() to UserRepository
- Check for existing email AND username on registration
- Add AuthButton component with shadow effect
- Add logout button to Header
- Add hash-password.js utility script for manual password resets
- Update CORS to allow X-User-Id header
2026-01-10 20:07:35 +01:00
71f84d1cc7 feat: implement structured logging for server and client
Server:
- Add pino and pino-http for structured logging
- Create @Logged class decorator using Proxy pattern for automatic method logging
- Add pino redact config for sensitive data (password, token, etc.)
- Move AuthMiddleware to controllers folder (per architecture diagram)
- Add LoggingMiddleware for HTTP request logging
- Replace console.log/error with structured logger in controllers and app.ts
- Decorate all repositories and GPTAdapter with @Logged

Client:
- Add react-native-logs with namespaced loggers (apiLogger, storeLogger)
- Add request/response logging to ApiClient with duration tracking
2026-01-10 16:59:40 +01:00
675785ec93 feat: replace Claude with GPT for AI chat integration
- Replace ClaudeAdapter with GPTAdapter using OpenAI GPT (gpt-5-mini)
- Implement function calling for calendar operations (getDay, proposeCreate/Update/Delete, searchEvents)
- Add provider-agnostic AI utilities in ai/utils/ (systemPrompt, toolDefinitions, toolExecutor, eventFormatter)
- Add USE_TEST_RESPONSES env var to toggle between real AI and test responses
- Switch ChatService.processMessage to use real AI provider
- Add npm run format command for Prettier
- Update CLAUDE.md with new architecture
2026-01-10 00:22:59 +01:00
c897b6d680 feat: implement chat persistence with MongoDB
- Add full chat persistence to database (conversations and messages)
- Implement MongoChatRepository with cursor-based pagination
- Add getConversations/getConversation endpoints in ChatController
- Save user and assistant messages in ChatService.processMessage()
- Track respondedAction (confirm/reject) on proposed event messages
- Load existing messages on chat screen mount
- Add addMessages() bulk action and chatMessageToMessageData() helper to ChatStore
- Add RespondedAction type and UpdateMessageDTO to shared types
2026-01-09 16:21:01 +01:00
d86b18173f feat: improve chat keyboard handling and MonthSelector memory efficiency
- Add KeyboardAvoidingView with platform-specific behavior to chat screen
- Implement auto-scroll to end on new messages and keyboard show
- Configure keyboardDismissMode and keyboardShouldPersistTaps for better UX
- Lazy-load MonthSelector data only when modal opens, clear on close
- Add .env to gitignore
2026-01-07 18:40:00 +01:00
613bafa5f5 feat: implement functional MonthSelector with infinite scroll
- Add MonthSelector dropdown with dynamic month loading
- Replace text buttons with Ionicons (chevron-back/forward/down)
- Add shadows and themed styling to navigation buttons
- Add secondaryBg color to theme for alternating list items
- Update CLAUDE.md documentation
2026-01-07 17:34:00 +01:00
8da054bbef better diagrams 2026-01-07 15:57:25 +01:00
8e58ab4249 refactor: extract shared EventCardBase component
- Create EventCardBase with common layout, icons (calendar, clock, repeat), and formatting functions
- Refactor EventCard and ProposedEventCard to use EventCardBase
- Add event details to delete action responses for better UX
- Include event title in delete confirmation message
2026-01-05 19:27:33 +01:00
24ab6f0420 move help response to first position in test responses 2026-01-05 12:55:36 +01:00
c8aba94879 updated diagrams and gitignore 2026-01-04 22:12:59 +01:00
2c0d4254ca fix API base URL for Android emulator 2026-01-04 17:48:39 +01:00
7c081787fe fix tab switching state issues
- Add useFocusEffect to calendar for automatic event reload on tab focus
- Create ChatStore (Zustand) for persistent chat messages across tab switches
- Replace local useState with store in chat screen
2026-01-04 17:40:40 +01:00
1532acab78 implement calendar event display with day indicators and overlay
- Add ExpandedEvent type to shared package for recurring event instances
- Implement EventController and EventService with full CRUD operations
- Server-side recurring event expansion via recurrenceExpander
- Calendar grid shows orange dot indicator for days with events
- Tap on day opens modal overlay with EventCards
- EventCard component with Feather icons (calendar, clock, repeat, edit, trash)
- EventsStore with Zustand for client-side event state management
- Load events for visible grid range including adjacent month days
- Add textPrimary, borderPrimary, eventIndicator to theme
- Update test responses for multiple events on Saturdays
2026-01-04 17:19:58 +01:00
e3f7a778c7 format codebase with prettier 2026-01-04 16:17:36 +01:00
77f15b6dd1 add event CRUD actions and recurring event expansion
- Implement full CRUD in MongoEventRepository (findById, findByUserId, findByDateRange, update, delete)
- Extend ChatService to handle create/update/delete actions with dynamic test responses
- Add recurrenceExpander utility using rrule library for RRULE parsing
- Add eventFormatters utility for German-localized week/month overviews
- Add German translations for days and months in shared Constants
- Update client ChatService to support all event actions (action, eventId, updates params)
2026-01-04 16:15:30 +01:00
9fecf94c7d implement event persistence and improve Mongoose TypeScript patterns
- Add event persistence: confirmed events are now saved to MongoDB
- Refactor Mongoose models to use virtuals for id field with IdVirtual interface
- Update repositories to use toJSON() with consistent type casting
- Add more test responses for chat (doctor, birthday, gym, etc.)
- Show event description in ProposedEventCard
- Change mongo-express port to 8083
- Update CLAUDE.md with Mongoose model pattern documentation
2026-01-04 11:52:05 +01:00
c33508a227 implement chat messaging with event proposals
- Add functional chat with server communication and test responses
- Add ProposedEventCard component for confirm/reject actions
- Move Constants (Day, Month) from client to shared package
- Add dateHelpers utility for weekday calculations
- Extend Themes.tsx with button and text colors
- Update CLAUDE.md with current implementation status
- Add *.tsbuildinfo to .gitignore
2026-01-04 00:01:26 +01:00
e553103470 extend chat model with CRUD actions for event changes
- Add ProposedEventChange type with create/update/delete actions
- Replace proposedEvent with proposedChange in ChatMessage
- Add currentDate to AIContext for time-aware AI responses
- Add AI test endpoint for development (/api/ai/test)
- Fix MongoUserRepository type safety with explicit toUser mapping
- Update CLAUDE.md documentation
2026-01-03 19:37:27 +01:00
105a9a4980 implement auth login and register with MongoDB
- Add AuthController login/register endpoints with error handling
- Implement AuthService with password validation and user creation
- Add MongoUserRepository with findByEmail and create methods
- Implement password hashing with bcrypt
- Add dotenv for environment variable support
- Add Docker Compose setup for MongoDB + Mongo Express
- Stub AuthMiddleware with fake user for testing
- Update CLAUDE.md with implementation status
2026-01-03 16:47:11 +01:00
9cc6d17607 implement frontend skeleton with tab navigation and service layer
- Add tab-based navigation (Chat, Calendar) using Expo-Router
- Create auth screens (login, register) as skeletons
- Add dynamic routes for event detail and note editing
- Implement service layer (ApiClient, AuthService, EventService, ChatService)
- Add Zustand stores (AuthStore, EventsStore) for state management
- Create EventCard and EventConfirmDialog components
- Update CLAUDE.md with new frontend architecture documentation
- Add Zustand and FlashList to technology stack
2026-01-03 10:47:12 +01:00
5cc1ce7f1c implement backend skeleton with MongoDB and Claude AI integration
- Add controllers (Auth, Chat, Event) with placeholder implementations
- Add services (Auth, Chat, Event) with business logic interfaces
- Add repositories with MongoDB/Mongoose models (User, Event, Chat)
- Add middleware for JWT authentication
- Add Claude AI adapter implementing AIProvider interface
- Add utility modules for JWT and password handling
- Add shared types and DTOs for User, CalendarEvent, ChatMessage
- Configure routes with proper endpoint structure
- Update app.ts with dependency injection setup
- Add required dependencies: mongoose, bcrypt, jsonwebtoken, @anthropic-ai/sdk
2026-01-02 20:09:42 +01:00
Linus109
5af6cffa9c added docs 2025-12-15 16:59:00 +01:00
157 changed files with 31026 additions and 3961 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

6
.gitignore vendored
View File

@@ -1 +1,7 @@
node_modules
*.tsbuildinfo
docs/praesi_2_context.md
docs/*.png
.env
apps/server/docker/radicale/config/
apps/server/docker/radicale/data/

810
CLAUDE.md Normal file
View File

@@ -0,0 +1,810 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
**CalChat** is a calendar mobile app with AI support. The core concept is managing calendar events through a chat interface with an AI chatbot. Users can add, edit, and delete events via natural language conversation.
This is a fullstack TypeScript monorepo with npm workspaces.
## Commands
### Root (monorepo)
```bash
npm install # Install all dependencies for all workspaces
npm run format # Format all TypeScript files with Prettier
npm run check_format # Check formatting without modifying files (used in CI)
```
### Client (apps/client) - Expo React Native app
```bash
npm run start -w @calchat/client # Start Expo dev server
npm run android -w @calchat/client # Start on Android
npm run ios -w @calchat/client # Start on iOS
npm run web -w @calchat/client # Start web version
npm run lint -w @calchat/client # Run ESLint
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
```bash
npm run dev -w @calchat/server # Build shared + start dev server with hot reload (tsx watch)
npm run build -w @calchat/server # Build shared + compile TypeScript
npm run start -w @calchat/server # Run compiled server (port 3000)
npm run test -w @calchat/server # Run Jest unit tests
```
## Technology Stack
| Area | Technology | Purpose |
|------|------------|---------|
| Frontend | React Native | Mobile UI Framework |
| | Expo | Development platform |
| | Expo-Router | File-based routing |
| | NativeWind | Tailwind CSS for React Native |
| | Zustand | State management |
| | FlashList | High-performance lists |
| | EAS Build | Local APK/IPA builds |
| Backend | Express.js | Web framework |
| | MongoDB | Database |
| | Mongoose | ODM |
| | GPT (OpenAI) | AI/LLM for chat |
| | X-User-Id Header | Authentication |
| | pino / pino-http | Structured logging |
| | react-native-logs | Client-side logging |
| | tsdav | CalDAV client library |
| | ical.js | iCalendar parsing/generation |
| Testing | Jest / ts-jest | Server unit tests |
| | 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 |
## Architecture
### Workspace Structure
```
apps/client - @calchat/client - Expo React Native app
apps/server - @calchat/server - Express.js backend
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)
```
src/
├── app/ # Expo-Router file-based routing
│ ├── _layout.tsx # Root Stack layout
│ ├── index.tsx # Entry redirect
│ ├── login.tsx # Login screen
│ ├── register.tsx # Registration screen
│ ├── (tabs)/ # Tab navigation group
│ │ ├── _layout.tsx # Tab bar configuration (themed)
│ │ ├── chat.tsx # Chat screen (AI conversation)
│ │ ├── calendar.tsx # Calendar overview (with CalendarToolbar: sync + logout)
│ │ └── settings.tsx # Settings screen (theme switcher, logout, CalDAV config with feedback)
│ ├── editEvent.tsx # Event edit screen (dual-mode: calendar/chat)
│ ├── event/
│ │ └── [id].tsx # Event detail screen (dynamic route)
│ └── note/
│ └── [id].tsx # Note editor for event (dynamic route)
├── components/
│ ├── AuthGuard.tsx # Auth wrapper: loads user, preloads app data (events + CalDAV config), CalDAV sync, shows loading, redirects if unauthenticated. Exports preloadAppData()
│ ├── BaseBackground.tsx # Common screen wrapper (themed)
│ ├── BaseButton.tsx # Reusable button component (themed, supports children)
│ ├── Header.tsx # Header component (themed)
│ ├── 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)
│ ├── EventConfirmDialog.tsx # AI-proposed event confirmation modal (skeleton)
│ ├── ProposedEventCard.tsx # Chat event proposal (uses EventCardBase + confirm/reject/edit buttons)
│ ├── DeleteEventModal.tsx # Delete confirmation modal (uses ModalBase)
│ ├── CustomTextInput.tsx # Themed text input with focus border (used in login, register, CaldavSettings, editEvent)
│ ├── DateTimePicker.tsx # Date and time picker components
│ └── ScrollableDropdown.tsx # Scrollable dropdown component
├── Themes.tsx # Theme definitions: THEMES object with defaultLight/defaultDark, Theme type
├── logging/
│ ├── index.ts # Re-exports
│ └── logger.ts # react-native-logs config (apiLogger, storeLogger)
├── services/
│ ├── index.ts # Re-exports all services
│ ├── ApiClient.ts # HTTP client with X-User-Id header injection, request logging, handles empty responses (204)
│ ├── AuthService.ts # login(), register(), logout() - calls API and updates AuthStore
│ ├── EventService.ts # getAll(), getById(), getByDateRange(), create(), update(), delete(mode, occurrenceDate)
│ ├── ChatService.ts # sendMessage(), confirmEvent(deleteMode, occurrenceDate), rejectEvent(), getConversations(), getConversation(), updateProposalEvent()
│ └── CaldavConfigService.ts # saveConfig(), getConfig(), deleteConfig(), pull(), pushAll(), sync()
├── stores/ # Zustand state management
│ ├── index.ts # Re-exports all stores
│ ├── AuthStore.ts # user, isAuthenticated, isLoading, login(), logout(), loadStoredUser()
│ │ # Uses expo-secure-store (native) / localStorage (web)
│ ├── ChatStore.ts # messages[], isWaitingForResponse, addMessage(), addMessages(), updateMessage(), clearMessages(), setWaitingForResponse(), chatMessageToMessageData()
│ ├── EventsStore.ts # events[], setEvents(), addEvent(), updateEvent(), deleteEvent()
│ ├── CaldavConfigStore.ts # config (CaldavConfig | null), setConfig() - cached CalDAV config
│ └── ThemeStore.ts # theme, setTheme() - reactive theme switching with Zustand
└── hooks/
└── useDropdownPosition.ts # Hook for positioning dropdowns relative to trigger element
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, 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)
```
src/
├── app.ts # Entry point, DI setup, Express config
├── controllers/ # Request handlers + middleware (per architecture diagram)
│ ├── AuthController.ts # login(), register()
│ ├── ChatController.ts # sendMessage(), confirmEvent() + CalDAV push, rejectEvent(), getConversations(), getConversation(), updateProposalEvent()
│ ├── EventController.ts # create(), getById(), getAll(), getByDateRange(), update(), delete() - pushes/deletes to CalDAV on mutations
│ ├── CaldavController.ts # saveConfig(), loadConfig(), deleteConfig(), pullEvents(), pushEvents(), pushEvent()
│ ├── AuthMiddleware.ts # authenticate() - X-User-Id header validation
│ └── LoggingMiddleware.ts # httpLogger - pino-http request logging
├── logging/
│ ├── index.ts # Re-exports
│ ├── logger.ts # pino config with redact for sensitive data
│ └── Logged.ts # @Logged() class decorator for automatic method logging
├── routes/ # API endpoint definitions
│ ├── index.ts # Combines all routes under /api
│ ├── auth.routes.ts # /api/auth/*
│ ├── chat.routes.ts # /api/chat/* (protected)
│ ├── event.routes.ts # /api/events/* (protected)
│ └── caldav.routes.ts # /api/caldav/* (protected)
├── services/ # Business logic
│ ├── interfaces/ # DB-agnostic interfaces (for dependency injection)
│ │ ├── AIProvider.ts # processMessage()
│ │ ├── UserRepository.ts # findById, findByEmail, findByUserName, create + CreateUserData
│ │ ├── EventRepository.ts
│ │ ├── ChatRepository.ts
│ │ └── CaldavRepository.ts
│ ├── AuthService.ts
│ ├── ChatService.ts
│ ├── EventService.ts
│ └── CaldavService.ts # connect(), pullEvents(), pushEvent(), pushAll(), deleteEvent(), sync logic
├── repositories/ # Data access (DB-specific implementations)
│ ├── index.ts # Re-exports from ./mongo
│ └── mongo/ # MongoDB implementation
│ ├── models/ # Mongoose schemas
│ │ ├── types.ts # Shared types (IdVirtual interface)
│ │ ├── UserModel.ts
│ │ ├── EventModel.ts
│ │ ├── ChatModel.ts
│ │ └── CaldavConfigModel.ts
│ ├── MongoUserRepository.ts # findById, findByEmail, findByUserName, create
│ ├── MongoEventRepository.ts
│ ├── MongoChatRepository.ts
│ └── MongoCaldavRepository.ts
├── ai/
│ ├── GPTAdapter.ts # Implements AIProvider using OpenAI GPT
│ ├── index.ts # Re-exports GPTAdapter
│ └── utils/ # Shared AI utilities (provider-agnostic)
│ ├── index.ts # Re-exports
│ ├── eventFormatter.ts # Re-exports formatDate/Time/DateTime from shared
│ ├── systemPrompt.ts # buildSystemPrompt() - German calendar assistant prompt
│ ├── toolDefinitions.ts # TOOL_DEFINITIONS - provider-agnostic tool specs
│ └── toolExecutor.ts # executeToolCall() - handles getDay, proposeCreate/Update/Delete, searchEvents, getEventsInRange
├── utils/
│ ├── password.ts # hash(), compare() using bcrypt
│ ├── eventFormatters.ts # getWeeksOverview(), getMonthOverview() - formatted event listings
│ └── recurrenceExpander.ts # expandRecurringEvents() - expand recurring events into occurrences
└── scripts/
└── hash-password.js # Utility to hash passwords for manual DB updates
```
**API Endpoints:**
- `POST /api/auth/login` - User login
- `POST /api/auth/register` - User registration
- `GET /api/events` - Get all events (protected)
- `GET /api/events/range` - Get events by date range (protected)
- `GET /api/events/:id` - Get single event (protected)
- `POST /api/events` - Create event (protected)
- `PUT /api/events/:id` - Update 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/confirm/:conversationId/:messageId` - Confirm 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/:id` - Get messages of a conversation with cursor-based pagination (protected)
- `PUT /api/chat/messages/:messageId/proposal` - Update proposal event data before confirming (protected)
- `PUT /api/caldav/config` - Save CalDAV config (protected)
- `GET /api/caldav/config` - Load CalDAV config (protected)
- `DELETE /api/caldav/config` - Delete CalDAV config (protected)
- `POST /api/caldav/pull` - Pull events from CalDAV server (protected)
- `POST /api/caldav/pushAll` - Push all unsynced events (protected)
- `POST /api/caldav/push/:caldavUUID` - Push single event (protected)
- `GET /health` - Health check
- `POST /api/ai/test` - AI test endpoint (development only)
### Shared Package (packages/shared)
The shared package is compiled to `dist/` (CommonJS). All imports must use `@calchat/shared` (NOT `@calchat/shared/src/...`). Server `dev` and `build` scripts automatically build shared first.
```
src/
├── index.ts
├── models/
│ ├── index.ts
│ ├── User.ts # User, CreateUserDTO, LoginDTO, AuthResponse
│ ├── CalendarEvent.ts # CalendarEvent, CreateEventDTO, UpdateEventDTO, ExpandedEvent, CaldavSyncStatus
│ ├── CaldavConfig.ts # CaldavConfig
│ ├── ChatMessage.ts # ChatMessage, Conversation, SendMessageDTO, CreateMessageDTO,
│ │ # GetMessagesOptions, ChatResponse, ConversationSummary,
│ │ # ProposedEventChange, EventAction, RespondedAction, UpdateMessageDTO
│ └── Constants.ts # DAYS, MONTHS, Day, Month, DAY_INDEX, DAY_INDEX_TO_DAY,
│ # DAY_TO_GERMAN, DAY_TO_GERMAN_SHORT, MONTH_TO_GERMAN
└── utils/
├── index.ts
├── 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:**
- `User`: id, email, userName, passwordHash?, createdAt?, updatedAt?
- `CalendarEvent`: id, userId, caldavUUID?, etag?, title, description?, startTime, endTime, note?, recurrenceRule?, exceptionDates?, caldavSyncStatus?
- `CaldavConfig`: userId, serverUrl, username, password, syncIntervalSeconds?
- `CaldavSyncStatus`: 'synced' | 'error'
- `ExpandedEvent`: Extends CalendarEvent with occurrenceStart, occurrenceEnd (for recurring event instances)
- `ChatMessage`: id, conversationId, sender ('user' | 'assistant'), content, proposedChanges?
- `ProposedEventChange`: id, action ('create' | 'update' | 'delete'), eventId?, event?, updates?, respondedAction?, deleteMode?, occurrenceDate?, conflictingEvents?
- Each proposal has unique `id` (e.g., "proposal-0") for individual confirm/reject
- `respondedAction` tracks user response per proposal (not per message)
- `deleteMode` ('single' | 'future' | 'all') and `occurrenceDate` for recurring event deletion
- `conflictingEvents` contains events that overlap with the proposed time (for conflict warnings)
- `ConflictingEvent`: title, startTime, endTime - simplified event info for conflict display
- `RecurringDeleteMode`: 'single' | 'future' | 'all' - delete modes for recurring events
- `DeleteRecurringEventDTO`: mode, occurrenceDate? - DTO for recurring event deletion
- `Conversation`: id, userId, createdAt?, updatedAt? (messages loaded separately via lazy loading)
- `CreateUserDTO`: email, userName, password (for registration)
- `LoginDTO`: identifier (email OR userName), password
- `CreateEventDTO`: Used for creating events AND for AI-proposed events, includes optional `exceptionDates` for proposals
- `GetMessagesOptions`: Cursor-based pagination with `before?: string` and `limit?: number`
- `ConversationSummary`: id, lastMessage?, createdAt? (for conversation list)
- `UpdateMessageDTO`: proposalId?, respondedAction? (for marking individual proposals as confirmed/rejected)
- `RespondedAction`: 'confirm' | 'reject' (tracks user response to proposed events)
- `Day`: "Monday" | "Tuesday" | ... | "Sunday"
- `Month`: "January" | "February" | ... | "December"
### AI Context Architecture
The AI assistant fetches calendar data on-demand rather than receiving pre-loaded events. This reduces token usage significantly.
**AIContext Interface:**
```typescript
interface AIContext {
userId: string;
conversationHistory: ChatMessage[]; // Last 20 messages for context
currentDate: Date;
// Callbacks for on-demand data fetching:
fetchEventsInRange: (start: Date, end: Date) => Promise<ExpandedEvent[]>;
searchEvents: (query: string) => Promise<CalendarEvent[]>;
fetchEventById: (eventId: string) => Promise<CalendarEvent | null>;
}
```
**Available AI Tools:**
- `getDay` - Calculate relative dates (e.g., "next Friday")
- `getCurrentDateTime` - Get current timestamp
- `proposeCreateEvent` - Propose new event (includes automatic conflict detection)
- `proposeUpdateEvent` - Propose event modification
- `proposeDeleteEvent` - Propose event deletion (supports recurring delete modes)
- `searchEvents` - Search events by title (returns IDs for update/delete)
- `getEventsInRange` - Load events for a date range (for "what's today?" queries)
**Conflict Detection:**
When creating events, `toolExecutor` automatically:
1. Fetches events for the target day via `fetchEventsInRange`
2. Checks for time overlaps using `occurrenceStart/occurrenceEnd` (important for recurring events)
3. Returns `conflictingEvents` array in the proposal for UI display
4. Adds ⚠️ warning to tool result so AI can inform user
### CalDAV Synchronization
CalDAV sync with external calendar servers (e.g., Radicale) using `tsdav` and `ical.js`.
**Naming Convention:** All CalDAV-related identifiers use `Caldav` (PascalCase) / `caldav` (camelCase), NOT `CalDav`. The only exception is the protocol name "CalDAV" in comments and log messages.
**Sync Triggers (client-side via `CaldavConfigService.sync()`):**
- **Login** (`login.tsx`): After successful authentication
- **Auto-login** (`AuthGuard.tsx`): After `loadStoredUser()` if authenticated
- **Calendar timer** (`calendar.tsx`): Events load instantly from DB on focus (`loadEvents`), CalDAV sync runs in background (`syncAndReload`) and reloads events after. Repeats every 10s via `setInterval`
- **Sync button** (`settings.tsx`): Manual trigger in CaldavSettings
**Lazy sync (server-side in ChatService):**
- AI data access callbacks (`fetchEventsInRange`, `searchEvents`, `fetchEventById`) trigger `syncOnce()` before the first DB query
- Uses `CaldavService.sync()` which checks config internally (silent no-op without config)
**Single-event sync (server-side in controllers):**
- `EventController`: `pushToCaldav()` after create/update, `deleteFromCaldav()` after delete
- `ChatController`: `pushAll()` after confirming an event proposal
**Sync Flow:**
1. `sync()` calls `pushAll` (push unsynced local events) then `pull` (fetch remote events)
2. `pullEvents`: Compares etags to skip unchanged events, creates/updates local events, deletes locally if removed remotely
3. `pushEvent`: Creates or updates remote event, fetches new etag after push
**Architecture:**
- `CaldavService` depends on `CaldavRepository` (config storage) and `EventService` (event CRUD)
- `ChatService` depends on `EventService` and `CaldavService` (lazy CalDAV sync on AI data access)
- `EventController` and `ChatController` both receive `CaldavService` for CalDAV push on mutations
### Database Abstraction
The repository pattern allows swapping databases:
- **Interfaces** (`services/interfaces/`) are DB-agnostic
- **Implementations** (`repositories/mongo/`) are DB-specific
- To add MySQL: create `repositories/mysql/` with TypeORM entities
### Mongoose Model Pattern
All Mongoose models use a consistent pattern for TypeScript-safe `id` virtuals:
```typescript
import { IdVirtual } from './types';
const Schema = new Schema<Doc, Model<Doc, {}, {}, IdVirtual>, {}, {}, IdVirtual>(
{ /* fields */ },
{
virtuals: {
id: {
get() { return this._id.toString(); }
}
},
toJSON: {
virtuals: true,
transform: (_, ret) => {
delete ret._id;
delete ret.__v;
return ret;
}
}
}
);
```
Repositories use `doc.toJSON() as unknown as Type` casting (required because Mongoose's TypeScript types don't reflect virtual fields in toJSON output).
### Logging
Structured logging with pino (server) and react-native-logs (client).
**Server Logging:**
- `pino` with `pino-pretty` for development, JSON in production
- `pino-http` middleware logs all HTTP requests (method, path, status, duration)
- `@Logged()` class decorator for automatic method logging on repositories and services
- Sensitive data (password, token, etc.) automatically redacted via pino's `redact` config
**@Logged Decorator Pattern:**
```typescript
@Logged("MongoEventRepository")
export class MongoEventRepository implements EventRepository { ... }
@Logged("GPTAdapter")
export class GPTAdapter implements AIProvider { ... }
```
The decorator uses a Proxy to intercept method calls lazily, preserves sync/async nature, and logs start/completion/failure with duration.
**Log Summarization:**
The `@Logged` decorator automatically summarizes large arguments to keep logs readable:
- `conversationHistory``"[5 messages]"`
- `proposedChanges` → logged in full (for debugging AI issues)
- Long strings (>100 chars) → truncated
- Arrays → `"[Array(n)]"`
**Client Logging:**
- `react-native-logs` with namespaced loggers (apiLogger, storeLogger)
- ApiClient logs all requests with method, endpoint, status, duration
- Log level: debug in __DEV__, warn in production
## MVP Feature Scope
### Must-Have
- Chat interface with AI assistant (text input) for event management
- Calendar overview
- Manual event CRUD (without AI)
- View completed events
- Simple reminders
- One note per event
- Recurring events
### Nice-to-Have
- iCalendar import/export
- Multiple calendars
- ~~CalDAV synchronization with external services~~ (implemented)
## Development Environment
### MongoDB (Docker)
```bash
cd apps/server/docker/mongo
docker compose up -d # Start MongoDB + Mongo Express
docker compose down # Stop services
```
- MongoDB: `localhost:27017` (root/mongoose)
- Mongo Express UI: `localhost:8083` (admin/admin)
### Radicale CalDAV Server (Docker)
```bash
cd apps/server/docker/radicale
docker compose up -d # Start Radicale CalDAV server
```
- Radicale: `localhost:5232`
### Server Docker Image
```bash
# Build (requires local build context):
docker build -f apps/server/docker/Dockerfile -t calchat-server .
docker run -p 3001:3001 --env-file apps/server/.env calchat-server
```
Multi-stage COPY-based build: copies `package.json` files first for layer caching, then source code. Compiles shared + server, then copies only `dist/` and production dependencies to the runtime stage. Exposes port 3001. In CI, the `plugins/docker` Drone plugin builds and pushes the image automatically.
### Environment Variables
Server requires `.env` file in `apps/server/`:
```
MONGODB_URI=mongodb://root:mongoose@localhost:27017/calchat?authSource=admin
OPENAI_API_KEY=sk-proj-...
USE_TEST_RESPONSES=false # true = static test responses, false = real GPT AI
LOG_LEVEL=debug # debug | info | warn | error | fatal
NODE_ENV=development # development = pretty logs, production = JSON
```
## Current Implementation Status
**Backend:**
- **Implemented:**
- `AuthController`: login(), register() with error handling
- `AuthService`: login() supports email OR userName, register() checks for existing email AND userName
- `AuthMiddleware`: Validates X-User-Id header for protected routes
- `MongoUserRepository`: findById(), findByEmail(), findByUserName(), create()
- `utils/password`: hash(), compare() using bcrypt
- `scripts/hash-password.js`: Utility for manual password resets
- `dotenv` integration for environment variables
- `ChatController`: sendMessage(), confirmEvent(), rejectEvent()
- `ChatService`: processMessage() with test responses (create, update, delete actions), confirmEvent() handles all CRUD actions
- `MongoEventRepository`: Full CRUD implemented (findById, findByUserId, findByDateRange, create, update, delete, addExceptionDate)
- `EventController`: Full CRUD (create, getById, getAll, getByDateRange, update, delete)
- `EventService`: Full CRUD with recurring event expansion via recurrenceExpander, deleteRecurring() with three modes (single/future/all)
- `utils/eventFormatters`: getWeeksOverview(), getMonthOverview() with German localization
- `utils/recurrenceExpander`: expandRecurringEvents() using rrule library for RRULE parsing
- `ChatController`: getConversations(), getConversation() with cursor-based pagination support
- `ChatService`: getConversations(), getConversation(), processMessage() uses real AI or test responses (via USE_TEST_RESPONSES), confirmEvent()/rejectEvent() update respondedAction and persist response messages
- `MongoChatRepository`: Full CRUD implemented (getConversationsByUser, createConversation, getMessages with cursor pagination, createMessage, updateMessage, updateProposalResponse, updateProposalEvent)
- `ChatRepository` interface: updateMessage(), updateProposalResponse(), updateProposalEvent() for per-proposal tracking
- `GPTAdapter`: Full implementation with OpenAI GPT (gpt-4o-mini model), function calling for calendar operations, collects multiple proposals per response
- `ai/utils/`: Provider-agnostic shared utilities (systemPrompt, toolDefinitions, toolExecutor)
- `ai/utils/systemPrompt`: AI fetches events on-demand (no pre-loaded context), includes RRULE documentation, warns AI not to put RRULE in description field, instructs AI not to show event IDs to users
- `ai/utils/toolDefinitions`: proposeUpdateEvent supports `recurrenceRule` parameter, getEventsInRange tool for on-demand event loading
- `ai/utils/toolExecutor`: Async execution, conflict detection uses `occurrenceStart/occurrenceEnd` for recurring events, returns `conflictingEvents` in proposals
- `MongoEventRepository`: Includes `searchByTitle()` for case-insensitive title search
- `utils/recurrenceExpander`: Handles RRULE parsing, strips `RRULE:` prefix if present (AI may include it), filters out exceptionDates
- `logging/`: Structured logging with pino, pino-http middleware, @Logged decorator
- All repositories and GPTAdapter decorated with @Logged for automatic method logging
- `CaldavService`: Full CalDAV sync (connect, pullEvents, pushEvent, pushAll, deleteEvent, sync, getConfig, saveConfig, deleteConfig). `sync()` checks config internally and is a silent no-op without config.
- `CaldavController`: REST endpoints for config CRUD, pull, push
- `MongoCaldavRepository`: Config persistence with createOrUpdate, findByUserId, deleteByUserId
- `EventController`: CalDAV push on create/update, CalDAV delete on delete (via pushToCaldav/deleteFromCaldav helpers)
- `ChatController`: CalDAV pushAll after confirmEvent (ensures chat-created events sync)
- `ChatService`: Uses EventService + CaldavService (lazy sync on AI data access via syncOnce pattern)
- `EventService`: Extended with searchByTitle(), findByCaldavUUID()
- `utils/eventFormatters`: Refactored to use EventService instead of EventRepository
- CORS configured to allow X-User-Id header
**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:**
- **Authentication fully implemented:**
- `AuthStore`: Manages user state with expo-secure-store (native) / localStorage (web)
- `AuthService`: login(), register(), logout() - calls backend API
- `ApiClient`: Automatically injects X-User-Id header for authenticated requests, handles empty responses (204)
- `AuthGuard`: Reusable component that wraps protected routes - loads user, preloads app data (events + CalDAV config) into stores before dismissing spinner, triggers CalDAV sync, shows loading, redirects if unauthenticated. Exports `preloadAppData()` (also called by `login.tsx`)
- Login screen: Supports email OR userName login, uses CustomTextInput with focus border, preloads app data + triggers CalDAV sync after successful login
- Register screen: Email validation, checks for existing email/userName, uses CustomTextInput with focus border
- `AuthButton`: Reusable button component with themed shadow
- `Header`: Themed header component (logout moved to Settings)
- `(tabs)/_layout.tsx`: Wraps tabs with AuthGuard for protected access
- `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:
- 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)
- Events loaded from API via EventService.getByDateRange()
- Orange dot indicator for days with events
- Tap-to-open modal overlay showing EventCards for selected day
- Supports events from adjacent months visible in grid
- 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
- **Multiple event proposals**: AI can propose multiple events in one response
- Arrow navigation between proposals with "Event X von Y" counter
- Each proposal individually confirmable/rejectable
- **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
- ChatStore with addMessages() for bulk loading, chatMessageToMessageData() helper
- KeyboardAvoidingView for proper keyboard handling (iOS padding, Android height)
- Auto-scroll to end on new messages and keyboard show; initial load uses `onContentSizeChange` with `animated: false` to start at bottom without visible scrolling
- keyboardDismissMode="interactive" and keyboardShouldPersistTaps="handled"
- `EventService`: getAll(), getById(), getByDateRange(), create(), update(), delete(mode, occurrenceDate) - fully implemented with recurring delete modes
- `ChatService`: sendMessage(), confirmEvent(deleteMode, occurrenceDate), rejectEvent(), getConversations(), getConversation(), updateProposalEvent() - fully implemented with cursor pagination, recurring delete support, and proposal editing
- `CaldavConfigService`: saveConfig(), getConfig(), deleteConfig(), pull(), pushAll(), sync() - CalDAV config management and sync trigger
- `CustomTextInput`: Themed text input with focus border highlight. Props: `text`, `onValueChange`, `placeholder`, `placeholderTextColor`, `secureTextEntry`, `autoCapitalize`, `keyboardType`, `className`, `multiline`. No default padding — callers must set padding via `className` (e.g., `px-3 py-2` or `p-4`). When not focused, cursor is reset to start (`selection={{ start: 0 }}`) to avoid text appearing scrolled to the end.
- `CardBase`: Reusable card component with header (title/subtitle), content area, and optional footer button - configurable padding, border, text size via props, ScrollView uses `nestedScrollEnabled` for Android
- `ModalBase`: Reusable modal wrapper with backdrop (absolute-positioned behind card), uses CardBase internally - provides click-outside-to-close, Android back button support, and proper scrolling on Android
- `EventCardBase`: Event card with date/time/recurring icons - uses CardBase for structure. Accepts `recurrenceRule` string (not boolean) and displays German-formatted recurrence via `formatRecurrenceRule()`
- `EventCard`: Uses EventCardBase + edit/delete buttons (TouchableOpacity with delayPressIn for scroll-friendly touch handling)
- `ProposedEventCard`: Uses EventCardBase + confirm/reject/edit buttons for chat proposals, displays green highlighted text for new changes ("Neue Ausnahme: [date]" for single delete, "Neues Ende: [date]" for UNTIL updates), shows yellow conflict warnings when proposed time overlaps with existing events. Edit button allows modifying proposals before confirming.
- `DeleteEventModal`: Delete confirmation modal using ModalBase - shows three options for recurring events (single/future/all), simple confirm for non-recurring
- `CalendarToolbar` (in calendar.tsx): Toolbar between header and weekdays with Sync button (CalDAV sync with spinner/green checkmark/red X feedback, disabled without config) and Logout button
- `EventOverlay` (in calendar.tsx): Day events overlay using ModalBase - shows EventCards for selected day
- `Themes.tsx`: Theme definitions with THEMES object (defaultLight, defaultDark) including all color tokens (textPrimary, borderPrimary, eventIndicator, secondaryBg, shadowColor, etc.)
- `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
- `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
### Local APK Build with EAS
```bash
npm run build:apk -w @calchat/client
```
This uses the `preview` profile from `eas.json` which builds an APK with:
- `arm64-v8a` architecture only (smaller APK size)
- No credentials required (`withoutCredentials: true`)
- Internal distribution
- Non-interactive mode with fixed output path (`calchat.apk`) for CI compatibility
**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:
- `development`: Development client with internal distribution
- `preview`: APK build for testing (used by `build:apk`)
- `production`: Production build with auto-increment versioning
**App Identity:**
- Package name: `com.gilmour109.calchat`
- 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
Detailed architecture diagrams are in `docs/`:
- `api-routes.md` - API endpoint overview (German)
- `technisches_brainstorm.tex` - Technical concept document (German)
- `architecture-class-diagram.puml` - Backend class diagram
- `frontend-class-diagram.puml` - Frontend class diagram
- `component-diagram.puml` - System component overview

157
README.md
View File

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

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

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

View File

@@ -1,50 +1,39 @@
{
"expo": {
"jsEngine": "hermes",
"name": "caldav",
"name": "CalChat",
"slug": "caldav",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "caldav",
"scheme": "calchat",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
"package": "com.gilmour109.calchat",
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false
"predictiveBackGestureEnabled": false,
"usesCleartextTraffic": true
},
"web": {
"output": "static",
"favicon": "./assets/images/favicon.png",
"bundler": "metro"
},
"plugins": [
"expo-router",
[
"expo-splash-screen",
{
"image": "./assets/images/splash-icon.png",
"imageWidth": 200,
"resizeMode": "contain",
"backgroundColor": "#ffffff",
"dark": {
"backgroundColor": "#000000"
}
}
]
"expo-router"
],
"experiments": {
"typedRoutes": true,
"reactCompiler": true
}
},
"extra": {
"router": {},
"eas": {
"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"]
}

29
apps/client/eas.json Normal file
View File

@@ -0,0 +1,29 @@
{
"cli": {
"version": ">= 16.28.0",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal",
"android": {
"buildType": "apk",
"withoutCredentials": true
},
"env": {
"ORG_GRADLE_PROJECT_reactNativeArchitectures": "arm64-v8a",
"EXPO_PUBLIC_API_URL": "https://calchat.gilmour109.de/api"
}
},
"production": {
"autoIncrement": true
}
},
"submit": {
"production": {}
}
}

View File

@@ -1,5 +1,5 @@
{
"name": "@caldav/client",
"name": "@calchat/client",
"main": "expo-router/entry",
"version": "1.0.0",
"scripts": {
@@ -8,22 +8,27 @@
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"lint": "expo lint"
"lint": "expo lint",
"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": {
"@caldav/shared": "*",
"@calchat/shared": "*",
"@expo/vector-icons": "^15.0.3",
"@react-native-community/datetimepicker": "8.4.4",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"@shopify/flash-list": "^2.0.2",
"expo": "~54.0.25",
"expo-build-properties": "^1.0.10",
"expo-constants": "~18.0.10",
"expo-font": "~14.0.9",
"expo-haptics": "~15.0.7",
"expo-image": "~3.0.10",
"expo-linking": "~8.0.9",
"expo-router": "~6.0.15",
"expo-secure-store": "^15.0.8",
"expo-splash-screen": "~31.0.11",
"expo-status-bar": "~3.0.8",
"expo-symbols": "~1.0.7",
@@ -34,17 +39,27 @@
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-logs": "^5.5.0",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "5.6.0",
"react-native-screens": "~4.16.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"
},
"devDependencies": {
"@types/jest": "^29.5.14",
"@types/react": "~19.1.0",
"appium": "^2.17.1",
"appium-uiautomator2-driver": "^3.8.0",
"eslint-config-expo": "~10.0.0",
"jest": "^29.7.0",
"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
}

View File

@@ -1,28 +0,0 @@
export const MONTHS = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
] as const;
export type Month = (typeof MONTHS)[number];
export const DAYS = [
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday",
] as const;
export type Day = (typeof DAYS)[number];

View File

@@ -1,20 +1,61 @@
type Theme = {
chatBot: string,
primeFg: string,
primeBg: string,
messageBorderBg: string,
placeholderBg: string,
calenderBg: string,
}
export type Theme = {
chatBot: string;
primeFg: string;
primeBg: string;
secondaryBg: string;
messageBorderBg: string;
placeholderBg: string;
calenderBg: string;
confirmButton: string;
rejectButton: string;
disabledButton: string;
buttonText: string;
textPrimary: string;
textSecondary: string;
textMuted: string;
eventIndicator: string;
borderPrimary: string;
shadowColor: string;
};
const defaultLight: Theme = {
chatBot: "#DE6C20",
primeFg: "#3B3329",
primeBg: "#FFEEDE",
messageBorderBg: "#FFFFFF",
placeholderBg: "#D9D9D9",
calenderBg: "#FBD5B2",
}
let currentTheme: Theme = defaultLight;
export default currentTheme;
export const THEMES = {
defaultLight: {
chatBot: "#DE6C20",
// chatBot: "#324121",
primeFg: "#3B3329",
primeBg: "#FFEEDE",
secondaryBg: "#FFFFFF",
messageBorderBg: "#FFFFFF",
placeholderBg: "#D9D9D9",
calenderBg: "#FBD5B2",
confirmButton: "#22c55e",
rejectButton: "#ef4444",
disabledButton: "#ccc",
buttonText: "#000000",
textPrimary: "#000000",
textSecondary: "#666",
textMuted: "#888",
eventIndicator: "#DE6C20",
borderPrimary: "#000000",
shadowColor: "#000000",
},
defaultDark: {
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

@@ -0,0 +1,51 @@
import { Ionicons } from "@expo/vector-icons";
import { Tabs } from "expo-router";
import { useThemeStore } from "../../stores/ThemeStore";
import { AuthGuard } from "../../components/AuthGuard";
export default function TabLayout() {
const { theme } = useThemeStore();
return (
<AuthGuard>
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: theme.chatBot,
tabBarInactiveTintColor: theme.primeFg,
tabBarStyle: { backgroundColor: theme.primeBg },
}}
>
<Tabs.Screen
name="chat"
options={{
title: "Chat",
tabBarTestID: "tab-chat",
tabBarIcon: ({ color }) => (
<Ionicons size={28} name="chatbubble" color={color} />
),
}}
/>
<Tabs.Screen
name="calendar"
options={{
title: "Calendar",
tabBarTestID: "tab-calendar",
tabBarIcon: ({ 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>
</AuthGuard>
);
}

View File

@@ -0,0 +1,780 @@
import { ActivityIndicator, Pressable, Text, View } from "react-native";
import {
DAYS,
MONTHS,
Month,
ExpandedEvent,
RecurringDeleteMode,
} from "@calchat/shared";
import Header from "../../components/Header";
import { EventCard } from "../../components/EventCard";
import { DeleteEventModal } from "../../components/DeleteEventModal";
import { ModalBase } from "../../components/ModalBase";
import { ScrollableDropdown } from "../../components/ScrollableDropdown";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { router, useFocusEffect } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { useThemeStore } from "../../stores/ThemeStore";
import BaseBackground from "../../components/BaseBackground";
import { AuthService, EventService } from "../../services";
import { useEventsStore } from "../../stores";
import { useDropdownPosition } from "../../hooks/useDropdownPosition";
import { CaldavConfigService } from "../../services/CaldavConfigService";
import { useCaldavConfigStore } from "../../stores/CaldavConfigStore";
// MonthSelector types and helpers
type MonthItem = {
id: string; // Format: "YYYY-MM"
year: number;
monthIndex: number; // 0-11
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 = (
centerYear: number,
centerMonth: number,
range: number,
): MonthItem[] => {
const months: MonthItem[] = [];
for (let offset = -range; offset <= range; offset++) {
let year = centerYear;
let month = centerMonth + offset;
while (month < 0) {
month += 12;
year--;
}
while (month > 11) {
month -= 12;
year++;
}
months.push({
id: `${year}-${String(month + 1).padStart(2, "0")}`,
year,
monthIndex: month,
label: `${MONTHS[month]} ${year}`,
});
}
return months;
};
const Calendar = () => {
const [monthIndex, setMonthIndex] = useState(new Date().getMonth());
const [currentYear, setCurrentYear] = useState(new Date().getFullYear());
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
const [overlayVisible, setOverlayVisible] = useState(false);
// State for delete modal
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const [eventToDelete, setEventToDelete] = useState<ExpandedEvent | null>(
null,
);
const { events, setEvents, deleteEvent } = useEventsStore();
// Load events from local DB (fast, no network sync)
const loadEvents = useCallback(async () => {
try {
// Calculate first visible day (up to 6 days before month start)
const firstOfMonth = new Date(currentYear, monthIndex, 1);
const dayOfWeek = firstOfMonth.getDay();
const daysFromPrevMonth = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
const startDate = new Date(
currentYear,
monthIndex,
1 - daysFromPrevMonth,
);
// Calculate last visible day (6 weeks * 7 days = 42 days total)
const endDate = new Date(startDate);
endDate.setDate(startDate.getDate() + 41);
endDate.setHours(23, 59, 59);
const loadedEvents = await EventService.getByDateRange(
startDate,
endDate,
);
setEvents(loadedEvents);
} catch (error) {
console.error("Failed to load events:", error);
}
}, [monthIndex, currentYear, setEvents]);
// Load events from DB on focus
useFocusEffect(
useCallback(() => {
loadEvents();
}, [loadEvents]),
);
// Re-open overlay after back navigation from editEvent
useFocusEffect(
useCallback(() => {
if (selectedDate) {
setOverlayVisible(true);
}
}, [selectedDate]),
);
// Group events by date (YYYY-MM-DD format)
// Multi-day events are added to all days they span
const eventsByDate = useMemo(() => {
const map = new Map<string, ExpandedEvent[]>();
events.forEach((e) => {
const start = new Date(e.occurrenceStart);
const end = new Date(e.occurrenceEnd);
// Iterate through each day the event spans
const current = new Date(start);
current.setHours(0, 0, 0, 0);
while (current <= end) {
const key = getDateKey(current);
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(e);
current.setDate(current.getDate() + 1);
}
});
return map;
}, [events]);
const changeMonth = (delta: number) => {
setMonthIndex((prev) => {
const newIndex = prev + delta;
if (newIndex > 11) {
setCurrentYear((y) => y + 1);
return 0;
}
if (newIndex < 0) {
setCurrentYear((y) => y - 1);
return 11;
}
return newIndex;
});
};
const handleDayPress = (date: Date) => {
setSelectedDate(date);
setOverlayVisible(true);
};
const handleCloseOverlay = () => {
setSelectedDate(null);
setOverlayVisible(false);
};
const handleCreateEvent = () => {
setOverlayVisible(false);
router.push({
pathname: "/editEvent",
params: { date: selectedDate?.toISOString() },
});
};
const handleEditEvent = (event?: ExpandedEvent) => {
router.push({
pathname: "/editEvent",
params: {
mode: "calendar",
id: event?.id,
},
});
};
const handleDeleteEvent = (event: ExpandedEvent) => {
// 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 {
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);
deleteEvent(event.id);
}
} catch (error) {
console.error("Failed to delete event:", error);
}
// 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
const selectedDateEvents = useMemo(() => {
if (!selectedDate) return [];
const key = getDateKey(selectedDate);
return eventsByDate.get(key) || [];
}, [selectedDate, eventsByDate]);
return (
<BaseBackground>
<CalendarHeader
changeMonth={changeMonth}
monthIndex={monthIndex}
currentYear={currentYear}
setMonthIndex={setMonthIndex}
setYear={setCurrentYear}
/>
<CalendarToolbar loadEvents={loadEvents} />
<WeekDaysLine />
<CalendarGrid
month={MONTHS[monthIndex]}
year={currentYear}
eventsByDate={eventsByDate}
onDayPress={handleDayPress}
/>
<EventOverlay
visible={overlayVisible && !deleteModalVisible}
date={selectedDate}
events={selectedDateEvents}
onClose={handleCloseOverlay}
onEditEvent={handleEditEvent}
onDeleteEvent={handleDeleteEvent}
onCreateEvent={handleCreateEvent}
/>
<DeleteEventModal
visible={deleteModalVisible}
eventTitle={eventToDelete?.title || ""}
isRecurring={!!eventToDelete?.recurrenceRule}
onConfirm={handleDeleteConfirm}
onCancel={handleDeleteCancel}
/>
</BaseBackground>
);
};
type EventOverlayProps = {
visible: boolean;
date: Date | null;
events: ExpandedEvent[];
onClose: () => void;
onEditEvent: (event?: ExpandedEvent) => void;
onDeleteEvent: (event: ExpandedEvent) => void;
onCreateEvent: () => void;
};
const EventOverlay = ({
visible,
date,
events,
onClose,
onEditEvent,
onDeleteEvent,
onCreateEvent,
}: EventOverlayProps) => {
const { theme } = useThemeStore();
if (!date) return null;
const dateString = date.toLocaleDateString("de-DE", {
weekday: "long",
day: "2-digit",
month: "long",
year: "numeric",
});
const subtitle = `${events.length} ${events.length === 1 ? "Termin" : "Termine"}`;
const addEventAttachment = (
<Pressable
className="flex flex-row justify-center items-center py-3"
style={{ backgroundColor: theme.confirmButton }}
onPress={onCreateEvent}
>
<Ionicons name="add-outline" size={24} color={theme.buttonText} />
<Text style={{ color: theme.buttonText }} className="font-semibold ml-1">
Neuen Termin erstellen
</Text>
</Pressable>
);
return (
<ModalBase
visible={visible}
onClose={onClose}
title={dateString}
subtitle={subtitle}
attachment={addEventAttachment}
footer={{ label: "Schliessen", onPress: onClose }}
scrollable={true}
maxContentHeight={400}
>
{events.map((event, index) => (
<EventCard
key={`${event.id}-${index}`}
event={event}
onEdit={() => onEditEvent(event)}
onDelete={() => onDeleteEvent(event)}
/>
))}
</ModalBase>
);
};
type MonthSelectorProps = {
modalVisible: boolean;
onClose: () => void;
position: { top: number; left: number; width: number };
currentYear: number;
currentMonthIndex: number;
onSelectMonth: (year: number, monthIndex: number) => void;
};
const INITIAL_RANGE = 12; // 12 months before and after current
const MonthSelector = ({
modalVisible,
onClose,
position,
currentYear,
currentMonthIndex,
onSelectMonth,
}: MonthSelectorProps) => {
const [monthSelectorData, setMonthSelectorData] = useState<MonthItem[]>([]);
const appendMonths = useCallback(
(direction: "start" | "end", count: number) => {
setMonthSelectorData((prevData) => {
if (prevData.length === 0) return prevData;
const newMonths: MonthItem[] = [];
const referenceMonth =
direction === "start" ? prevData[0] : prevData[prevData.length - 1];
for (let i = 1; i <= count; i++) {
const offset = direction === "start" ? -i : i;
let year = referenceMonth.year;
let month = referenceMonth.monthIndex + offset;
while (month < 0) {
month += 12;
year--;
}
while (month > 11) {
month -= 12;
year++;
}
const newMonth: MonthItem = {
id: `${year}-${String(month + 1).padStart(2, "0")}`,
year,
monthIndex: month,
label: `${MONTHS[month]} ${year}`,
};
if (direction === "start") {
newMonths.unshift(newMonth);
} else {
newMonths.push(newMonth);
}
}
return direction === "start"
? [...newMonths, ...prevData]
: [...prevData, ...newMonths];
});
},
[],
);
// Generate fresh data when modal opens, clear when closes
useEffect(() => {
if (modalVisible) {
setMonthSelectorData(
generateMonths(currentYear, currentMonthIndex, INITIAL_RANGE),
);
} else {
setMonthSelectorData([]);
}
}, [modalVisible, currentYear, currentMonthIndex]);
const handleSelect = useCallback(
(item: MonthItem) => {
onSelectMonth(item.year, item.monthIndex);
onClose();
},
[onSelectMonth, onClose],
);
return (
<ScrollableDropdown
visible={modalVisible}
onClose={onClose}
position={position}
data={monthSelectorData}
keyExtractor={(item) => item.id}
renderItem={(item, theme) => (
<View
className="w-full flex justify-center items-center py-2"
style={{
backgroundColor:
item.monthIndex % 2 === 0 ? theme.primeBg : theme.secondaryBg,
}}
>
<Text className="text-xl" style={{ color: theme.primeFg }}>
{item.label}
</Text>
</View>
)}
onSelect={handleSelect}
height={200}
initialScrollIndex={INITIAL_RANGE}
onEndReached={() => appendMonths("end", 12)}
onStartReached={() => appendMonths("start", 12)}
/>
);
};
type CalendarHeaderProps = {
changeMonth: (delta: number) => void;
monthIndex: number;
currentYear: number;
setMonthIndex: (index: number) => void;
setYear: (year: number) => void;
};
const CalendarHeader = (props: CalendarHeaderProps) => {
const { theme } = useThemeStore();
const dropdown = useDropdownPosition();
const prevMonth = () => props.changeMonth(-1);
const nextMonth = () => props.changeMonth(1);
return (
<Header className="flex flex-row items-center justify-between">
<ChangeMonthButton onPress={prevMonth} icon="chevron-back" />
<View
ref={dropdown.ref}
className="relative flex flex-row items-center justify-around"
>
<Text className="text-4xl px-1" style={{ color: theme.textPrimary }}>
{MONTHS[props.monthIndex]} {props.currentYear}
</Text>
<Pressable
className="flex justify-center items-center w-12 h-12 border rounded-lg"
style={{
borderColor: theme.primeFg,
backgroundColor: theme.chatBot,
// iOS shadow
shadowColor: theme.shadowColor,
shadowOffset: { width: 0, height: 3 },
shadowOpacity: 0.35,
shadowRadius: 5,
// Android shadow
elevation: 6,
}}
onPress={dropdown.open}
>
<Ionicons name="chevron-down" size={28} color={theme.primeFg} />
</Pressable>
</View>
<MonthSelector
modalVisible={dropdown.visible}
onClose={dropdown.close}
position={dropdown.position}
currentYear={props.currentYear}
currentMonthIndex={props.monthIndex}
onSelectMonth={(year, month) => {
props.setYear(year);
props.setMonthIndex(month);
}}
/>
<ChangeMonthButton onPress={nextMonth} icon="chevron-forward" />
</Header>
);
};
type ChangeMonthButtonProps = {
onPress: () => void;
icon: "chevron-back" | "chevron-forward";
};
const ChangeMonthButton = (props: ChangeMonthButtonProps) => {
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"
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.icon}
size={48}
color={theme.primeFg}
style={{
marginLeft: props.icon === "chevron-forward" ? 4 : 0,
marginRight: props.icon === "chevron-back" ? 4 : 0,
}}
/>
</Pressable>
);
};
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">
{/* TODO: px and gap need fine tuning to perfectly align with the grid */}
{DAYS.map((day, i) => (
<Text key={i} style={{ color: theme.textPrimary }}>
{day.substring(0, 2).toUpperCase()}
</Text>
))}
</View>
);
};
type CalendarGridProps = {
month: Month;
year: number;
eventsByDate: Map<string, ExpandedEvent[]>;
onDayPress: (date: Date) => void;
};
const CalendarGrid = (props: CalendarGridProps) => {
const { theme } = useThemeStore();
const { baseDate, dateOffset } = useMemo(() => {
const monthIndex = MONTHS.indexOf(props.month);
const base = new Date(props.year, monthIndex, 1);
const offset = base.getDay() === 0 ? 6 : base.getDay() - 1;
return { baseDate: base, dateOffset: offset };
}, [props.month, props.year]);
// TODO: create array beforehand in a useMemo
const createDateFromOffset = (offset: number): Date => {
const date = new Date(baseDate);
date.setDate(date.getDate() + offset);
return date;
};
return (
<View
className="h-full flex-1 flex-col flex-wrap gap-2 p-2"
style={{
backgroundColor: theme.calenderBg,
}}
>
{Array.from({ length: 6 }).map((_, i) => (
<View
key={i}
className="w-full flex-1 flex-row justify-around items-center gap-2"
>
{Array.from({ length: 7 }).map((_, j) => {
const date = createDateFromOffset(i * 7 + j - dateOffset);
const dateKey = getDateKey(date);
const hasEvents = props.eventsByDate.has(dateKey);
return (
<SingleDay
key={j}
date={date}
month={props.month}
hasEvents={hasEvents}
onPress={() => props.onDayPress(date)}
/>
);
})}
</View>
))}
</View>
);
};
type SingleDayProps = {
date: Date;
month: Month;
hasEvents: boolean;
onPress: () => void;
};
const SingleDay = (props: SingleDayProps) => {
const { theme } = useThemeStore();
const isSameMonth = MONTHS[props.date.getMonth()] === props.month;
return (
<Pressable
onPress={props.onPress}
className="h-full flex-1 aspect-auto rounded-xl items-center justify-between py-1"
style={{
backgroundColor: theme.primeBg,
}}
>
<Text
className="text-xl"
style={{ color: theme.textPrimary, opacity: isSameMonth ? 1 : 0.5 }}
>
{props.date.getDate()}
</Text>
{/* Event indicator dot */}
{props.hasEvents && (
<View
className="w-2 h-2 rounded-full"
style={{ backgroundColor: theme.eventIndicator }}
/>
)}
</Pressable>
);
};
export default Calendar;

View File

@@ -0,0 +1,465 @@
import {
View,
Text,
TextInput,
Pressable,
KeyboardAvoidingView,
Platform,
Keyboard,
} from "react-native";
import { useThemeStore } from "../../stores/ThemeStore";
import React, { useState, useRef, useEffect, useCallback } from "react";
import { useFocusEffect, router } from "expo-router";
import Header from "../../components/Header";
import BaseBackground from "../../components/BaseBackground";
import { FlashList } from "@shopify/flash-list";
import { ChatService } from "../../services";
import {
useChatStore,
useAuthStore,
chatMessageToMessageData,
MessageData,
} from "../../stores";
import { ProposedEventChange, RespondedAction } from "@calchat/shared";
import { ProposedEventCard } from "../../components/ProposedEventCard";
import { Ionicons } from "@expo/vector-icons";
import TypingIndicator from "../../components/TypingIndicator";
import { ChatBubble } from "../../components/ChatBubble";
// TODO: better shadows for everything
// (maybe with extra library because of differences between android and ios)
// TODO: max width for messages
type BubbleSide = "left" | "right";
type ChatMessageProps = {
side: BubbleSide;
content: string;
proposedChanges?: ProposedEventChange[];
onConfirm?: (proposalId: string, proposal: ProposedEventChange) => void;
onReject?: (proposalId: string) => void;
onEdit?: (proposalId: string, proposal: ProposedEventChange) => void;
};
type ChatInputProps = {
onSend: (text: string) => void;
};
const TYPING_INDICATOR_DELAY_MS = 500;
const Chat = () => {
const { isAuthenticated, isLoading: isAuthLoading } = useAuthStore();
const {
messages,
addMessage,
addMessages,
updateMessage,
isWaitingForResponse,
setWaitingForResponse,
} = useChatStore();
const listRef =
useRef<React.ComponentRef<typeof FlashList<MessageData>>>(null);
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const [currentConversationId, setCurrentConversationId] = useState<
string | undefined
>();
const [hasLoadedMessages, setHasLoadedMessages] = useState(false);
const needsInitialScroll = useRef(false);
useEffect(() => {
const keyboardDidShow = Keyboard.addListener("keyboardDidShow", () =>
scrollToEnd(),
);
return () => keyboardDidShow.remove();
}, []);
// Load existing messages from database only once (on initial mount)
// Skip on subsequent focus events to preserve local edits (e.g., edited proposals)
useFocusEffect(
useCallback(() => {
if (isAuthLoading || !isAuthenticated || hasLoadedMessages) return;
const fetchMessages = async () => {
try {
const conversationSummaries = await ChatService.getConversations();
if (conversationSummaries.length > 0) {
const conversationId = conversationSummaries[0].id;
setCurrentConversationId(conversationId);
const serverMessages =
await ChatService.getConversation(conversationId);
const clientMessages = serverMessages.map(chatMessageToMessageData);
addMessages(clientMessages);
needsInitialScroll.current = true;
}
} catch (error) {
console.error("Failed to load messages:", error);
} finally {
setHasLoadedMessages(true);
}
};
fetchMessages();
}, [isAuthLoading, isAuthenticated, hasLoadedMessages]),
);
const scrollToEnd = (animated = true) => {
setTimeout(() => {
listRef.current?.scrollToEnd({ animated });
}, 100);
};
const handleEventResponse = async (
action: RespondedAction,
messageId: string,
conversationId: string,
proposalId: string,
proposedChange?: ProposedEventChange,
) => {
// Mark proposal as responded (optimistic update)
const message = messages.find((m) => m.id === messageId);
if (message?.proposedChanges) {
const updatedProposals = message.proposedChanges.map((p) =>
p.id === proposalId ? { ...p, respondedAction: action } : p,
);
updateMessage(messageId, { proposedChanges: updatedProposals });
}
try {
const response =
action === "confirm" && proposedChange
? await ChatService.confirmEvent(
conversationId,
messageId,
proposalId,
proposedChange.action,
proposedChange.event,
proposedChange.eventId,
proposedChange.updates,
proposedChange.deleteMode,
proposedChange.occurrenceDate,
)
: await ChatService.rejectEvent(
conversationId,
messageId,
proposalId,
);
const botMessage: MessageData = {
id: response.message.id,
side: "left",
content: response.message.content,
conversationId: response.conversationId,
};
addMessage(botMessage);
scrollToEnd();
} catch (error) {
console.error(`Failed to ${action} event:`, error);
// Revert on error
if (message?.proposedChanges) {
const revertedProposals = message.proposedChanges.map((p) =>
p.id === proposalId ? { ...p, respondedAction: undefined } : p,
);
updateMessage(messageId, { proposedChanges: revertedProposals });
}
}
};
const handleEditProposal = (
messageId: string,
conversationId: string,
proposalId: string,
proposal: ProposedEventChange,
) => {
router.push({
pathname: "/editEvent",
params: {
mode: "chat",
eventData: JSON.stringify(proposal.event),
proposalContext: JSON.stringify({
messageId,
proposalId,
conversationId,
}),
},
});
};
const handleSend = async (text: string) => {
// Show user message immediately
const userMessage: MessageData = {
id: Date.now().toString(),
side: "right",
content: text,
conversationId: currentConversationId,
};
addMessage(userMessage);
scrollToEnd();
// Show typing indicator after delay
typingTimeoutRef.current = setTimeout(() => {
setWaitingForResponse(true);
scrollToEnd();
}, TYPING_INDICATOR_DELAY_MS);
try {
// Fetch server response (include conversationId for existing conversations)
const response = await ChatService.sendMessage({
content: text,
conversationId: currentConversationId,
});
// Track conversation ID for subsequent messages
if (!currentConversationId) {
setCurrentConversationId(response.conversationId);
}
// Show bot response
const botMessage: MessageData = {
id: response.message.id,
side: "left",
content: response.message.content,
proposedChanges: response.message.proposedChanges,
conversationId: response.conversationId,
};
addMessage(botMessage);
scrollToEnd();
} catch (error) {
console.error("Failed to send message:", error);
} finally {
// Hide typing indicator
clearTimeout(typingTimeoutRef.current);
setWaitingForResponse(false);
}
};
return (
<BaseBackground>
<ChatHeader />
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={{ flex: 1 }}
>
<FlashList
ref={listRef}
data={messages}
renderItem={({ item }) => (
<ChatMessage
side={item.side}
content={item.content}
proposedChanges={item.proposedChanges}
onConfirm={(proposalId, proposal) =>
handleEventResponse(
"confirm",
item.id,
item.conversationId!,
proposalId,
proposal,
)
}
onReject={(proposalId) =>
handleEventResponse(
"reject",
item.id,
item.conversationId!,
proposalId,
)
}
onEdit={(proposalId, proposal) =>
handleEditProposal(
item.id,
item.conversationId!,
proposalId,
proposal,
)
}
/>
)}
keyExtractor={(item) => item.id}
keyboardDismissMode="interactive"
keyboardShouldPersistTaps="handled"
onContentSizeChange={() => {
if (needsInitialScroll.current) {
needsInitialScroll.current = false;
listRef.current?.scrollToEnd({ animated: false });
}
}}
ListFooterComponent={
isWaitingForResponse ? <TypingIndicator /> : null
}
/>
<ChatInput onSend={handleSend} />
</KeyboardAvoidingView>
</BaseBackground>
);
};
const ChatHeader = () => {
const { theme } = useThemeStore();
return (
<Header className="flex flex-row items-center">
<View
className="ml-3 w-12 h-12 rounded-3xl border border-solid"
style={{
backgroundColor: theme.placeholderBg,
borderColor: theme.primeFg,
}}
></View>
<Text className="text-lg pl-3" style={{ color: theme.textPrimary }}>
CalChat
</Text>
<View
className="h-2 bg-black"
style={{
shadowColor: theme.shadowColor,
shadowOffset: {
width: 0,
height: 5,
},
shadowOpacity: 0.34,
shadowRadius: 6.27,
elevation: 10,
}}
/>
</Header>
);
};
const MIN_INPUT_HEIGHT = 40;
const MAX_INPUT_HEIGHT = 150;
const ChatInput = ({ onSend }: ChatInputProps) => {
const { theme } = useThemeStore();
const [text, setText] = useState("");
const handleSend = () => {
if (text.trim()) {
onSend(text.trim());
setText("");
}
};
return (
<View className="flex flex-row w-full items-end my-2 px-2">
<TextInput
testID="chat-message-input"
className="flex-1 border border-solid rounded-2xl px-3 py-2 mr-2"
style={{
backgroundColor: theme.messageBorderBg,
color: theme.textPrimary,
minHeight: MIN_INPUT_HEIGHT,
maxHeight: MAX_INPUT_HEIGHT,
textAlignVertical: "top",
}}
onChangeText={setText}
value={text}
placeholder="Nachricht..."
placeholderTextColor={theme.textMuted}
multiline
/>
<Pressable testID="chat-send-button" onPress={handleSend}>
<View
className="w-10 h-10 rounded-full items-center justify-center"
style={{
backgroundColor: theme.placeholderBg,
}}
/>
</Pressable>
</View>
);
};
const ChatMessage = ({
side,
content,
proposedChanges,
onConfirm,
onReject,
onEdit,
}: ChatMessageProps) => {
const { theme } = useThemeStore();
const [currentIndex, setCurrentIndex] = useState(0);
const hasProposals = proposedChanges && proposedChanges.length > 0;
const hasMultiple = proposedChanges && proposedChanges.length > 1;
const currentProposal = proposedChanges?.[currentIndex];
const goToPrev = () => setCurrentIndex((i) => Math.max(0, i - 1));
const goToNext = () =>
setCurrentIndex((i) => Math.min((proposedChanges?.length || 1) - 1, i + 1));
const canGoPrev = currentIndex > 0;
const canGoNext = currentIndex < (proposedChanges?.length || 1) - 1;
return (
<ChatBubble
side={side}
testID={`chat-bubble-${side}`}
style={{
maxWidth: "80%",
minWidth: hasProposals ? "75%" : undefined,
}}
>
<Text className="p-2" style={{ color: theme.textPrimary }}>
{content}
</Text>
{hasProposals && currentProposal && onConfirm && onReject && onEdit && (
<View>
{/* Event card with optional navigation arrows */}
<View className="flex-row items-center">
{/* Left arrow */}
{hasMultiple && (
<Pressable
onPress={goToPrev}
disabled={!canGoPrev}
className="p-1"
style={{ opacity: canGoPrev ? 1 : 0.3 }}
>
<Ionicons name="chevron-back" size={24} color={theme.primeFg} />
</Pressable>
)}
{/* Event Card */}
<View className="flex-1">
<ProposedEventCard
proposedChange={currentProposal}
onConfirm={(proposal) => onConfirm(proposal.id, proposal)}
onReject={() => onReject(currentProposal.id)}
onEdit={(proposal) => onEdit(proposal.id, proposal)}
/>
</View>
{/* Right arrow */}
{hasMultiple && (
<Pressable
onPress={goToNext}
disabled={!canGoNext}
className="p-1"
style={{ opacity: canGoNext ? 1 : 0.3 }}
>
<Ionicons
name="chevron-forward"
size={24}
color={theme.primeFg}
/>
</Pressable>
)}
</View>
{/* Event counter */}
{hasMultiple && (
<Text
className="text-center text-sm pb-2"
style={{ color: theme.textSecondary || "#666" }}
>
Event {currentIndex + 1} von {proposedChanges.length}
</Text>
)}
</View>
)}
</ChatBubble>
);
};
export default Chat;

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

@@ -1,308 +0,0 @@
import { Animated, Modal, Pressable, Text, View } from "react-native";
import { DAYS, MONTHS, Month } from "../Constants";
import Header from "../components/Header";
import React, { useEffect, useMemo, useRef, useState } from "react";
import currentTheme from "../Themes";
import BaseBackground from "../components/BaseBackground";
import { FlashList } from "@shopify/flash-list";
// TODO: month selection dropdown menu
const Calendar = () => {
const [monthIndex, setMonthIndex] = useState(0);
const [currentYear, setCurrentYear] = useState(new Date().getFullYear());
const changeMonth = (delta: number) => {
setMonthIndex((prev) => {
const newIndex = prev + delta;
if (newIndex > 11) {
setCurrentYear((y) => y + 1);
return 0;
}
if (newIndex < 0) {
setCurrentYear((y) => y - 1);
return 11;
}
return newIndex;
});
};
return (
<BaseBackground>
<CalendarHeader
changeMonth={changeMonth}
monthIndex={monthIndex}
currentYear={currentYear}
/>
<WeekDaysLine />
<CalendarGrid month={MONTHS[monthIndex]} year={currentYear} />
</BaseBackground>
);
};
type MonthSelectorProps = {
modalVisible: boolean;
onClose: () => void;
position: { top: number; left: number; width: number };
};
const MonthSelector = ({
modalVisible,
onClose,
position,
}: MonthSelectorProps) => {
const heightAnim = useRef(new Animated.Value(0)).current;
type ItemType = { id: string; text: string };
const listRef = useRef<React.ComponentRef<typeof FlashList<ItemType>>>(null);
const [monthSelectorData, setMonthSelectorData] = useState(() => {
const initial = [];
for (let i = 1; i <= 10; i++) {
initial.push({ id: i.toString(), text: `number ${i}` });
}
return initial;
});
const appendToTestData = (
startIndex: number,
numberOfEntries: number,
appendToStart: boolean,
) => {
// create new data
const newData = [];
for (let i = 0; i < numberOfEntries; i++) {
const newIndex = startIndex + i + 1;
const newEntry = {
id: newIndex + "",
text: `number ${newIndex}`,
};
if (appendToStart) {
newData.unshift(newEntry);
} else {
newData.push(newEntry);
}
}
// add new data
if (appendToStart) {
setMonthSelectorData([...newData, ...monthSelectorData]);
} else {
setMonthSelectorData([...monthSelectorData, ...newData]);
}
};
useEffect(() => {
if (modalVisible) {
Animated.timing(heightAnim, {
toValue: 200,
duration: 200,
useNativeDriver: false,
}).start();
} else {
// reset on close
heightAnim.setValue(0);
}
}, [modalVisible]);
const renderItem = ({ item }: { item: ItemType }) => (
<Pressable>
<View className="w-full flex justify-center items-center">
<Text className="text-3xl">{item.text}</Text>
</View>
</Pressable>
);
return (
<Modal
visible={modalVisible}
transparent={true}
animationType="none"
onRequestClose={onClose}
>
<Pressable className="flex-1" onPress={onClose}>
<Animated.View
className="absolute bg-white border-2 border-solid rounded-lg overflow-hidden"
style={{
top: position.top,
left: position.left,
width: position.width,
height: heightAnim,
}}
>
<FlashList
className="w-full"
ref={listRef}
keyExtractor={(item) => item.id}
data={monthSelectorData}
initialScrollIndex={5}
onEndReachedThreshold={0.5}
onEndReached={() =>
appendToTestData(monthSelectorData.length, 10, false)
}
onStartReachedThreshold={0.5}
onStartReached={() =>
appendToTestData(monthSelectorData.length, 10, true)
}
renderItem={renderItem}
/>
</Animated.View>
</Pressable>
</Modal>
);
};
type CalendarHeaderProps = {
changeMonth: (delta: number) => void;
monthIndex: number;
currentYear: number;
};
const CalendarHeader = (props: CalendarHeaderProps) => {
const [modalVisible, setModalVisible] = useState(false);
const [dropdownPosition, setDropdownPosition] = useState({
top: 0,
left: 0,
width: 0,
});
const containerRef = useRef<View>(null);
const prevMonth = () => props.changeMonth(-1);
const nextMonth = () => props.changeMonth(1);
const measureAndOpen = () => {
containerRef.current?.measureInWindow((x, y, width, height) => {
setDropdownPosition({ top: y + height, left: x, width });
setModalVisible(true);
});
};
return (
<Header className="flex flex-row items-center justify-between">
<ChangeMonthButton onPress={prevMonth} title={"<"} />
<View
ref={containerRef}
className="relative flex flex-row items-center justify-around"
>
<Text className="text-4xl">
{MONTHS[props.monthIndex]} {props.currentYear}
</Text>
<Pressable
className={
"flex justify-center items-center bg-white w-12 h-12 p-2 " +
"border border-solid rounded-full ml-2"
}
style={{
borderColor: currentTheme.primeFg,
}}
onPress={measureAndOpen}
>
<Text className="text-4xl">v</Text>
</Pressable>
</View>
<MonthSelector
modalVisible={modalVisible}
onClose={() => setModalVisible(false)}
position={dropdownPosition}
/>
<ChangeMonthButton onPress={nextMonth} title={">"} />
</Header>
);
};
type ChangeMonthButtonProps = {
onPress: () => void;
title: string;
};
const ChangeMonthButton = (props: ChangeMonthButtonProps) => (
<Pressable
onPress={props.onPress}
className={
"w-16 h-16 bg-white rounded-full flex items-center " +
"justify-center border border-solid border-1 mx-2"
}
style={{
borderColor: currentTheme.primeFg,
}}
>
<Text className="text-4xl">{props.title}</Text>
</Pressable>
);
const WeekDaysLine = () => (
<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 */}
{DAYS.map((day, i) => (
<Text key={i}>{day.substring(0, 2).toUpperCase()}</Text>
))}
</View>
);
type CalendarGridProps = {
month: Month;
year: number;
};
const CalendarGrid = (props: CalendarGridProps) => {
const { baseDate, dateOffset } = useMemo(() => {
const monthIndex = MONTHS.indexOf(props.month);
const base = new Date(props.year, monthIndex, 1);
const offset = base.getDay() === 0 ? 6 : base.getDay() - 1;
return { baseDate: base, dateOffset: offset };
}, [props.month, props.year]);
// TODO: create array beforehand in a useMemo
const createDateFromOffset = (offset: number): Date => {
const date = new Date(baseDate);
date.setDate(date.getDate() + offset);
return date;
};
return (
<View
className="h-full flex-1 flex-col flex-wrap gap-2 p-2"
style={{
backgroundColor: currentTheme.calenderBg,
}}
>
{Array.from({ length: 6 }).map((_, i) => (
<View
key={i}
className="w-full flex-1 flex-row justify-around items-center gap-2"
>
{Array.from({ length: 7 }).map((_, j) => (
<SingleDay
key={j}
date={createDateFromOffset(i * 7 + j - dateOffset)}
month={props.month}
/>
))}
</View>
))}
</View>
);
};
type SingleDayProps = {
date: Date;
month: Month;
};
const SingleDay = (props: SingleDayProps) => {
const isSameMonth = MONTHS[props.date.getMonth()] === props.month;
return (
<View
className="h-full flex-1 aspect-auto rounded-xl items-center"
style={{
backgroundColor: currentTheme.primeBg,
}}
>
<Text
className={`text-xl ` + (isSameMonth ? "text-black" : "text-black/50")}
>
{props.date.getDate()}
</Text>
</View>
);
};
export default Calendar;

View File

@@ -1,349 +0,0 @@
import { View, Text, TextInput } from "react-native";
import currentTheme from "../Themes";
import { useState } from "react";
import Header from "../components/Header";
import BaseBackground from "../components/BaseBackground";
import { FlashList } from "@shopify/flash-list";
// TODO: better shadows for everything
// (maybe with extra library because of differences between android and ios)
// TODO: max width for messages
// TODO: create new messages
type BubbleSide = "left" | "right";
type ChatMessageProps = {
side: BubbleSide;
width: number;
height: number;
};
type MessageData = {
id: string;
side: BubbleSide;
width: number;
height: number;
};
// NOTE: only for testing
const getRandomInt = (min: number, max: number) => {
min = Math.ceil(min);
max = Math.floor(max);
return Math.floor(Math.random() * (max - min + 1)) + min;
};
const randomWidth = () => getRandomInt(100, 400);
const randomHeight = () => getRandomInt(50, 100);
const messages: MessageData[] = [
// {{{
{
id: "1",
side: "left",
width: randomWidth(),
height: randomHeight(),
},
{
id: "2",
side: "right",
width: randomWidth(),
height: randomHeight(),
},
{
id: "3",
side: "left",
width: randomWidth(),
height: randomHeight(),
},
{
id: "4",
side: "right",
width: randomWidth(),
height: randomHeight(),
},
{
id: "5",
side: "left",
width: randomWidth(),
height: randomHeight(),
},
{
id: "6",
side: "right",
width: randomWidth(),
height: randomHeight(),
},
{
id: "7",
side: "left",
width: randomWidth(),
height: randomHeight(),
},
{
id: "8",
side: "right",
width: randomWidth(),
height: randomHeight(),
},
{
id: "9",
side: "left",
width: randomWidth(),
height: randomHeight(),
},
{
id: "10",
side: "right",
width: randomWidth(),
height: randomHeight(),
},
// {
// id: "11",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "12",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "13",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "14",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "15",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "16",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "17",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "18",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "19",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "20",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "21",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "22",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "23",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "24",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "25",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "26",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "27",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "28",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "29",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "30",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "31",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "32",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "33",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "34",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
//, width: randomWidth, height: getRandomInt(50, 500) }}}
];
const Chat = () => {
return (
<BaseBackground>
<ChatHeader />
<FlashList
data={messages}
renderItem={({ item }) => (
<ChatMessage
side={item.side}
width={item.width}
height={item.height}
/>
)}
maintainVisibleContentPosition={{
autoscrollToBottomThreshold: 0.2,
startRenderingFromBottom: true,
}}
keyExtractor={(item) => item.id}
// extraData={selectedId} might need this later for re-rendering
/>
<ChatInput />
</BaseBackground>
);
};
const ChatHeader = () => {
return (
<Header className="flex flex-row items-center">
<View
className="ml-3 w-12 h-12 rounded-3xl border border-solid"
style={{
backgroundColor: currentTheme.placeholderBg,
borderColor: currentTheme.primeFg,
}}
></View>
<Text className="text-lg pl-3">CalChat</Text>
<View
className="h-2 bg-black"
style={{
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 5,
},
shadowOpacity: 0.34,
shadowRadius: 6.27,
elevation: 10,
}}
/>
</Header>
);
};
const ChatInput = () => {
const [text, onChangeText] = useState("Nachricht");
return (
<View className="flex flex-row w-full h-8 my-2">
<TextInput
className="w-4/5 h-full border border-solid rounded-2xl mx-2 px-2"
style={{
backgroundColor: currentTheme.messageBorderBg,
}}
onChangeText={onChangeText}
value={text}
/>
<View
className="w-8 h-full rounded-2xl"
style={{
backgroundColor: currentTheme.placeholderBg,
}}
></View>
</View>
);
};
const ChatMessage = (props: ChatMessageProps) => {
const borderColor =
props.side === "left" ? currentTheme.chatBot : currentTheme.primeFg;
const selfSide =
props.side === "left"
? "self-start ml-2 rounded-bl-sm"
: "self-end mr-2 rounded-br-sm";
return (
<View
className={
`bg-white border-2 border-solid rounded-xl my-2 ` + `${selfSide}`
}
style={{
width: props.width,
height: props.height,
borderColor: borderColor,
elevation: 8,
}}
>
<Text className="p-1">Lorem Ipsum Dolor sit amet</Text>
</View>
);
};
export default Chat;

View File

@@ -1,49 +0,0 @@
import React, { useState } from 'react';
import { Button, Text, View } from 'react-native';
// const styles = StyleSheet.create({
// container: {
// alignItems: 'center',
// },
// text: {
// fontSize: 40
// }
// })
type HelloWorldProps = {
text: string;
aNumber: number;
}
const HelloWorld = (props: HelloWorldProps) => {
return (
<View className='flex-1 items-center justify-center'>
<Text>{props.text} : {props.aNumber}</Text>
</View>
)
}
const Counter = () => {
const [count, setCount] = useState<number>(0);
return (
<View>
<Button
onPress={() => setCount(count + 1)}
title={`You tabbed me ${count} times`}/>
</View>
)
}
const ManyHelloes = () => {
return (
<View>
<HelloWorld text="first number" aNumber={1}/>
<HelloWorld text="second number" aNumber={2}/>
<HelloWorld text="third number" aNumber={3}/>
<Counter/>
</View>
)
}
export default ManyHelloes;

View File

@@ -2,5 +2,14 @@ import { Stack } from "expo-router";
import "../../global.css";
export default function RootLayout() {
return <Stack screenOptions={{ headerShown: false }} />;
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(tabs)" />
<Stack.Screen name="login" />
<Stack.Screen name="register" />
<Stack.Screen name="editEvent" />
{/* <Stack.Screen name="event/[id]" /> */}
{/* <Stack.Screen name="note/[id]" /> */}
</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

@@ -0,0 +1,72 @@
import { View, Text, TextInput, Pressable } from "react-native";
import { useLocalSearchParams } from "expo-router";
import BaseBackground from "../../components/BaseBackground";
import { useThemeStore } from "../../stores/ThemeStore";
const EventDetailScreen = () => {
const { id } = useLocalSearchParams<{ id: string }>();
const { theme } = useThemeStore();
// TODO: Fetch event by id using EventService.getById()
// TODO: Display event details (title, description, start/end time)
// TODO: Edit mode toggle
// TODO: Save changes -> EventService.update()
// TODO: Delete button -> EventService.delete()
// TODO: Link to NoteScreen for this event
// TODO: Loading and error states
throw new Error("Not implemented");
return (
<BaseBackground>
<View className="flex-1 p-4">
<Text className="text-2xl mb-4" style={{ color: theme.textPrimary }}>
Event Detail
</Text>
<Text className="mb-4" style={{ color: theme.textSecondary }}>
ID: {id}
</Text>
<TextInput
placeholder="Title"
placeholderTextColor={theme.textMuted}
className="w-full border rounded p-2 mb-4"
style={{
color: theme.textPrimary,
borderColor: theme.borderPrimary,
backgroundColor: theme.secondaryBg,
}}
/>
<TextInput
placeholder="Description"
placeholderTextColor={theme.textMuted}
multiline
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">
<Pressable
className="p-3 rounded flex-1"
style={{ backgroundColor: theme.confirmButton }}
>
<Text className="text-center" style={{ color: theme.buttonText }}>
Save
</Text>
</Pressable>
<Pressable
className="p-3 rounded flex-1"
style={{ backgroundColor: theme.rejectButton }}
>
<Text className="text-center" style={{ color: theme.buttonText }}>
Delete
</Text>
</Pressable>
</View>
</View>
</BaseBackground>
);
};
export default EventDetailScreen;

View File

@@ -1,10 +1,6 @@
import React from "react";
import Chat from "./Chat";
import Calender from "./Calender";
import { Redirect } from "expo-router";
export default function Index() {
return (
// <Chat />
<Calender />
);
// AuthGuard in (tabs)/_layout.tsx handles authentication
return <Redirect href="/(tabs)/chat" />;
}

View File

@@ -0,0 +1,104 @@
import { useState } from "react";
import { View, Text, Pressable } from "react-native";
import { Link, router } from "expo-router";
import BaseBackground from "../components/BaseBackground";
import AuthButton from "../components/AuthButton";
import CustomTextInput from "../components/CustomTextInput";
import { AuthService } from "../services";
import { CaldavConfigService } from "../services/CaldavConfigService";
import { preloadAppData } from "../components/AuthGuard";
import { useThemeStore } from "../stores/ThemeStore";
const LoginScreen = () => {
const { theme } = useThemeStore();
const [identifier, setIdentifier] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const handleLogin = async () => {
setError(null);
if (!identifier || !password) {
setError("Bitte alle Felder ausfüllen");
return;
}
setIsLoading(true);
try {
await AuthService.login({ identifier, password });
await preloadAppData();
try {
await CaldavConfigService.sync();
} catch {
// No CalDAV config or sync failed — not critical
}
router.replace("/(tabs)/chat");
} catch {
setError("Anmeldung fehlgeschlagen. Überprüfe deine Zugangsdaten.");
} finally {
setIsLoading(false);
}
};
return (
<BaseBackground>
<View className="flex-1 justify-center items-center p-8">
<Text
testID="login-title"
className="text-3xl font-bold mb-8"
style={{ color: theme.textPrimary }}
>
Anmelden
</Text>
{error && (
<Text
testID="login-error-text"
className="mb-4 text-center"
style={{ color: theme.rejectButton }}
>
{error}
</Text>
)}
<CustomTextInput
testID="login-identifier-input"
placeholder="E-Mail oder Benutzername"
placeholderTextColor={theme.textMuted}
text={identifier}
onValueChange={setIdentifier}
autoCapitalize="none"
className="w-full rounded-lg p-4 mb-4"
/>
<CustomTextInput
testID="login-password-input"
placeholder="Passwort"
placeholderTextColor={theme.textMuted}
text={password}
onValueChange={setPassword}
secureTextEntry
className="w-full rounded-lg p-4 mb-6"
/>
<AuthButton
testID="login-button"
title="Anmelden"
onPress={handleLogin}
isLoading={isLoading}
/>
<Link href="/register" asChild>
<Pressable>
<Text style={{ color: theme.chatBot }}>
Noch kein Konto? Registrieren
</Text>
</Pressable>
</Link>
</View>
</BaseBackground>
);
};
export default LoginScreen;

View File

@@ -0,0 +1,51 @@
import { View, Text, TextInput, Pressable } from "react-native";
import { useLocalSearchParams } from "expo-router";
import BaseBackground from "../../components/BaseBackground";
import { useThemeStore } from "../../stores/ThemeStore";
const NoteScreen = () => {
const { id } = useLocalSearchParams<{ id: string }>();
const { theme } = useThemeStore();
// TODO: Fetch event by id using EventService.getById()
// TODO: Display and edit the event's note field
// TODO: Auto-save or manual save button
// TODO: Save changes -> EventService.update({ note: ... })
// TODO: Loading and error states
throw new Error("Not implemented");
return (
<BaseBackground>
<View className="flex-1 p-4">
<Text className="text-2xl mb-4" style={{ color: theme.textPrimary }}>
Note
</Text>
<Text className="mb-4" style={{ color: theme.textSecondary }}>
Event ID: {id}
</Text>
<TextInput
placeholder="Write your note here..."
placeholderTextColor={theme.textMuted}
multiline
className="w-full border rounded p-2 flex-1 mb-4"
textAlignVertical="top"
style={{
color: theme.textPrimary,
borderColor: theme.borderPrimary,
backgroundColor: theme.secondaryBg,
}}
/>
<Pressable
className="p-3 rounded"
style={{ backgroundColor: theme.confirmButton }}
>
<Text className="text-center" style={{ color: theme.buttonText }}>
Save Note
</Text>
</Pressable>
</View>
</BaseBackground>
);
};
export default NoteScreen;

View File

@@ -0,0 +1,109 @@
import { useState } from "react";
import { View, Text, Pressable } from "react-native";
import { Link, router } from "expo-router";
import BaseBackground from "../components/BaseBackground";
import AuthButton from "../components/AuthButton";
import CustomTextInput from "../components/CustomTextInput";
import { AuthService } from "../services";
import { useThemeStore } from "../stores/ThemeStore";
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const RegisterScreen = () => {
const { theme } = useThemeStore();
const [email, setEmail] = useState("");
const [userName, setUserName] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const handleRegister = async () => {
setError(null);
if (!email || !userName || !password) {
setError("Bitte alle Felder ausfüllen");
return;
}
if (!EMAIL_REGEX.test(email)) {
setError("Bitte eine gültige E-Mail-Adresse eingeben");
return;
}
setIsLoading(true);
try {
await AuthService.register({ email, userName, password });
router.replace("/(tabs)/chat");
} catch {
setError("Registrierung fehlgeschlagen. E-Mail bereits vergeben?");
} finally {
setIsLoading(false);
}
};
return (
<BaseBackground>
<View className="flex-1 justify-center items-center p-8">
<Text
className="text-3xl font-bold mb-8"
style={{ color: theme.textPrimary }}
>
Registrieren
</Text>
{error && (
<Text
className="mb-4 text-center"
style={{ color: theme.rejectButton }}
>
{error}
</Text>
)}
<CustomTextInput
placeholder="E-Mail"
placeholderTextColor={theme.textMuted}
text={email}
onValueChange={setEmail}
autoCapitalize="none"
keyboardType="email-address"
className="w-full rounded-lg p-4 mb-4"
/>
<CustomTextInput
placeholder="Benutzername"
placeholderTextColor={theme.textMuted}
text={userName}
onValueChange={setUserName}
autoCapitalize="none"
className="w-full rounded-lg p-4 mb-4"
/>
<CustomTextInput
placeholder="Passwort"
placeholderTextColor={theme.textMuted}
text={password}
onValueChange={setPassword}
secureTextEntry
className="w-full rounded-lg p-4 mb-6"
/>
<AuthButton
title="Registrieren"
onPress={handleRegister}
isLoading={isLoading}
/>
<Link href="/login" asChild>
<Pressable>
<Text style={{ color: theme.chatBot }}>
Bereits ein Konto? Anmelden
</Text>
</Pressable>
</Link>
</View>
</BaseBackground>
);
};
export default RegisterScreen;

View File

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

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,23 +1,24 @@
import { View } from "react-native"
import currentTheme from "../Themes"
import { View } from "react-native";
import { useThemeStore } from "../stores/ThemeStore";
import { ReactNode } from "react";
type BaseBackgroundProps = {
children?: ReactNode;
className?: string;
}
};
const BaseBackground = (props: BaseBackgroundProps) => {
const { theme } = useThemeStore();
return (
<View
className={`h-full ${props.className}`}
style={{
backgroundColor: currentTheme.primeBg,
backgroundColor: theme.primeBg,
}}
>
{props.children}
</View>
)
}
);
};
export default BaseBackground;

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

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

View File

@@ -0,0 +1,126 @@
import { View, Text } from "react-native";
import { Feather } from "@expo/vector-icons";
import { ReactNode } from "react";
import { useThemeStore } from "../stores/ThemeStore";
import { CardBase } from "./CardBase";
import {
isMultiDayEvent,
formatDateWithWeekday,
formatDateWithWeekdayShort,
formatTime,
formatRecurrenceRule,
} from "@calchat/shared";
type EventCardBaseProps = {
className?: string;
title: string;
startTime: Date;
endTime: Date;
description?: string;
recurrenceRule?: string;
children?: ReactNode;
};
function formatDuration(start: Date, end: Date): string {
const startDate = new Date(start);
const endDate = new Date(end);
const diffMs = endDate.getTime() - startDate.getTime();
const diffMins = Math.round(diffMs / 60000);
if (diffMins < 60) {
return `${diffMins} min`;
}
const hours = Math.floor(diffMins / 60);
const mins = diffMins % 60;
if (mins === 0) {
return hours === 1 ? "1 Std" : `${hours} Std`;
}
return `${hours} Std ${mins} min`;
}
export const EventCardBase = ({
className,
title,
startTime,
endTime,
description,
recurrenceRule,
children,
}: EventCardBaseProps) => {
const { theme } = useThemeStore();
const multiDay = isMultiDayEvent(startTime, endTime);
return (
<CardBase title={title} className={className} borderWidth={2}>
{/* Date */}
<View className="flex-row items-center mb-1">
<Feather
name="calendar"
size={16}
color={theme.textPrimary}
style={{ marginRight: 8 }}
/>
{multiDay ? (
<Text style={{ color: theme.textPrimary }}>
{formatDateWithWeekdayShort(startTime)} {" "}
{formatDateWithWeekday(endTime)}
</Text>
) : (
<Text style={{ color: theme.textPrimary }}>
{formatDateWithWeekday(startTime)}
</Text>
)}
</View>
{/* Time with duration */}
<View className="flex-row items-center mb-1">
<Feather
name="clock"
size={16}
color={theme.textPrimary}
style={{ marginRight: 8 }}
/>
{multiDay ? (
<Text style={{ color: theme.textPrimary }}>
{formatTime(startTime)} {formatTime(endTime)}
</Text>
) : (
<Text style={{ color: theme.textPrimary }}>
{formatTime(startTime)} - {formatTime(endTime)} (
{formatDuration(startTime, endTime)})
</Text>
)}
</View>
{/* Recurring indicator */}
{recurrenceRule && (
<View className="flex-row items-center mb-1">
<Feather
name="repeat"
size={16}
color={theme.textPrimary}
style={{ marginRight: 8 }}
/>
<Text style={{ color: theme.textPrimary }}>
{formatRecurrenceRule(recurrenceRule)}
</Text>
</View>
)}
{/* Description */}
{description && (
<Text style={{ color: theme.textPrimary }} className="text-sm mt-1">
{description}
</Text>
)}
{/* Action buttons slot */}
{children}
</CardBase>
);
};
export default EventCardBase;

View File

@@ -0,0 +1,41 @@
import { View, Text, Modal, Pressable } from "react-native";
import { CreateEventDTO } from "@calchat/shared";
import { useThemeStore } from "../stores/ThemeStore";
type EventConfirmDialogProps = {
visible: boolean;
proposedEvent: CreateEventDTO | null;
onConfirm: () => void;
onReject: () => void;
onClose: () => void;
};
const EventConfirmDialog = ({
visible: _visible,
proposedEvent: _proposedEvent,
onConfirm: _onConfirm,
onReject: _onReject,
onClose: _onClose,
}: EventConfirmDialogProps) => {
const { theme } = useThemeStore();
// TODO: Display proposed event details (title, time, description)
// TODO: Confirm button calls onConfirm and closes dialog
// TODO: Reject button calls onReject and closes dialog
// TODO: Close button or backdrop tap calls onClose
throw new Error("Not implemented");
return (
<Modal visible={false} transparent animationType="fade">
<View>
<Pressable>
<Text style={{ color: theme.textPrimary }}>
EventConfirmDialog - Not Implemented
</Text>
</Pressable>
</View>
</Modal>
);
};
export default EventConfirmDialog;

View File

@@ -1,6 +1,7 @@
import { View } from "react-native";
import currentTheme from "../Themes";
import { ReactNode } from "react";
import { View, Text, Pressable } from "react-native";
import { useThemeStore } from "../stores/ThemeStore";
import { ComponentProps, ReactNode } from "react";
import { Ionicons } from "@expo/vector-icons";
type HeaderProps = {
children?: ReactNode;
@@ -8,12 +9,13 @@ type HeaderProps = {
};
const Header = (props: HeaderProps) => {
const { theme } = useThemeStore();
return (
<View>
<View
className={`w-full h-32 pt-10 pb-4 ${props.className}`}
style={{
backgroundColor: currentTheme.chatBot,
backgroundColor: theme.chatBot,
}}
>
{props.children}
@@ -21,7 +23,7 @@ const Header = (props: HeaderProps) => {
<View
className="h-2 bg-black"
style={{
shadowColor: "#000",
shadowColor: theme.shadowColor,
shadowOffset: {
width: 0,
height: 5,
@@ -36,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;

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

@@ -0,0 +1,189 @@
import { View, Text, Pressable } from "react-native";
import { Feather, Ionicons } from "@expo/vector-icons";
import { ProposedEventChange, formatDate, formatTime } from "@calchat/shared";
import { rrulestr } from "rrule";
import { useThemeStore } from "../stores/ThemeStore";
import { EventCardBase } from "./EventCardBase";
type ProposedEventCardProps = {
proposedChange: ProposedEventChange;
onConfirm: (proposal: ProposedEventChange) => void;
onReject: () => void;
onEdit?: (proposal: ProposedEventChange) => void;
};
const ActionButtons = ({
isDisabled,
respondedAction,
showEdit,
onConfirm,
onReject,
onEdit,
}: {
isDisabled: boolean;
respondedAction?: "confirm" | "reject";
showEdit: boolean;
onConfirm: () => void;
onReject: () => void;
onEdit?: () => void;
}) => {
const { theme } = useThemeStore();
return (
<View className="flex-row mt-3 gap-2">
<Pressable
testID="event-accept-button"
onPress={onConfirm}
disabled={isDisabled}
className="flex-1 py-2 rounded-lg items-center"
style={{
backgroundColor: isDisabled
? theme.disabledButton
: theme.confirmButton,
borderWidth: respondedAction === "confirm" ? 2 : 0,
borderColor: theme.confirmButton,
}}
>
<Text style={{ color: theme.buttonText }} className="font-medium">
Annehmen
</Text>
</Pressable>
<Pressable
testID="event-reject-button"
onPress={onReject}
disabled={isDisabled}
className="flex-1 py-2 rounded-lg items-center"
style={{
backgroundColor: isDisabled
? theme.disabledButton
: theme.rejectButton,
borderWidth: respondedAction === "reject" ? 2 : 0,
borderColor: theme.rejectButton,
}}
>
<Text style={{ color: theme.buttonText }} className="font-medium">
Ablehnen
</Text>
</Pressable>
{showEdit && onEdit && (
<Pressable
onPress={onEdit}
className="py-2 px-3 rounded-lg items-center"
style={{
backgroundColor: theme.secondaryBg,
borderWidth: 1,
borderColor: theme.borderPrimary,
}}
>
<Feather name="edit-2" size={18} color={theme.textPrimary} />
</Pressable>
)}
</View>
);
};
export const ProposedEventCard = ({
proposedChange,
onConfirm,
onReject,
onEdit,
}: ProposedEventCardProps) => {
const { theme } = useThemeStore();
const event = proposedChange.event;
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) {
return null;
}
return (
<View className="mt-2">
<EventCardBase
className="m-2"
title={event.title}
startTime={event.startTime}
endTime={event.endTime}
description={event.description}
recurrenceRule={event.recurrenceRule}
>
{/* 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}
respondedAction={proposedChange.respondedAction}
showEdit={proposedChange.action !== "delete" && !isDisabled}
onConfirm={() => onConfirm(proposedChange)}
onReject={onReject}
onEdit={onEdit ? () => onEdit(proposedChange) : undefined}
/>
</EventCardBase>
</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

@@ -0,0 +1 @@
export { log, apiLogger, storeLogger } from "./logger";

View File

@@ -0,0 +1,30 @@
import { logger, consoleTransport } from "react-native-logs";
const log = logger.createLogger({
levels: {
debug: 0,
info: 1,
warn: 2,
error: 3,
},
severity: __DEV__ ? "debug" : "warn",
transport: consoleTransport,
transportOptions: {
colors: {
debug: "white",
info: "blueBright",
warn: "yellowBright",
error: "redBright",
},
},
async: true,
dateFormat: "time",
printLevel: true,
printDate: true,
enabled: true,
});
export const apiLogger = log.extend("API");
export const storeLogger = log.extend("Store");
export { log };

View File

@@ -0,0 +1,107 @@
import { Platform } from "react-native";
import { apiLogger } from "../logging";
import { useAuthStore } from "../stores";
const API_BASE_URL =
process.env.EXPO_PUBLIC_API_URL ||
Platform.select({
android: "http://10.0.2.2:3001/api",
default: "http://localhost:3001/api",
});
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
interface RequestOptions {
headers?: Record<string, string>;
body?: unknown;
skipAuth?: boolean;
}
function getAuthHeaders(): Record<string, string> {
const user = useAuthStore.getState().user;
apiLogger.debug(`getAuthHeaders - user: ${JSON.stringify(user)}`);
if (user?.id) {
return { "X-User-Id": user.id };
}
return {};
}
async function request<T>(
method: HttpMethod,
endpoint: string,
options?: RequestOptions,
): Promise<T> {
const start = performance.now();
apiLogger.debug(`${method} ${endpoint}`);
try {
const authHeaders = options?.skipAuth ? {} : getAuthHeaders();
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
method,
headers: {
"Content-Type": "application/json",
...authHeaders,
...options?.headers,
},
body: options?.body ? JSON.stringify(options.body) : undefined,
});
const duration = Math.round(performance.now() - start);
if (!response.ok) {
apiLogger.error(
`${method} ${endpoint} - ${response.status} (${duration}ms)`,
);
throw new Error(`HTTP ${response.status}`);
}
apiLogger.debug(
`${method} ${endpoint} - ${response.status} (${duration}ms)`,
);
const text = await response.text();
if (!text) {
return undefined as T;
}
return JSON.parse(text);
} catch (error) {
const duration = Math.round(performance.now() - start);
apiLogger.error(`${method} ${endpoint} failed (${duration}ms): ${error}`);
throw error;
}
}
export const ApiClient = {
get: async <T>(
endpoint: string,
options?: Omit<RequestOptions, "body">,
): Promise<T> => {
return request<T>("GET", endpoint, options);
},
post: async <T>(
endpoint: string,
body?: unknown,
options?: RequestOptions,
): Promise<T> => {
return request<T>("POST", endpoint, { ...options, body });
},
put: async <T>(
endpoint: string,
body?: unknown,
options?: RequestOptions,
): Promise<T> => {
return request<T>("PUT", endpoint, { ...options, body });
},
delete: async <T>(
endpoint: string,
options?: Omit<RequestOptions, "body">,
): Promise<T> => {
return request<T>("DELETE", endpoint, options);
},
};
export { API_BASE_URL };

View File

@@ -0,0 +1,29 @@
import { LoginDTO, CreateUserDTO, AuthResponse } from "@calchat/shared";
import { ApiClient } from "./ApiClient";
import { useAuthStore } from "../stores";
export const AuthService = {
login: async (credentials: LoginDTO): Promise<AuthResponse> => {
const response = await ApiClient.post<AuthResponse>(
"/auth/login",
credentials,
{ skipAuth: true },
);
await useAuthStore.getState().login(response.user);
return response;
},
register: async (data: CreateUserDTO): Promise<AuthResponse> => {
const response = await ApiClient.post<AuthResponse>(
"/auth/register",
data,
{ skipAuth: true },
);
await useAuthStore.getState().login(response.user);
return response;
},
logout: async (): Promise<void> => {
await useAuthStore.getState().logout();
},
};

View File

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

View File

@@ -0,0 +1,99 @@
import {
SendMessageDTO,
ChatResponse,
ChatMessage,
ConversationSummary,
GetMessagesOptions,
CreateEventDTO,
UpdateEventDTO,
EventAction,
RecurringDeleteMode,
} from "@calchat/shared";
import { ApiClient } from "./ApiClient";
interface ConfirmEventRequest {
proposalId: string;
action: EventAction;
event?: CreateEventDTO;
eventId?: string;
updates?: UpdateEventDTO;
deleteMode?: RecurringDeleteMode;
occurrenceDate?: string;
}
interface RejectEventRequest {
proposalId: string;
}
export const ChatService = {
sendMessage: async (data: SendMessageDTO): Promise<ChatResponse> => {
return ApiClient.post<ChatResponse>("/chat/message", data);
},
confirmEvent: async (
conversationId: string,
messageId: string,
proposalId: string,
action: EventAction,
event?: CreateEventDTO,
eventId?: string,
updates?: UpdateEventDTO,
deleteMode?: RecurringDeleteMode,
occurrenceDate?: string,
): Promise<ChatResponse> => {
const body: ConfirmEventRequest = {
proposalId,
action,
event,
eventId,
updates,
deleteMode,
occurrenceDate,
};
return ApiClient.post<ChatResponse>(
`/chat/confirm/${conversationId}/${messageId}`,
body,
);
},
rejectEvent: async (
conversationId: string,
messageId: string,
proposalId: string,
): Promise<ChatResponse> => {
const body: RejectEventRequest = { proposalId };
return ApiClient.post<ChatResponse>(
`/chat/reject/${conversationId}/${messageId}`,
body,
);
},
getConversations: async (): Promise<ConversationSummary[]> => {
return ApiClient.get<ConversationSummary[]>("/chat/conversations");
},
getConversation: async (
id: string,
options?: GetMessagesOptions,
): Promise<ChatMessage[]> => {
const params = new URLSearchParams();
if (options?.before) params.append("before", options.before);
if (options?.limit) params.append("limit", options.limit.toString());
const queryString = params.toString();
const url = `/chat/conversations/${id}${queryString ? `?${queryString}` : ""}`;
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

@@ -0,0 +1,47 @@
import {
CalendarEvent,
CreateEventDTO,
UpdateEventDTO,
ExpandedEvent,
RecurringDeleteMode,
} from "@calchat/shared";
import { ApiClient } from "./ApiClient";
export const EventService = {
getAll: async (): Promise<CalendarEvent[]> => {
return ApiClient.get<CalendarEvent[]>("/events");
},
getById: async (id: string): Promise<CalendarEvent> => {
return ApiClient.get<CalendarEvent>(`/events/${id}`);
},
getByDateRange: async (start: Date, end: Date): Promise<ExpandedEvent[]> => {
return ApiClient.get<ExpandedEvent[]>(
`/events/range?start=${start.toISOString()}&end=${end.toISOString()}`,
);
},
create: async (data: CreateEventDTO): Promise<CalendarEvent> => {
return ApiClient.post<CalendarEvent>("/events", data);
},
update: async (id: string, data: UpdateEventDTO): Promise<CalendarEvent> => {
return ApiClient.put<CalendarEvent>(`/events/${id}`, data);
},
delete: async (
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,4 @@
export { ApiClient, API_BASE_URL } from "./ApiClient";
export { AuthService } from "./AuthService";
export { EventService } from "./EventService";
export { ChatService } from "./ChatService";

View File

@@ -0,0 +1,69 @@
import { create } from "zustand";
import { Platform } from "react-native";
import { User } from "@calchat/shared";
import * as SecureStore from "expo-secure-store";
const USER_STORAGE_KEY = "auth_user";
// SecureStore doesn't work on web, use localStorage as fallback
const storage = {
async setItem(key: string, value: string): Promise<void> {
if (Platform.OS === "web") {
localStorage.setItem(key, value);
} else {
await SecureStore.setItemAsync(key, value);
}
},
async getItem(key: string): Promise<string | null> {
if (Platform.OS === "web") {
return localStorage.getItem(key);
}
return SecureStore.getItemAsync(key);
},
async deleteItem(key: string): Promise<void> {
if (Platform.OS === "web") {
localStorage.removeItem(key);
} else {
await SecureStore.deleteItemAsync(key);
}
},
};
interface AuthState {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (user: User) => Promise<void>;
logout: () => Promise<void>;
loadStoredUser: () => Promise<void>;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
isAuthenticated: false,
isLoading: true,
login: async (user: User) => {
await storage.setItem(USER_STORAGE_KEY, JSON.stringify(user));
set({ user, isAuthenticated: true });
},
logout: async () => {
await storage.deleteItem(USER_STORAGE_KEY);
set({ user: null, isAuthenticated: false });
},
loadStoredUser: async () => {
try {
const stored = await storage.getItem(USER_STORAGE_KEY);
if (stored) {
const user = JSON.parse(stored) as User;
set({ user, isAuthenticated: true, isLoading: false });
} else {
set({ isLoading: false });
}
} catch {
set({ isLoading: false });
}
},
}));

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

@@ -0,0 +1,57 @@
import { create } from "zustand";
import { ChatMessage, ProposedEventChange } from "@calchat/shared";
type BubbleSide = "left" | "right";
export type MessageData = {
id: string;
side: BubbleSide;
content: string;
proposedChanges?: ProposedEventChange[];
conversationId?: string;
};
interface ChatState {
messages: MessageData[];
isWaitingForResponse: boolean;
addMessages: (messages: MessageData[]) => void;
addMessage: (message: MessageData) => void;
updateMessage: (id: string, updates: Partial<MessageData>) => void;
clearMessages: () => void;
setWaitingForResponse: (waiting: boolean) => void;
}
export const useChatStore = create<ChatState>((set) => ({
messages: [],
isWaitingForResponse: false,
addMessages(messages) {
set((state) => ({ messages: [...state.messages, ...messages] }));
},
addMessage: (message: MessageData) => {
set((state) => ({ messages: [...state.messages, message] }));
},
updateMessage: (id: string, updates: Partial<MessageData>) => {
set((state) => ({
messages: state.messages.map((msg) =>
msg.id === id ? { ...msg, ...updates } : msg,
),
}));
},
clearMessages: () => {
set({ messages: [] });
},
setWaitingForResponse: (waiting: boolean) => {
set({ isWaitingForResponse: waiting });
},
}));
// Helper to convert server ChatMessage to client MessageData
export function chatMessageToMessageData(msg: ChatMessage): MessageData {
return {
id: msg.id,
side: msg.sender === "assistant" ? "left" : "right",
content: msg.content,
proposedChanges: msg.proposedChanges,
conversationId: msg.conversationId,
};
}

View File

@@ -0,0 +1,30 @@
import { create } from "zustand";
import { ExpandedEvent } from "@calchat/shared";
interface EventsState {
events: ExpandedEvent[];
setEvents: (events: ExpandedEvent[]) => void;
addEvent: (event: ExpandedEvent) => void;
updateEvent: (id: string, event: Partial<ExpandedEvent>) => void;
deleteEvent: (id: string) => void;
}
export const useEventsStore = create<EventsState>((set) => ({
events: [],
setEvents: (events: ExpandedEvent[]) => {
set({ events });
},
addEvent: (event: ExpandedEvent) => {
set((state) => ({ events: [...state.events, event] }));
},
updateEvent: (id: string, updates: Partial<ExpandedEvent>) => {
set((state) => ({
events: state.events.map((e) => (e.id === id ? { ...e, ...updates } : e)),
}));
},
deleteEvent: (id: string) => {
set((state) => ({
events: state.events.filter((e) => e.id !== id),
}));
},
}));

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

@@ -0,0 +1,8 @@
export { useAuthStore } from "./AuthStore";
export {
useChatStore,
chatMessageToMessageData,
type MessageData,
} from "./ChatStore";
export { useEventsStore } from "./EventsStore";
export { useCaldavConfigStore } from "./CaldavConfigStore";

View File

@@ -2,12 +2,14 @@
"extends": "../../tsconfig.json",
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"module": "ESNext",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
"skipLibCheck": true,
"jsx": "react-native",
"moduleResolution": "bundler"
},
"include": [
"**/*.ts",

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

@@ -1 +1,2 @@
dist
.env

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

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

View File

@@ -0,0 +1,10 @@
const bcrypt = require("bcrypt");
const password = process.argv[2];
if (!password) {
console.error("Usage: node scripts/hash-password.js <password>");
process.exit(1);
}
bcrypt.hash(password, 10).then((hash) => console.log(hash));

View File

@@ -0,0 +1,137 @@
import OpenAI from "openai";
import { ProposedEventChange } from "@calchat/shared";
import { AIProvider, AIContext, AIResponse } from "../services/interfaces";
import {
buildSystemPrompt,
TOOL_DEFINITIONS,
executeToolCall,
ToolDefinition,
} from "./utils";
import { Logged } from "../logging";
import {
ChatCompletionMessageParam,
ChatCompletionMessageToolCall,
ChatCompletionTool,
} from "openai/resources/chat/completions/completions";
/**
* Convert tool definitions to OpenAI format.
*/
function toOpenAITools(
defs: ToolDefinition[],
): OpenAI.Chat.Completions.ChatCompletionTool[] {
return defs.map((def) => ({
type: "function" as const,
function: {
name: def.name,
description: def.description,
parameters: def.parameters,
},
}));
}
@Logged("GPTAdapter")
export class GPTAdapter implements AIProvider {
private client: OpenAI;
private model: string;
private tools: ChatCompletionTool[];
constructor(apiKey?: string, model: string = "gpt-5-mini") {
this.client = new OpenAI({
apiKey: apiKey || process.env.OPENAI_API_KEY,
});
this.model = model;
this.tools = toOpenAITools(TOOL_DEFINITIONS);
}
async processMessage(
message: string,
context: AIContext,
): Promise<AIResponse> {
const systemPrompt = buildSystemPrompt(context);
// Build messages array with conversation history
const messages: ChatCompletionMessageParam[] = [
{ role: "developer", content: systemPrompt },
];
// Add conversation history
for (const msg of context.conversationHistory) {
messages.push({
role: msg.sender === "user" ? "user" : "assistant",
content: msg.content,
});
}
// Add current user message
messages.push({ role: "user", content: message });
const proposedChanges: ProposedEventChange[] = [];
let proposalIndex = 0;
// Tool calling loop
while (true) {
const response = await this.client.chat.completions.create({
model: this.model,
messages,
tools: this.tools,
});
const assistantMessage = response.choices[0].message;
// If no tool calls, return the final response
if (
!assistantMessage.tool_calls ||
assistantMessage.tool_calls.length === 0
) {
return {
content:
assistantMessage.content || "Ich konnte keine Antwort generieren.",
proposedChanges:
proposedChanges.length > 0 ? proposedChanges : undefined,
};
}
// Process all tool calls and collect results
const toolResults: Array<{
toolCall: ChatCompletionMessageToolCall;
content: string;
}> = [];
for (const toolCall of assistantMessage.tool_calls) {
if (toolCall.type !== "function") continue;
const { name, arguments: argsRaw } = toolCall.function;
const args = JSON.parse(argsRaw);
const result = await executeToolCall(name, args, context);
// Collect proposed changes
if (result.proposedChange) {
proposedChanges.push({
id: `proposal-${proposalIndex++}`,
...result.proposedChange,
});
}
toolResults.push({ toolCall, content: result.content });
}
// Add assistant message with ALL tool calls at once
messages.push({
role: "assistant",
tool_calls: assistantMessage.tool_calls,
content: assistantMessage.content,
});
// Add all tool results
for (const { toolCall, content } of toolResults) {
messages.push({
role: "tool",
tool_call_id: toolCall.id,
content,
});
}
}
}
}

View File

@@ -0,0 +1 @@
export * from "./GPTAdapter";

View File

@@ -0,0 +1,7 @@
export { buildSystemPrompt } from "./systemPrompt";
export {
TOOL_DEFINITIONS,
type ToolDefinition,
type ParameterDef,
} from "./toolDefinitions";
export { executeToolCall, type ToolResult } from "./toolExecutor";

View File

@@ -0,0 +1,75 @@
import { AIContext } from "../../services/interfaces";
/**
* Build the system prompt for the AI assistant.
* This prompt is provider-agnostic and can be used with any LLM.
*/
export function buildSystemPrompt(context: AIContext): string {
const currentDate = context.currentDate.toLocaleDateString("de-DE", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
return `Du bist ein hilfreicher Kalender-Assistent für die App "CalChat".
Du hilfst Benutzern beim Erstellen, Ändern und Löschen von Terminen.
Antworte immer auf Deutsch.
Aktuelles Datum und Uhrzeit: ${currentDate}
Wichtige Regeln:
- Nutze getDay() für relative Datumsberechnungen (z.B. "nächsten Freitag um 14 Uhr")
- Wenn der Benutzer einen Termin erstellen will, nutze proposeCreateEvent
- Wenn der Benutzer einen Termin ändern will, nutze proposeUpdateEvent mit der Event-ID
- Wenn der Benutzer einen Termin löschen will, nutze proposeDeleteEvent mit der Event-ID
- Du kannst mehrere Event-Vorschläge in einer Antwort machen (z.B. für mehrere Termine auf einmal)
- WICHTIG: Bei Terminen in der VERGANGENHEIT: Weise den Benutzer darauf hin und erstelle KEIN Event. Beispiel: "Das Datum liegt in der Vergangenheit. Meintest du vielleicht [nächstes Jahr]?"
- KRITISCH: Wenn ein Tool-Result eine ⚠️-Warnung enthält (z.B. "Zeitkonflikt mit..."), MUSST du diese dem Benutzer mitteilen! Ignoriere NIEMALS solche Warnungen! Beispiel: "An diesem Tag hast du bereits 'Jannes Geburtstag'. Soll ich den Termin trotzdem erstellen?"
WICHTIG - Event-Abfragen:
- Du hast KEINEN vorgeladenen Kalender-Kontext!
- Nutze IMMER getEventsInRange um Events zu laden, wenn der Benutzer nach Terminen fragt
- Nutze searchEvents um nach Terminen per Titel zu suchen (gibt auch die Event-ID zurück)
- Beispiel: "Was habe ich heute?" → getEventsInRange für heute
- Beispiel: "Was habe ich diese Woche?" → getEventsInRange für die Woche
- Beispiel: "Wann ist der Zahnarzt?" → searchEvents mit "Zahnarzt"
WICHTIG - Tool-Verwendung:
- Du MUSST die proposeCreateEvent/proposeUpdateEvent/proposeDeleteEvent Tools verwenden, um Termine vorzuschlagen!
- Sage NIEMALS einfach nur "ich habe einen Termin erstellt" ohne das Tool zu verwenden
- Die Tools erzeugen Karten, die dem Benutzer angezeigt werden - ohne Tool-Aufruf sieht er nichts
WICHTIG - Wiederkehrende Termine (RRULE):
- Ein wiederkehrendes Event hat EINE FESTE Start- und Endzeit
- RRULE bestimmt NUR an welchen Tagen das Event wiederholt wird, NICHT unterschiedliche Uhrzeiten pro Tag!
- Wenn der Benutzer UNTERSCHIEDLICHE ZEITEN an verschiedenen Tagen will, MUSST du SEPARATE Events erstellen
- Beispiel: "Arbeit Mo+Do 9-17:30, Fr 9-13" → ZWEI Events:
1. "Arbeit" Mo+Do 9:00-17:30 (RRULE mit BYDAY=MO,TH)
2. "Arbeit" Fr 9:00-13:00 (RRULE mit BYDAY=FR)
- Nutze NIEMALS BYHOUR/BYMINUTE in RRULE - diese überschreiben die Startzeit nicht wie erwartet!
- Gültige RRULE-Optionen: FREQ (DAILY/WEEKLY/MONTHLY/YEARLY), BYDAY (MO,TU,WE,TH,FR,SA,SU), INTERVAL, COUNT, UNTIL
- UNTIL Format: YYYYMMDDTHHMMSSZ (UTC) z.B. UNTIL=20260310T000000Z
- WICHTIG: Schreibe die RRULE NIEMALS in das description-Feld! Nutze IMMER das recurrenceRule-Feld!
WICHTIG - Antwortformat:
- Verwende kontextbezogene Antworten in der GEGENWARTSFORM je nach Aktion:
- Bei Termin-Erstellung: "Ich schlage folgenden Termin vor:" oder "Neuer Termin:"
- Bei Termin-Änderung: "Ich schlage folgende Änderung vor:" oder "Änderung:"
- Bei Termin-Löschung: "Ich schlage vor, diesen Termin zu löschen:" oder "Löschung:"
- WICHTIG: Verwende NIEMALS Vergangenheitsform wie "Ich habe ... vorgeschlagen" - immer Gegenwartsform!
WICHTIG - Unterscheide zwischen PROPOSALS und ABFRAGEN:
1. Bei PROPOSALS (proposeCreateEvent/proposeUpdateEvent/proposeDeleteEvent):
- Halte deine Textantworten SEHR KURZ (1-2 Sätze)
- Die Event-Details werden automatisch in Karten angezeigt
- Wiederhole NICHT die Details im Text
2. Bei ABFRAGEN (searchEvents, getEventsInRange, oder Fragen zu existierenden Terminen):
- Du MUSST die gefundenen Termine im Text nennen!
- Liste die relevanten Termine mit Titel, Datum und Uhrzeit auf
- NIEMALS Event-IDs dem Benutzer zeigen! Die IDs sind nur für dich intern
- Wenn keine Termine gefunden wurden, sage das explizit (z.B. "In diesem Zeitraum hast du keine Termine.")
- Beispiel: "Heute hast du: Zahnarzt um 10:00 Uhr, Meeting um 14:00 Uhr."`;
}

View File

@@ -0,0 +1,201 @@
/**
* Parameter definition for tool parameters.
*/
export interface ParameterDef {
type: "string" | "number" | "boolean" | "object" | "array";
description?: string;
enum?: string[];
}
/**
* Provider-agnostic tool definition format.
* Can be converted to OpenAI, Claude, or other provider formats.
*/
export interface ToolDefinition {
name: string;
description: string;
parameters: {
type: "object";
properties: Record<string, ParameterDef>;
required: string[];
};
}
/**
* All available tools for the calendar assistant.
*/
export const TOOL_DEFINITIONS: ToolDefinition[] = [
{
name: "getDay",
description:
"Get a date for a specific weekday relative to today. Returns an ISO date string.",
parameters: {
type: "object",
properties: {
day: {
type: "string",
enum: [
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday",
],
description: "The target weekday",
},
offset: {
type: "number",
description:
"1 = next occurrence, 2 = the one after, -1 = last occurrence, etc.",
},
hour: {
type: "number",
description: "Hour of day (0-23)",
},
minute: {
type: "number",
description: "Minute (0-59)",
},
},
required: ["day", "offset", "hour", "minute"],
},
},
{
name: "getCurrentDateTime",
description: "Get the current date and time as an ISO string",
parameters: {
type: "object",
properties: {},
required: [],
},
},
{
name: "proposeCreateEvent",
description:
"Propose creating a new calendar event. The user must confirm before it's saved. Call this when the user wants to create a new appointment.",
parameters: {
type: "object",
properties: {
title: {
type: "string",
description: "Event title",
},
startTime: {
type: "string",
description: "Start time as ISO date string",
},
endTime: {
type: "string",
description: "End time as ISO date string",
},
description: {
type: "string",
description: "Optional event description",
},
recurrenceRule: {
type: "string",
description: "RRULE format string for recurring events",
},
},
required: ["title", "startTime", "endTime"],
},
},
{
name: "proposeUpdateEvent",
description:
"Propose updating an existing event. The user must confirm. Use this when the user wants to modify an appointment.",
parameters: {
type: "object",
properties: {
eventId: {
type: "string",
description: "ID of the event to update",
},
title: {
type: "string",
description: "New title (optional)",
},
startTime: {
type: "string",
description: "New start time as ISO date string (optional)",
},
endTime: {
type: "string",
description: "New end time as ISO date string (optional)",
},
description: {
type: "string",
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"],
},
},
{
name: "proposeDeleteEvent",
description:
"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: {
type: "object",
properties: {
eventId: {
type: "string",
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"],
},
},
{
name: "searchEvents",
description:
"Search for events by title in the user's calendar. Returns matching events.",
parameters: {
type: "object",
properties: {
query: {
type: "string",
description: "Search query to match against event titles",
},
},
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

@@ -0,0 +1,251 @@
import {
ProposedEventChange,
getDay,
Day,
DAY_TO_GERMAN,
RecurringDeleteMode,
} from "@calchat/shared";
import { AIContext } from "../../services/interfaces";
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
*/
type ToolProposedChange = Omit<ProposedEventChange, "id" | "respondedAction">;
/**
* Result of executing a tool call.
*/
export interface ToolResult {
content: string;
proposedChange?: ToolProposedChange;
}
/**
* Execute a tool call and return the result.
* This function is provider-agnostic and can be used with any LLM.
* Async to support tools that need to fetch data (e.g., getEventsInRange).
*/
export async function executeToolCall(
name: string,
args: Record<string, unknown>,
context: AIContext,
): Promise<ToolResult> {
switch (name) {
case "getDay": {
const date = getDay(
args.day as Day,
args.offset as number,
args.hour as number,
args.minute as number,
);
const dayName = DAY_TO_GERMAN[args.day as Day];
return {
content: `${date.toISOString()} (${dayName}, ${formatDate(date)} um ${formatTime(date)} Uhr)`,
};
}
case "getCurrentDateTime": {
const now = context.currentDate;
return {
content: `${now.toISOString()} (${formatDateTime(now)})`,
};
}
case "proposeCreateEvent": {
const event = {
title: args.title as string,
startTime: new Date(args.startTime as string),
endTime: new Date(args.endTime as string),
description: args.description as string | undefined,
recurrenceRule: args.recurrenceRule as string | undefined,
};
const dateStr = formatDate(event.startTime);
const startStr = formatTime(event.startTime);
const endStr = formatTime(event.endTime);
// Check for conflicts - fetch events for the specific day
const dayStart = new Date(event.startTime);
dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(dayStart);
dayEnd.setDate(dayStart.getDate() + 1);
const dayEvents = await context.fetchEventsInRange(dayStart, dayEnd);
// Use occurrenceStart/occurrenceEnd for expanded recurring events
const conflicts = dayEvents.filter((e) =>
hasTimeOverlap(
event.startTime,
event.endTime,
new Date(e.occurrenceStart),
new Date(e.occurrenceEnd),
),
);
// Build conflict warning if any
let conflictWarning = "";
if (conflicts.length > 0) {
const conflictNames = conflicts.map((c) => `"${c.title}"`).join(", ");
conflictWarning = `\n⚠ ACHTUNG: Zeitkonflikt mit ${conflictNames}!`;
}
return {
content: `Event-Vorschlag erstellt: "${event.title}" am ${dateStr} von ${startStr} bis ${endStr} Uhr${conflictWarning}`,
proposedChange: {
action: "create",
event,
conflictingEvents:
conflicts.length > 0
? conflicts.map((c) => ({
title: c.title,
startTime: new Date(c.occurrenceStart),
endTime: new Date(c.occurrenceEnd),
}))
: undefined,
},
};
}
case "proposeUpdateEvent": {
const eventId = args.eventId as string;
const existingEvent = await context.fetchEventById(eventId);
if (!existingEvent) {
return { content: `Event mit ID ${eventId} nicht gefunden.` };
}
const updates: Record<string, unknown> = {};
if (args.title) updates.title = args.title;
if (args.startTime)
updates.startTime = new Date(args.startTime as string);
if (args.endTime) updates.endTime = new Date(args.endTime as string);
if (args.description) updates.description = args.description;
if (args.recurrenceRule) updates.recurrenceRule = args.recurrenceRule;
// Build event object for display (merge existing with updates)
const displayEvent = {
title: (updates.title as string) || existingEvent.title,
startTime: (updates.startTime as Date) || existingEvent.startTime,
endTime: (updates.endTime as Date) || existingEvent.endTime,
description:
(updates.description as string) || existingEvent.description,
recurrenceRule:
(updates.recurrenceRule as string) || existingEvent.recurrenceRule,
exceptionDates: existingEvent.exceptionDates,
};
return {
content: `Update-Vorschlag für "${existingEvent.title}" erstellt.`,
proposedChange: {
action: "update",
eventId,
updates,
event: displayEvent,
},
};
}
case "proposeDeleteEvent": {
const eventId = args.eventId as string;
const deleteMode = (args.deleteMode as RecurringDeleteMode) || "all";
const occurrenceDate = args.occurrenceDate as string | undefined;
const existingEvent = await context.fetchEventById(eventId);
if (!existingEvent) {
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 {
content: `Lösch-Vorschlag für "${existingEvent.title}"${modeDescription} erstellt.`,
proposedChange: {
action: "delete",
eventId,
event: {
title: existingEvent.title,
startTime: existingEvent.startTime,
endTime: existingEvent.endTime,
description: existingEvent.description,
recurrenceRule: existingEvent.recurrenceRule,
exceptionDates: existingEvent.exceptionDates,
},
deleteMode: existingEvent.recurrenceRule ? deleteMode : undefined,
occurrenceDate: existingEvent.recurrenceRule
? occurrenceDate
: undefined,
},
};
}
case "searchEvents": {
const query = args.query as string;
const matches = await context.searchEvents(query);
if (matches.length === 0) {
return { content: `Keine Termine mit "${query}" gefunden.` };
}
const results = matches
.map((e) => {
const start = new Date(e.startTime);
const recurrenceInfo = e.recurrenceRule ? " (wiederkehrend)" : "";
return `- ${e.title} (ID: ${e.id}) am ${formatDate(start)} um ${formatTime(start)} Uhr${recurrenceInfo}`;
})
.join("\n");
return { content: `Gefundene Termine:\n${results}` };
}
case "getEventsInRange": {
const startDate = new Date(args.startDate as string);
const endDate = new Date(args.endDate as string);
const events = await context.fetchEventsInRange(startDate, endDate);
if (events.length === 0) {
return { content: "Keine Termine in diesem Zeitraum." };
}
const eventsText = events
.map((e) => {
const start = new Date(e.occurrenceStart);
const recurrenceInfo = e.recurrenceRule ? " (wiederkehrend)" : "";
return `- ${e.title} (ID: ${e.id}) am ${formatDate(start)} um ${formatTime(start)} Uhr${recurrenceInfo}`;
})
.join("\n");
return {
content: `Termine von ${formatDate(startDate)} bis ${formatDate(endDate)}:\n${eventsText}`,
};
}
default:
return { content: `Unbekannte Funktion: ${name}` };
}
}

View File

@@ -1,12 +1,142 @@
import express, { Request, Response } from 'express';
import express from "express";
import mongoose from "mongoose";
import "dotenv/config";
import { createRoutes } from "./routes";
import {
AuthController,
ChatController,
EventController,
httpLogger,
} from "./controllers";
import { AuthService, ChatService, EventService } from "./services";
import {
MongoUserRepository,
MongoEventRepository,
MongoChatRepository,
} from "./repositories";
import { GPTAdapter } from "./ai";
import { logger } from "./logging";
import { MongoCaldavRepository } from "./repositories/mongo/MongoCaldavRepository";
import { CaldavService } from "./services/CaldavService";
import { CaldavController } from "./controllers/CaldavController";
const app = express();
const port = 3000;
const port = process.env.PORT || 3000;
const mongoUri = process.env.MONGODB_URI || "mongodb://localhost:27017/calchat";
app.get('/', (req: Request, res: Response) => {
res.send('Hello World!');
// Middleware
app.use(express.json());
app.use(httpLogger);
// CORS - only needed for web browser development
// Native mobile apps don't send Origin headers and aren't affected by CORS
if (process.env.NODE_ENV !== "production") {
app.use((req, res, next) => {
res.header("Access-Control-Allow-Origin", "*");
res.header(
"Access-Control-Allow-Methods",
"GET, POST, PUT, DELETE, OPTIONS",
);
res.header(
"Access-Control-Allow-Headers",
"Content-Type, Authorization, X-User-Id",
);
if (req.method === "OPTIONS") {
res.sendStatus(200);
return;
}
next();
});
}
// Initialize repositories
const userRepo = new MongoUserRepository();
const eventRepo = new MongoEventRepository();
const chatRepo = new MongoChatRepository();
const caldavRepo = new MongoCaldavRepository();
// Initialize AI provider
const aiProvider = new GPTAdapter();
// Initialize services
const authService = new AuthService(userRepo);
const eventService = new EventService(eventRepo);
const caldavService = new CaldavService(caldavRepo, eventService);
const chatService = new ChatService(chatRepo, eventService, aiProvider);
// Initialize controllers
const authController = new AuthController(authService);
const chatController = new ChatController(chatService, caldavService);
const eventController = new EventController(eventService, caldavService);
const caldavController = new CaldavController(caldavService);
// Setup routes
app.use(
"/api",
createRoutes({
authController,
chatController,
eventController,
caldavController,
}),
);
// Health check
app.get("/health", (_, res) => {
res.json({ status: "ok" });
});
app.listen(port, () => {
console.log(`Example app listening on port ${port}`);
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)
app.post("/api/ai/test", async (req, res) => {
try {
const { message } = req.body;
if (!message) {
res.status(400).json({ error: "message is required" });
return;
}
const result = await aiProvider.processMessage(message, {
userId: "test-user",
conversationHistory: [],
currentDate: new Date(),
fetchEventsInRange: async () => [],
searchEvents: async () => [],
fetchEventById: async () => null,
});
res.json(result);
} catch (error) {
logger.error({ error }, "AI test error");
res.status(500).json({ error: String(error) });
}
});
// Start server
async function start() {
try {
await mongoose.connect(mongoUri);
logger.info("Connected to MongoDB");
app.listen(port, () => {
logger.info({ port }, "Server started");
});
} catch (error) {
logger.fatal({ error }, "Failed to start server");
process.exit(1);
}
}
start();
export default app;

View File

@@ -0,0 +1,24 @@
import { Request, Response } from "express";
import { AuthService } from "../services";
export class AuthController {
constructor(private authService: AuthService) {}
async login(req: Request, res: Response): Promise<void> {
try {
const result = await this.authService.login(req.body);
res.json(result);
} catch (error) {
res.status(401).json({ error: (error as Error).message });
}
}
async register(req: Request, res: Response): Promise<void> {
try {
const result = await this.authService.register(req.body);
res.status(201).json(result);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
}
}

View File

@@ -0,0 +1,25 @@
import { Request, Response, NextFunction } from "express";
export interface AuthenticatedUser {
userId: string;
}
export interface AuthenticatedRequest extends Request {
user?: AuthenticatedUser;
}
export function authenticate(
req: AuthenticatedRequest,
res: Response,
next: NextFunction,
): void {
const userId = req.headers["x-user-id"];
if (!userId || typeof userId !== "string") {
res.status(401).json({ error: "Unauthorized" });
return;
}
req.user = { userId };
next();
}

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

@@ -0,0 +1,196 @@
import { Response } from "express";
import {
SendMessageDTO,
CreateEventDTO,
UpdateEventDTO,
EventAction,
GetMessagesOptions,
RecurringDeleteMode,
} from "@calchat/shared";
import { ChatService } from "../services";
import { CaldavService } from "../services/CaldavService";
import { createLogger } from "../logging";
import { AuthenticatedRequest } from "./AuthMiddleware";
const log = createLogger("ChatController");
export class ChatController {
constructor(
private chatService: ChatService,
private caldavService: CaldavService,
) {}
async sendMessage(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const userId = req.user!.userId;
const data: SendMessageDTO = req.body;
const response = await this.chatService.processMessage(userId, data);
res.json(response);
} catch (error) {
log.error(
{ err: error, userId: req.user?.userId },
"Error processing message",
);
res.status(500).json({ error: "Failed to process message" });
}
}
async confirmEvent(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const userId = req.user!.userId;
const { conversationId, messageId } = req.params;
// DEBUG: Log incoming request body to trace deleteMode issue
log.debug({ body: req.body }, "confirmEvent request body");
const {
proposalId,
action,
event,
eventId,
updates,
deleteMode,
occurrenceDate,
} = req.body as {
proposalId: string;
action: EventAction;
event?: CreateEventDTO;
eventId?: string;
updates?: UpdateEventDTO;
deleteMode?: RecurringDeleteMode;
occurrenceDate?: string;
};
const response = await this.chatService.confirmEvent(
userId,
conversationId,
messageId,
proposalId,
action,
event,
eventId,
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);
} catch (error) {
log.error(
{ err: error, conversationId: req.params.conversationId },
"Error confirming event",
);
res.status(500).json({ error: "Failed to confirm event" });
}
}
async rejectEvent(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const userId = req.user!.userId;
const { conversationId, messageId } = req.params;
const { proposalId } = req.body as { proposalId: string };
const response = await this.chatService.rejectEvent(
userId,
conversationId,
messageId,
proposalId,
);
res.json(response);
} catch (error) {
log.error(
{ err: error, conversationId: req.params.conversationId },
"Error rejecting event",
);
res.status(500).json({ error: "Failed to reject event" });
}
}
async getConversations(
req: AuthenticatedRequest,
res: Response,
): Promise<void> {
try {
const userId = req.user!.userId;
const conversations = await this.chatService.getConversations(userId);
res.json(conversations);
} catch (error) {
log.error(
{ err: error, userId: req.user?.userId },
"Error getting conversations",
);
res.status(500).json({ error: "Failed to get conversations" });
}
}
async getConversation(
req: AuthenticatedRequest,
res: Response,
): Promise<void> {
try {
const userId = req.user!.userId;
const { id } = req.params;
const { before, limit } = req.query as {
before?: string;
limit?: string;
};
const options: GetMessagesOptions = {};
if (before) options.before = before;
if (limit) options.limit = parseInt(limit, 10);
const messages = await this.chatService.getConversation(
userId,
id,
options,
);
res.json(messages);
} catch (error) {
if ((error as Error).message === "Conversation not found") {
res.status(404).json({ error: "Conversation not found" });
} else {
log.error(
{ err: error, conversationId: req.params.id },
"Error getting 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

@@ -0,0 +1,184 @@
import { Response } from "express";
import { CalendarEvent, RecurringDeleteMode } from "@calchat/shared";
import { EventService } from "../services";
import { createLogger } from "../logging";
import { AuthenticatedRequest } from "./AuthMiddleware";
import { CaldavService } from "../services/CaldavService";
const log = createLogger("EventController");
export class EventController {
constructor(
private eventService: EventService,
private caldavService: CaldavService,
) {}
private async pushToCaldav(userId: string, event: CalendarEvent) {
if (await this.caldavService.getConfig(userId)) {
try {
await this.caldavService.pushEvent(userId, event);
} catch (error) {
log.error({ err: error, userId }, "Error pushing event to CalDAV");
}
}
}
private async deleteFromCaldav(userId: string, event: CalendarEvent) {
if (event.caldavUUID && (await this.caldavService.getConfig(userId))) {
try {
await this.caldavService.deleteEvent(userId, event.caldavUUID);
} catch (error) {
log.error({ err: error, userId }, "Error deleting event from CalDAV");
}
}
}
async create(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const userId = req.user!.userId;
const event = await this.eventService.create(userId, req.body);
await this.pushToCaldav(userId, event);
res.status(201).json(event);
} catch (error) {
log.error(
{ err: error, userId: req.user?.userId },
"Error creating event",
);
res.status(500).json({ error: "Failed to create event" });
}
}
async getById(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const event = await this.eventService.getById(
req.params.id,
req.user!.userId,
);
if (!event) {
res.status(404).json({ error: "Event not found" });
return;
}
res.json(event);
} catch (error) {
log.error({ err: error, eventId: req.params.id }, "Error getting event");
res.status(500).json({ error: "Failed to get event" });
}
}
async getAll(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const events = await this.eventService.getAll(req.user!.userId);
res.json(events);
} catch (error) {
log.error(
{ err: error, userId: req.user?.userId },
"Error getting events",
);
res.status(500).json({ error: "Failed to get events" });
}
}
async getByDateRange(
req: AuthenticatedRequest,
res: Response,
): Promise<void> {
try {
const { start, end } = req.query;
if (!start || !end) {
res.status(400).json({ error: "start and end query params required" });
return;
}
const startDate = new Date(start as string);
const endDate = new Date(end as string);
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
res.status(400).json({ error: "Invalid date format" });
return;
}
const events = await this.eventService.getByDateRange(
req.user!.userId,
startDate,
endDate,
);
res.json(events);
} catch (error) {
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" });
}
}
async update(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const userId = req.user!.userId;
const event = await this.eventService.update(
req.params.id,
userId,
req.body,
);
if (!event) {
res.status(404).json({ error: "Event not found" });
return;
}
await this.pushToCaldav(userId, event);
res.json(event);
} catch (error) {
log.error({ err: error, eventId: req.params.id }, "Error updating event");
res.status(500).json({ error: "Failed to update event" });
}
}
async delete(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const userId = req.user!.userId;
const { mode, occurrenceDate } = req.query as {
mode?: RecurringDeleteMode;
occurrenceDate?: string;
};
// Fetch event before deletion to get caldavUUID for sync
const event = await this.eventService.getById(req.params.id, userId);
if (!event) {
res.status(404).json({ error: "Event not found" });
return;
}
// If mode is specified, use deleteRecurring
if (mode) {
const result = await this.eventService.deleteRecurring(
req.params.id,
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();
} catch (error) {
log.error({ err: error, eventId: req.params.id }, "Error deleting event");
res.status(500).json({ error: "Failed to delete event" });
}
}
}

View File

@@ -0,0 +1,27 @@
import pinoHttp from "pino-http";
import { logger } from "../logging";
export const httpLogger = pinoHttp({
logger,
customLogLevel: (_req, res, err) => {
if (res.statusCode >= 500 || err) return "error";
if (res.statusCode >= 400) return "warn";
return "info";
},
customSuccessMessage: (req, res) => {
return `${req.method} ${req.url} ${res.statusCode}`;
},
customErrorMessage: (req, _res, err) => {
return `${req.method} ${req.url} failed: ${err.message}`;
},
redact: ["req.headers.authorization"],
serializers: {
req: (req) => ({
method: req.method,
url: req.url,
}),
res: (res) => ({
statusCode: res.statusCode,
}),
},
});

View File

@@ -0,0 +1,6 @@
export * from "./AuthController";
export * from "./ChatController";
export * from "./EventController";
export * from "./AuthMiddleware";
export * from "./LoggingMiddleware";
export * from "./CaldavController";

View File

@@ -0,0 +1,129 @@
import { createLogger } from "./logger";
/**
* Summarize args for logging to avoid huge log entries.
* - Arrays: show length only
* - Long strings: truncate
* - Objects with conversationHistory: summarize
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function summarizeArgs(args: any[]): any[] {
return args.map((arg) => summarizeValue(arg));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function summarizeValue(value: any, depth = 0): any {
if (depth > 2) return "[...]";
if (value === null || value === undefined) return value;
if (Array.isArray(value)) {
return `[Array(${value.length})]`;
}
if (typeof value === "string" && value.length > 100) {
return value.substring(0, 100) + "...";
}
if (typeof value === "object") {
// Summarize known large fields
const summarized: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value)) {
if (key === "conversationHistory" && Array.isArray(val)) {
summarized[key] = `[${val.length} messages]`;
} else if (key === "proposedChanges" && Array.isArray(val)) {
// Log full proposedChanges for debugging AI issues
summarized[key] = val.map((p) => summarizeValue(p, depth + 1));
} else if (Array.isArray(val)) {
summarized[key] = `[Array(${val.length})]`;
} else if (typeof val === "object" && val !== null) {
summarized[key] = summarizeValue(val, depth + 1);
} else if (typeof val === "string" && val.length > 100) {
summarized[key] = val.substring(0, 100) + "...";
} else {
summarized[key] = val;
}
}
return summarized;
}
return value;
}
export function Logged(name: string) {
const log = createLogger(name);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return function <T extends { new (...args: any[]): any }>(Constructor: T) {
return class extends Constructor {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
constructor(...args: any[]) {
super(...args);
// Return a Proxy that intercepts method calls lazily
return new Proxy(this, {
get(target, propKey, receiver) {
const original = Reflect.get(target, propKey, receiver);
if (typeof original !== "function" || propKey === "constructor") {
return original;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const originalFn = original as (...args: any[]) => any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return function (this: any, ...methodArgs: any[]) {
const start = performance.now();
const method = String(propKey);
// Summarize args to avoid huge log entries
log.debug(
{ method, args: summarizeArgs(methodArgs) },
`${method} started`,
);
const logCompletion = (err?: unknown) => {
const duration = Math.round(performance.now() - start);
if (err) {
const message =
err instanceof Error ? err.message : String(err);
log.error(
{ method, duration, error: message },
`${method} failed`,
);
} else {
log.info({ method, duration }, `${method} completed`);
}
};
try {
const result = originalFn.apply(this, methodArgs);
// Check if async - preserves sync/async nature of method
if (result instanceof Promise) {
return result
.then((val) => {
logCompletion();
return val;
})
.catch((err) => {
logCompletion(err);
throw err;
});
}
// Synchronous completion
logCompletion();
return result;
} catch (err) {
logCompletion(err);
throw err;
}
};
},
});
}
};
};
}

View File

@@ -0,0 +1,2 @@
export { logger, createLogger, type Logger } from "./logger";
export { Logged } from "./Logged";

View File

@@ -0,0 +1,43 @@
import pino from "pino";
const isDevelopment = process.env.NODE_ENV !== "production";
export const logger = pino({
level: process.env.LOG_LEVEL || (isDevelopment ? "debug" : "info"),
redact: {
paths: [
// Root level
"password",
"passwordHash",
"token",
// One level deep (e.g. user.password)
"*.password",
"*.passwordHash",
"*.token",
// In arrays (for 'args' in decorator)
"args[*].password",
"args[*].passwordHash",
"args[*].token",
],
censor: "[REDACTED]",
},
transport: isDevelopment
? {
target: "pino-pretty",
options: {
colorize: true,
translateTime: "SYS:HH:MM:ss",
ignore: "pid,hostname",
},
}
: undefined,
base: {
service: "calchat-server",
},
});
export function createLogger(module: string) {
return logger.child({ module });
}
export type Logger = pino.Logger;

View File

@@ -0,0 +1 @@
export * from "./mongo";

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

@@ -0,0 +1,127 @@
import {
ChatMessage,
Conversation,
CreateMessageDTO,
CreateEventDTO,
GetMessagesOptions,
UpdateMessageDTO,
ConflictingEvent,
} from "@calchat/shared";
import { ChatRepository } from "../../services/interfaces";
import { Logged } from "../../logging";
import { ChatMessageModel, ConversationModel } from "./models";
@Logged("MongoChatRepository")
export class MongoChatRepository implements ChatRepository {
// Conversations
async getConversationsByUser(userId: string): Promise<Conversation[]> {
const conversations = await ConversationModel.find({ userId });
return conversations.map((c) => c.toJSON() as unknown as Conversation);
}
async createConversation(userId: string): Promise<Conversation> {
const conversation = await ConversationModel.create({
userId,
});
return conversation.toJSON() as unknown as Conversation;
}
async getConversationById(
conversationId: string,
): Promise<Conversation | null> {
const conversation = await ConversationModel.findById(conversationId);
return conversation
? (conversation.toJSON() as unknown as Conversation)
: null;
}
// Messages (cursor-based pagination)
async getMessages(
conversationId: string,
options?: GetMessagesOptions,
): Promise<ChatMessage[]> {
const query: Record<string, unknown> = { conversationId };
// Cursor: load messages before this ID (for "load more" scrolling up)
if (options?.before) {
query._id = { $lt: options.before };
}
// Fetch newest first, then reverse for chronological order
// Only apply limit if explicitly specified (no default - load all messages)
let queryBuilder = ChatMessageModel.find(query).sort({ _id: -1 });
if (options?.limit) {
queryBuilder = queryBuilder.limit(options.limit);
}
const docs = await queryBuilder;
return docs.reverse().map((doc) => doc.toJSON() as unknown as ChatMessage);
}
async createMessage(
conversationId: string,
message: CreateMessageDTO,
): Promise<ChatMessage> {
const repoMessage = await ChatMessageModel.create({
conversationId: conversationId,
sender: message.sender,
content: message.content,
proposedChanges: message.proposedChanges,
});
return repoMessage.toJSON() as unknown as ChatMessage;
}
async updateMessage(
messageId: string,
updates: UpdateMessageDTO,
): Promise<ChatMessage | null> {
const doc = await ChatMessageModel.findByIdAndUpdate(
messageId,
{ $set: updates },
{ new: true },
);
return doc ? (doc.toJSON() as unknown as ChatMessage) : null;
}
async updateProposalResponse(
messageId: string,
proposalId: string,
respondedAction: "confirm" | "reject",
): Promise<ChatMessage | null> {
const doc = await ChatMessageModel.findOneAndUpdate(
{ _id: messageId, "proposedChanges.id": proposalId },
{ $set: { "proposedChanges.$.respondedAction": respondedAction } },
{ new: true },
);
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

@@ -0,0 +1,80 @@
import { CalendarEvent, CreateEventDTO, UpdateEventDTO } from "@calchat/shared";
import { EventRepository } from "../../services/interfaces";
import { Logged } from "../../logging";
import { EventModel } from "./models";
@Logged("MongoEventRepository")
export class MongoEventRepository implements EventRepository {
async findById(id: string): Promise<CalendarEvent | null> {
const event = await EventModel.findById(id);
if (!event) return null;
return event.toJSON() as unknown as CalendarEvent;
}
async findByUserId(userId: string): Promise<CalendarEvent[]> {
const events = await EventModel.find({ userId }).sort({ startTime: 1 });
return events.map((e) => e.toJSON() as unknown as CalendarEvent);
}
async findByDateRange(
userId: string,
startDate: Date,
endDate: Date,
): Promise<CalendarEvent[]> {
const events = await EventModel.find({
userId,
startTime: { $gte: startDate, $lte: endDate },
}).sort({ startTime: 1 });
return events.map((e) => e.toJSON() as unknown as CalendarEvent);
}
async findByCaldavUUID(
userId: string,
caldavUUID: string,
): Promise<CalendarEvent | null> {
const event = await EventModel.findOne({ userId, caldavUUID });
if (!event) return null;
return event.toJSON() as unknown as CalendarEvent;
}
async searchByTitle(userId: string, query: string): Promise<CalendarEvent[]> {
const events = await EventModel.find({
userId,
title: { $regex: query, $options: "i" },
}).sort({ startTime: 1 });
return events.map((e) => e.toJSON() as unknown as CalendarEvent);
}
async create(userId: string, data: CreateEventDTO): Promise<CalendarEvent> {
const event = await EventModel.create({ userId, ...data });
// NOTE: Casting required because Mongoose's toJSON() type doesn't reflect our virtual 'id' field
return event.toJSON() as unknown as CalendarEvent;
}
async update(
id: string,
data: UpdateEventDTO,
): Promise<CalendarEvent | null> {
const event = await EventModel.findByIdAndUpdate(id, data, { new: true });
if (!event) return null;
return event.toJSON() as unknown as CalendarEvent;
}
async delete(id: string): Promise<boolean> {
const result = await EventModel.findByIdAndDelete(id);
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,38 @@
import { User } from "@calchat/shared";
import { UserRepository, CreateUserData } from "../../services/interfaces";
import { Logged } from "../../logging";
import { UserModel, UserDocument } from "./models";
function toUser(doc: UserDocument): User {
return {
id: doc._id.toString(),
email: doc.email,
userName: doc.userName,
passwordHash: doc.passwordHash,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
};
}
@Logged("MongoUserRepository")
export class MongoUserRepository implements UserRepository {
async findById(id: string): Promise<User | null> {
const user = await UserModel.findById(id);
return user ? toUser(user) : null;
}
async findByEmail(email: string): Promise<User | null> {
const user = await UserModel.findOne({ email: email.toLowerCase() });
return user ? toUser(user) : null;
}
async findByUserName(userName: string): Promise<User | null> {
const user = await UserModel.findOne({ userName });
return user ? toUser(user) : null;
}
async create(data: CreateUserData): Promise<User> {
const user = await UserModel.create(data);
return toUser(user);
}
}

View File

@@ -0,0 +1,3 @@
export * from "./MongoUserRepository";
export * from "./MongoEventRepository";
export * from "./MongoChatRepository";

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,
);

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