diff --git a/CLAUDE.md b/CLAUDE.md index d172f1f..a2ad7e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,7 +84,7 @@ src/ │ └── note/ │ └── [id].tsx # Note editor for event (dynamic route) ├── components/ -│ ├── AuthGuard.tsx # Auth wrapper: loads user, CalDAV sync on auto-login, shows loading, redirects if unauthenticated +│ ├── AuthGuard.tsx # Auth wrapper: loads user, preloads app data (events + CalDAV config), CalDAV sync, shows loading, redirects if unauthenticated. Exports preloadAppData() │ ├── BaseBackground.tsx # Common screen wrapper (themed) │ ├── BaseButton.tsx # Reusable button component (themed, supports children) │ ├── Header.tsx # Header component (themed) @@ -118,6 +118,7 @@ src/ │ │ # 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 @@ -128,9 +129,12 @@ src/ **Authentication Flow:** - `AuthGuard` component wraps the tab layout in `(tabs)/_layout.tsx` - On app start, `AuthGuard` calls `loadStoredUser()` and shows loading indicator +- After auth, `preloadAppData()` loads events (current month) + CalDAV config into stores before dismissing spinner - If not authenticated, redirects to `/login` +- `login.tsx` also calls `preloadAppData()` after successful login (spinner stays visible during preload) - `index.tsx` simply redirects to `/(tabs)/chat` - AuthGuard handles the rest - This pattern handles Expo Router's navigation state caching (avoids race conditions) +- Preloading prevents empty screens when navigating to Calendar or Settings tabs for the first time ### Theme System @@ -597,8 +601,8 @@ NODE_ENV=development # development = pretty logs, production = JSON - `AuthStore`: Manages user state with expo-secure-store (native) / localStorage (web) - `AuthService`: login(), register(), logout() - calls backend API - `ApiClient`: Automatically injects X-User-Id header for authenticated requests, handles empty responses (204) - - `AuthGuard`: Reusable component that wraps protected routes - loads user, triggers CalDAV sync on auto-login, shows loading, redirects if unauthenticated - - Login screen: Supports email OR userName login, triggers CalDAV sync after successful login + - `AuthGuard`: Reusable component that wraps protected routes - loads user, preloads app data (events + CalDAV config) into stores before dismissing spinner, triggers CalDAV sync, shows loading, redirects if unauthenticated. Exports `preloadAppData()` (also called by `login.tsx`) + - Login screen: Supports email OR userName login, preloads app data + triggers CalDAV sync after successful login - Register screen: Email validation, checks for existing email/userName - `AuthButton`: Reusable button component with themed shadow - `Header`: Themed header component (logout moved to Settings) @@ -644,7 +648,8 @@ NODE_ENV=development # development = pretty logs, production = JSON - `DeleteEventModal`: Delete confirmation modal using ModalBase - shows three options for recurring events (single/future/all), simple confirm for non-recurring - `EventOverlay` (in calendar.tsx): Day events overlay using ModalBase - shows EventCards for selected day - `Themes.tsx`: Theme definitions with THEMES object (defaultLight, defaultDark) including all color tokens (textPrimary, borderPrimary, eventIndicator, secondaryBg, shadowColor, etc.) -- `EventsStore`: Zustand store with setEvents(), addEvent(), updateEvent(), deleteEvent() - stores ExpandedEvent[] +- `EventsStore`: Zustand store with setEvents(), addEvent(), updateEvent(), deleteEvent() - stores ExpandedEvent[], preloaded by AuthGuard +- `CaldavConfigStore`: Zustand store with config (CaldavConfig | null), setConfig() - cached CalDAV config, preloaded by AuthGuard, used by Settings to avoid API call on mount - `ChatStore`: Zustand store with addMessage(), addMessages(), updateMessage(), clearMessages(), isWaitingForResponse/setWaitingForResponse() for typing indicator - loads from server on mount and persists across tab switches - `ThemeStore`: Zustand store with theme/setTheme() for reactive theme switching across all components - `ChatBubble`: Reusable chat bubble component with Tailwind styling, used by ChatMessage and TypingIndicator diff --git a/apps/client/src/app/(tabs)/calendar.tsx b/apps/client/src/app/(tabs)/calendar.tsx index 07811bb..7c87b30 100644 --- a/apps/client/src/app/(tabs)/calendar.tsx +++ b/apps/client/src/app/(tabs)/calendar.tsx @@ -11,12 +11,7 @@ import { EventCard } from "../../components/EventCard"; import { DeleteEventModal } from "../../components/DeleteEventModal"; import { ModalBase } from "../../components/ModalBase"; import { ScrollableDropdown } from "../../components/ScrollableDropdown"; -import React, { - useCallback, - useEffect, - useMemo, - useState, -} from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { router, useFocusEffect } from "expo-router"; import { Ionicons } from "@expo/vector-icons"; import { useThemeStore } from "../../stores/ThemeStore"; diff --git a/apps/client/src/app/(tabs)/chat.tsx b/apps/client/src/app/(tabs)/chat.tsx index 1333a3b..ea3b077 100644 --- a/apps/client/src/app/(tabs)/chat.tsx +++ b/apps/client/src/app/(tabs)/chat.tsx @@ -175,7 +175,11 @@ const Chat = () => { params: { mode: "chat", eventData: JSON.stringify(proposal.event), - proposalContext: JSON.stringify({ messageId, proposalId, conversationId }), + proposalContext: JSON.stringify({ + messageId, + proposalId, + conversationId, + }), }, }); }; diff --git a/apps/client/src/app/(tabs)/settings.tsx b/apps/client/src/app/(tabs)/settings.tsx index 19dbe6a..b9c0fdf 100644 --- a/apps/client/src/app/(tabs)/settings.tsx +++ b/apps/client/src/app/(tabs)/settings.tsx @@ -8,8 +8,9 @@ import { Ionicons } from "@expo/vector-icons"; import { SimpleHeader } from "../../components/Header"; import { THEMES } from "../../Themes"; import CustomTextInput from "../../components/CustomTextInput"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { CaldavConfigService } from "../../services/CaldavConfigService"; +import { useCaldavConfigStore } from "../../stores"; const handleLogout = async () => { await AuthService.logout(); @@ -34,38 +35,38 @@ type CaldavTextInputProps = { onValueChange: (text: string) => void; }; -const CaldavTextInput = ({ title, value, onValueChange }: CaldavTextInputProps) => { +const CaldavTextInput = ({ + title, + value, + onValueChange, +}: CaldavTextInputProps) => { return ( {title}: - + ); }; const CaldavSettings = () => { const { theme } = useThemeStore(); + const { config, setConfig } = useCaldavConfigStore(); - const [serverUrl, setServerUrl] = useState(""); - const [username, setUsername] = useState(""); - const [password, setPassword] = useState(""); - - useEffect(() => { - const loadConfig = async () => { - try { - const config = await CaldavConfigService.getConfig(); - setServerUrl(config.serverUrl); - setUsername(config.username); - setPassword(config.password); - } catch { - // No config saved yet - } - }; - loadConfig(); - }, []); + const [serverUrl, setServerUrl] = useState(config?.serverUrl ?? ""); + const [username, setUsername] = useState(config?.username ?? ""); + const [password, setPassword] = useState(config?.password ?? ""); const saveConfig = async () => { - await CaldavConfigService.saveConfig(serverUrl, username, password); + const saved = await CaldavConfigService.saveConfig( + serverUrl, + username, + password, + ); + setConfig(saved); }; const sync = async () => { @@ -84,9 +85,21 @@ const CaldavSettings = () => { - - - + + + diff --git a/apps/client/src/app/editEvent.tsx b/apps/client/src/app/editEvent.tsx index 8505ee4..d1968d4 100644 --- a/apps/client/src/app/editEvent.tsx +++ b/apps/client/src/app/editEvent.tsx @@ -19,7 +19,12 @@ 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 { + buildRRule, + CreateEventDTO, + REPEAT_TYPE_LABELS, + RepeatType, +} from "@calchat/shared"; import { useChatStore } from "../stores"; import CustomTextInput, { CustomTextInputProps, diff --git a/apps/client/src/app/login.tsx b/apps/client/src/app/login.tsx index 002b09d..c33911d 100644 --- a/apps/client/src/app/login.tsx +++ b/apps/client/src/app/login.tsx @@ -5,6 +5,7 @@ import BaseBackground from "../components/BaseBackground"; import AuthButton from "../components/AuthButton"; import { AuthService } from "../services"; import { CaldavConfigService } from "../services/CaldavConfigService"; +import { preloadAppData } from "../components/AuthGuard"; import { useThemeStore } from "../stores/ThemeStore"; const LoginScreen = () => { @@ -25,6 +26,7 @@ const LoginScreen = () => { setIsLoading(true); try { await AuthService.login({ identifier, password }); + await preloadAppData(); try { await CaldavConfigService.sync(); } catch { diff --git a/apps/client/src/components/AuthGuard.tsx b/apps/client/src/components/AuthGuard.tsx index f25e6ad..ddde39b 100644 --- a/apps/client/src/components/AuthGuard.tsx +++ b/apps/client/src/components/AuthGuard.tsx @@ -1,17 +1,52 @@ -import { useEffect, ReactNode } from "react"; +import { useEffect, useState, ReactNode } from "react"; import { View, ActivityIndicator } from "react-native"; import { Redirect } from "expo-router"; import { useAuthStore } from "../stores"; import { useThemeStore } from "../stores/ThemeStore"; +import { useEventsStore } from "../stores/EventsStore"; +import { useCaldavConfigStore } from "../stores/CaldavConfigStore"; +import { EventService } from "../services"; import { CaldavConfigService } from "../services/CaldavConfigService"; type AuthGuardProps = { children: ReactNode; }; +/** + * Preloads app data (events + CalDAV config) into stores. + * Called before the loading spinner is dismissed so screens have data immediately. + */ +export const preloadAppData = async () => { + const now = new Date(); + const firstOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); + const dayOfWeek = firstOfMonth.getDay(); + const daysFromPrevMonth = dayOfWeek === 0 ? 6 : dayOfWeek - 1; + const startDate = new Date( + now.getFullYear(), + now.getMonth(), + 1 - daysFromPrevMonth, + ); + const endDate = new Date(startDate); + endDate.setDate(startDate.getDate() + 41); + endDate.setHours(23, 59, 59); + + const [eventsResult, configResult] = await Promise.allSettled([ + EventService.getByDateRange(startDate, endDate), + CaldavConfigService.getConfig(), + ]); + + if (eventsResult.status === "fulfilled") { + useEventsStore.getState().setEvents(eventsResult.value); + } + if (configResult.status === "fulfilled") { + useCaldavConfigStore.getState().setConfig(configResult.value); + } +}; + /** * Wraps content that requires authentication. * - Loads stored user on mount + * - Preloads app data (events, CalDAV config) before dismissing spinner * - Shows loading indicator while checking auth state * - Redirects to login if not authenticated * - Renders children if authenticated @@ -19,11 +54,14 @@ type AuthGuardProps = { 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); try { await CaldavConfigService.sync(); } catch { @@ -33,7 +71,7 @@ export const AuthGuard = ({ children }: AuthGuardProps) => { init(); }, [loadStoredUser]); - if (isLoading) { + if (isLoading || (isAuthenticated && !dataReady)) { return ( { +const BaseButton = ({ + className, + children, + onPress, + solid = false, +}: BaseButtonProps) => { const { theme } = useThemeStore(); return ( + props: Omit, ) => ; export const TimePickerButton = ( - props: Omit + props: Omit, ) => ; export default DateTimePickerButton; diff --git a/apps/client/src/components/ProposedEventCard.tsx b/apps/client/src/components/ProposedEventCard.tsx index d2f9694..6e98c9f 100644 --- a/apps/client/src/components/ProposedEventCard.tsx +++ b/apps/client/src/components/ProposedEventCard.tsx @@ -124,8 +124,12 @@ export const ProposedEventCard = ({ color={theme.confirmButton} style={{ marginRight: 8 }} /> - - Neue Ausnahme: {formatDate(new Date(proposedChange.occurrenceDate!))} + + Neue Ausnahme:{" "} + {formatDate(new Date(proposedChange.occurrenceDate!))} )} @@ -138,7 +142,10 @@ export const ProposedEventCard = ({ color={theme.confirmButton} style={{ marginRight: 8 }} /> - + Neues Ende: {formatDate(newUntilDate)} diff --git a/apps/client/src/stores/CaldavConfigStore.ts b/apps/client/src/stores/CaldavConfigStore.ts new file mode 100644 index 0000000..a591de6 --- /dev/null +++ b/apps/client/src/stores/CaldavConfigStore.ts @@ -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((set) => ({ + config: null, + setConfig: (config: CaldavConfig | null) => { + set({ config }); + }, +})); diff --git a/apps/client/src/stores/index.ts b/apps/client/src/stores/index.ts index a832c08..bfeda05 100644 --- a/apps/client/src/stores/index.ts +++ b/apps/client/src/stores/index.ts @@ -5,3 +5,4 @@ export { type MessageData, } from "./ChatStore"; export { useEventsStore } from "./EventsStore"; +export { useCaldavConfigStore } from "./CaldavConfigStore"; diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 4d5c62e..3ccba6f 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -83,7 +83,7 @@ app.use( authController, chatController, eventController, - caldavController + caldavController, }), ); diff --git a/apps/server/src/repositories/mongo/MongoCaldavRepository.ts b/apps/server/src/repositories/mongo/MongoCaldavRepository.ts index 87442e4..d65fca2 100644 --- a/apps/server/src/repositories/mongo/MongoCaldavRepository.ts +++ b/apps/server/src/repositories/mongo/MongoCaldavRepository.ts @@ -25,7 +25,7 @@ export class MongoCaldavRepository implements CaldavRepository { } async deleteByUserId(userId: string): Promise { - const result = await CaldavConfigModel.findOneAndDelete({userId}); + const result = await CaldavConfigModel.findOneAndDelete({ userId }); return result !== null; } } diff --git a/apps/server/src/repositories/mongo/MongoEventRepository.ts b/apps/server/src/repositories/mongo/MongoEventRepository.ts index aa765bd..d779975 100644 --- a/apps/server/src/repositories/mongo/MongoEventRepository.ts +++ b/apps/server/src/repositories/mongo/MongoEventRepository.ts @@ -28,7 +28,10 @@ export class MongoEventRepository implements EventRepository { return events.map((e) => e.toJSON() as unknown as CalendarEvent); } - async findByCaldavUUID(userId: string, caldavUUID: string): Promise { + async findByCaldavUUID( + userId: string, + caldavUUID: string, + ): Promise { const event = await EventModel.findOne({ userId, caldavUUID }); if (!event) return null; return event.toJSON() as unknown as CalendarEvent; diff --git a/apps/server/src/repositories/mongo/models/EventModel.ts b/apps/server/src/repositories/mongo/models/EventModel.ts index a5b7beb..cc99284 100644 --- a/apps/server/src/repositories/mongo/models/EventModel.ts +++ b/apps/server/src/repositories/mongo/models/EventModel.ts @@ -2,9 +2,7 @@ import mongoose, { Schema, Document, Model } from "mongoose"; import { CalendarEvent } from "@calchat/shared"; import { IdVirtual } from "./types"; -export interface EventDocument - extends Omit, - Document { +export interface EventDocument extends Omit, Document { toJSON(): CalendarEvent; } diff --git a/apps/server/src/routes/index.ts b/apps/server/src/routes/index.ts index 95ea427..bcad14c 100644 --- a/apps/server/src/routes/index.ts +++ b/apps/server/src/routes/index.ts @@ -6,7 +6,7 @@ import { AuthController, ChatController, EventController, - CaldavController + CaldavController, } from "../controllers"; import { createCaldavRoutes } from "./caldav.routes"; diff --git a/apps/server/src/services/ChatService.ts b/apps/server/src/services/ChatService.ts index 63f135b..739af91 100644 --- a/apps/server/src/services/ChatService.ts +++ b/apps/server/src/services/ChatService.ts @@ -363,9 +363,7 @@ async function getTestResponse( event: { title: sportEvent.title, startTime: exceptionDate, - endTime: new Date( - exceptionDate.getTime() + 90 * 60 * 1000, - ), // +90 min + endTime: new Date(exceptionDate.getTime() + 90 * 60 * 1000), // +90 min description: sportEvent.description, recurrenceRule: sportEvent.recurrenceRule, exceptionDates: sportEvent.exceptionDates, @@ -375,7 +373,8 @@ async function getTestResponse( }; } return { - content: "Ich konnte keinen Termin 'Sport' finden. Bitte erstelle ihn zuerst.", + content: + "Ich konnte keinen Termin 'Sport' finden. Bitte erstelle ihn zuerst.", }; } @@ -387,13 +386,13 @@ async function getTestResponse( // Calculate UNTIL date: 6 weeks from start const untilDate = new Date(sportEvent.startTime); untilDate.setDate(untilDate.getDate() + 42); // 6 weeks - const untilStr = untilDate.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"; + const untilStr = + untilDate.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"; const newRule = `FREQ=WEEKLY;BYDAY=WE;UNTIL=${untilStr}`; return { - content: - "Alles klar! Ich beende die Sport-Serie nach 6 Wochen:", + content: "Alles klar! Ich beende die Sport-Serie nach 6 Wochen:", proposedChanges: [ { id: "sport-until", @@ -413,7 +412,8 @@ async function getTestResponse( }; } return { - content: "Ich konnte keinen Termin 'Sport' finden. Bitte erstelle ihn zuerst.", + content: + "Ich konnte keinen Termin 'Sport' finden. Bitte erstelle ihn zuerst.", }; } @@ -440,9 +440,7 @@ async function getTestResponse( event: { title: sportEvent.title, startTime: exceptionDate, - endTime: new Date( - exceptionDate.getTime() + 90 * 60 * 1000, - ), // +90 min + endTime: new Date(exceptionDate.getTime() + 90 * 60 * 1000), // +90 min description: sportEvent.description, recurrenceRule: sportEvent.recurrenceRule, exceptionDates: sportEvent.exceptionDates, @@ -452,7 +450,8 @@ async function getTestResponse( }; } return { - content: "Ich konnte keinen Termin 'Sport' finden. Bitte erstelle ihn zuerst.", + content: + "Ich konnte keinen Termin 'Sport' finden. Bitte erstelle ihn zuerst.", }; } @@ -567,7 +566,11 @@ export class ChatService { if (process.env.USE_TEST_RESPONSES === "true") { // Test mode: use static responses - response = await getTestResponse(responseIndex, this.eventService, userId); + response = await getTestResponse( + responseIndex, + this.eventService, + userId, + ); responseIndex++; } else { // Production mode: use real AI @@ -642,7 +645,11 @@ export class ChatService { const createdEvent = await this.eventService.create(userId, event); content = `Der Termin "${createdEvent.title}" wurde erstellt.`; } else if (action === "update" && eventId && updates) { - const updatedEvent = await this.eventService.update(eventId, userId, updates); + const updatedEvent = await this.eventService.update( + eventId, + userId, + updates, + ); content = updatedEvent ? `Der Termin "${updatedEvent.title}" wurde aktualisiert.` : "Termin nicht gefunden."; diff --git a/apps/server/src/services/EventService.ts b/apps/server/src/services/EventService.ts index 47d2048..b8478e2 100644 --- a/apps/server/src/services/EventService.ts +++ b/apps/server/src/services/EventService.ts @@ -24,7 +24,10 @@ export class EventService { return event; } - async findByCaldavUUID(userId: string, caldavUUID: string): Promise { + async findByCaldavUUID( + userId: string, + caldavUUID: string, + ): Promise { return this.eventRepo.findByCaldavUUID(userId, caldavUUID); } diff --git a/apps/server/src/services/interfaces/AIProvider.ts b/apps/server/src/services/interfaces/AIProvider.ts index 870816c..2189ca2 100644 --- a/apps/server/src/services/interfaces/AIProvider.ts +++ b/apps/server/src/services/interfaces/AIProvider.ts @@ -11,7 +11,10 @@ export interface AIContext { currentDate: Date; // Callback to load events from a specific date range // Returns ExpandedEvent[] with occurrenceStart/occurrenceEnd for recurring events - fetchEventsInRange: (startDate: Date, endDate: Date) => Promise; + fetchEventsInRange: ( + startDate: Date, + endDate: Date, + ) => Promise; // Callback to search events by title searchEvents: (query: string) => Promise; // Callback to fetch a single event by ID diff --git a/apps/server/src/services/interfaces/EventRepository.ts b/apps/server/src/services/interfaces/EventRepository.ts index 8a87896..85a3ff7 100644 --- a/apps/server/src/services/interfaces/EventRepository.ts +++ b/apps/server/src/services/interfaces/EventRepository.ts @@ -8,7 +8,10 @@ export interface EventRepository { startDate: Date, endDate: Date, ): Promise; - findByCaldavUUID(userId: string, caldavUUID: string): Promise; + findByCaldavUUID( + userId: string, + caldavUUID: string, + ): Promise; searchByTitle(userId: string, query: string): Promise; create(userId: string, data: CreateEventDTO): Promise; update(id: string, data: UpdateEventDTO): Promise; diff --git a/packages/shared/src/utils/rruleHelpers.ts b/packages/shared/src/utils/rruleHelpers.ts index 0293ade..fd20de3 100644 --- a/packages/shared/src/utils/rruleHelpers.ts +++ b/packages/shared/src/utils/rruleHelpers.ts @@ -36,7 +36,10 @@ const REPEAT_TYPE_SINGULAR: Record = { * @param interval - The interval between repetitions (default: 1) * @returns RRULE string like "FREQ=WEEKLY;INTERVAL=2" */ -export function buildRRule(repeatType: RepeatType, interval: number = 1): string { +export function buildRRule( + repeatType: RepeatType, + interval: number = 1, +): string { const freq = REPEAT_TYPE_TO_FREQ[repeatType]; if (interval <= 1) {