Compare commits

..

2 Commits

Author SHA1 Message Date
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
22 changed files with 195 additions and 83 deletions

View File

@@ -84,7 +84,7 @@ src/
│ └── note/ │ └── note/
│ └── [id].tsx # Note editor for event (dynamic route) │ └── [id].tsx # Note editor for event (dynamic route)
├── components/ ├── components/
│ ├── AuthGuard.tsx # Auth wrapper: loads user, 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) │ ├── BaseBackground.tsx # Common screen wrapper (themed)
│ ├── BaseButton.tsx # Reusable button component (themed, supports children) │ ├── BaseButton.tsx # Reusable button component (themed, supports children)
│ ├── Header.tsx # Header component (themed) │ ├── Header.tsx # Header component (themed)
@@ -118,6 +118,7 @@ src/
│ │ # Uses expo-secure-store (native) / localStorage (web) │ │ # Uses expo-secure-store (native) / localStorage (web)
│ ├── ChatStore.ts # messages[], isWaitingForResponse, addMessage(), addMessages(), updateMessage(), clearMessages(), setWaitingForResponse(), chatMessageToMessageData() │ ├── ChatStore.ts # messages[], isWaitingForResponse, addMessage(), addMessages(), updateMessage(), clearMessages(), setWaitingForResponse(), chatMessageToMessageData()
│ ├── EventsStore.ts # events[], setEvents(), addEvent(), updateEvent(), deleteEvent() │ ├── 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 │ └── ThemeStore.ts # theme, setTheme() - reactive theme switching with Zustand
└── hooks/ └── hooks/
└── useDropdownPosition.ts # Hook for positioning dropdowns relative to trigger element └── useDropdownPosition.ts # Hook for positioning dropdowns relative to trigger element
@@ -128,9 +129,12 @@ src/
**Authentication Flow:** **Authentication Flow:**
- `AuthGuard` component wraps the tab layout in `(tabs)/_layout.tsx` - `AuthGuard` component wraps the tab layout in `(tabs)/_layout.tsx`
- On app start, `AuthGuard` calls `loadStoredUser()` and shows loading indicator - On app start, `AuthGuard` calls `loadStoredUser()` and shows loading indicator
- After auth, `preloadAppData()` loads events (current month) + CalDAV config into stores before dismissing spinner
- If not authenticated, redirects to `/login` - If not authenticated, redirects to `/login`
- `login.tsx` also calls `preloadAppData()` after successful login (spinner stays visible during preload)
- `index.tsx` simply redirects to `/(tabs)/chat` - AuthGuard handles the rest - `index.tsx` simply redirects to `/(tabs)/chat` - AuthGuard handles the rest
- This pattern handles Expo Router's navigation state caching (avoids race conditions) - This pattern handles Expo Router's navigation state caching (avoids race conditions)
- Preloading prevents empty screens when navigating to Calendar or Settings tabs for the first time
### Theme System ### Theme System
@@ -405,7 +409,7 @@ CalDAV sync with external calendar servers (e.g., Radicale) using `tsdav` and `i
**Sync Triggers (client-side via `CaldavConfigService.sync()`):** **Sync Triggers (client-side via `CaldavConfigService.sync()`):**
- **Login** (`login.tsx`): After successful authentication - **Login** (`login.tsx`): After successful authentication
- **Auto-login** (`AuthGuard.tsx`): After `loadStoredUser()` if authenticated - **Auto-login** (`AuthGuard.tsx`): After `loadStoredUser()` if authenticated
- **Calendar timer** (`calendar.tsx`): Every 10s while Calendar tab is focused, via `setInterval` in `useFocusEffect` - **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 - **Sync button** (`settings.tsx`): Manual trigger in CaldavSettings
**Lazy sync (server-side in ChatService):** **Lazy sync (server-side in ChatService):**
@@ -597,8 +601,8 @@ NODE_ENV=development # development = pretty logs, production = JSON
- `AuthStore`: Manages user state with expo-secure-store (native) / localStorage (web) - `AuthStore`: Manages user state with expo-secure-store (native) / localStorage (web)
- `AuthService`: login(), register(), logout() - calls backend API - `AuthService`: login(), register(), logout() - calls backend API
- `ApiClient`: Automatically injects X-User-Id header for authenticated requests, handles empty responses (204) - `ApiClient`: Automatically injects X-User-Id header for authenticated requests, handles empty responses (204)
- `AuthGuard`: Reusable component that wraps protected routes - loads user, triggers CalDAV sync on auto-login, shows loading, redirects if unauthenticated - `AuthGuard`: Reusable component that wraps protected routes - loads user, preloads app data (events + CalDAV config) into stores before dismissing spinner, triggers CalDAV sync, shows loading, redirects if unauthenticated. Exports `preloadAppData()` (also called by `login.tsx`)
- Login screen: Supports email OR userName login, triggers CalDAV sync after successful login - 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 - Register screen: Email validation, checks for existing email/userName
- `AuthButton`: Reusable button component with themed shadow - `AuthButton`: Reusable button component with themed shadow
- `Header`: Themed header component (logout moved to Settings) - `Header`: Themed header component (logout moved to Settings)
@@ -618,7 +622,7 @@ NODE_ENV=development # development = pretty logs, production = JSON
- Orange dot indicator for days with events - Orange dot indicator for days with events
- Tap-to-open modal overlay showing EventCards for selected day - Tap-to-open modal overlay showing EventCards for selected day
- Supports events from adjacent months visible in grid - Supports events from adjacent months visible in grid
- Uses `useFocusEffect` for automatic reload on tab focus with periodic CalDAV sync (10s interval while focused) - 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 - DeleteEventModal integration for recurring event deletion with three modes
- EventOverlay hides when DeleteEventModal is open (fixes modal stacking on web) - EventOverlay hides when DeleteEventModal is open (fixes modal stacking on web)
- Chat screen fully functional with FlashList, message sending, and event confirm/reject - Chat screen fully functional with FlashList, message sending, and event confirm/reject
@@ -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 - `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 - `EventOverlay` (in calendar.tsx): Day events overlay using ModalBase - shows EventCards for selected day
- `Themes.tsx`: Theme definitions with THEMES object (defaultLight, defaultDark) including all color tokens (textPrimary, borderPrimary, eventIndicator, secondaryBg, shadowColor, etc.) - `Themes.tsx`: Theme definitions with THEMES object (defaultLight, defaultDark) including all color tokens (textPrimary, borderPrimary, eventIndicator, secondaryBg, shadowColor, etc.)
- `EventsStore`: Zustand store with setEvents(), addEvent(), updateEvent(), deleteEvent() - stores ExpandedEvent[] - `EventsStore`: Zustand store with setEvents(), addEvent(), updateEvent(), deleteEvent() - stores ExpandedEvent[], preloaded by AuthGuard
- `CaldavConfigStore`: Zustand store with config (CaldavConfig | null), setConfig() - cached CalDAV config, preloaded by AuthGuard, used by Settings to avoid API call on mount
- `ChatStore`: Zustand store with addMessage(), addMessages(), updateMessage(), clearMessages(), isWaitingForResponse/setWaitingForResponse() for typing indicator - loads from server on mount and persists across tab switches - `ChatStore`: Zustand store with addMessage(), addMessages(), updateMessage(), clearMessages(), isWaitingForResponse/setWaitingForResponse() for typing indicator - loads from server on mount and persists across tab switches
- `ThemeStore`: Zustand store with theme/setTheme() for reactive theme switching across all components - `ThemeStore`: Zustand store with theme/setTheme() for reactive theme switching across all components
- `ChatBubble`: Reusable chat bubble component with Tailwind styling, used by ChatMessage and TypingIndicator - `ChatBubble`: Reusable chat bubble component with Tailwind styling, used by ChatMessage and TypingIndicator

View File

@@ -11,12 +11,7 @@ import { EventCard } from "../../components/EventCard";
import { DeleteEventModal } from "../../components/DeleteEventModal"; import { DeleteEventModal } from "../../components/DeleteEventModal";
import { ModalBase } from "../../components/ModalBase"; import { ModalBase } from "../../components/ModalBase";
import { ScrollableDropdown } from "../../components/ScrollableDropdown"; import { ScrollableDropdown } from "../../components/ScrollableDropdown";
import React, { import React, { useCallback, useEffect, useMemo, useState } from "react";
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { router, useFocusEffect } from "expo-router"; import { router, useFocusEffect } from "expo-router";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { useThemeStore } from "../../stores/ThemeStore"; import { useThemeStore } from "../../stores/ThemeStore";
@@ -85,15 +80,9 @@ const Calendar = () => {
const { events, setEvents, deleteEvent } = useEventsStore(); const { events, setEvents, deleteEvent } = useEventsStore();
// Sync CalDAV then load events for current view // Load events from local DB (fast, no network sync)
const loadEvents = useCallback(async () => { const loadEvents = useCallback(async () => {
try { try {
try {
await CaldavConfigService.sync();
} catch {
// No CalDAV config or sync failed — not critical
}
// Calculate first visible day (up to 6 days before month start) // Calculate first visible day (up to 6 days before month start)
const firstOfMonth = new Date(currentYear, monthIndex, 1); const firstOfMonth = new Date(currentYear, monthIndex, 1);
const dayOfWeek = firstOfMonth.getDay(); const dayOfWeek = firstOfMonth.getDay();
@@ -119,16 +108,25 @@ const Calendar = () => {
} }
}, [monthIndex, currentYear, setEvents]); }, [monthIndex, currentYear, setEvents]);
// Load events when tab gains focus or month/year changes // Sync CalDAV in background, then reload events
// NOTE: Wrapper needed because loadEvents is async (returns Promise) const syncAndReload = useCallback(async () => {
// and useFocusEffect expects a sync function (optionally returning cleanup) try {
await CaldavConfigService.sync();
await loadEvents();
} catch {
// Sync failed — not critical
}
}, [loadEvents]);
// Load events instantly on focus, then sync in background periodically
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
loadEvents(); loadEvents();
syncAndReload();
const interval = setInterval(loadEvents, 10_000); const interval = setInterval(syncAndReload, 10_000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [loadEvents]), }, [loadEvents, syncAndReload]),
); );
// Re-open overlay after back navigation from editEvent // Re-open overlay after back navigation from editEvent

View File

@@ -175,7 +175,11 @@ const Chat = () => {
params: { params: {
mode: "chat", mode: "chat",
eventData: JSON.stringify(proposal.event), eventData: JSON.stringify(proposal.event),
proposalContext: JSON.stringify({ messageId, proposalId, conversationId }), proposalContext: JSON.stringify({
messageId,
proposalId,
conversationId,
}),
}, },
}); });
}; };

View File

@@ -8,8 +8,9 @@ import { Ionicons } from "@expo/vector-icons";
import { SimpleHeader } from "../../components/Header"; import { SimpleHeader } from "../../components/Header";
import { THEMES } from "../../Themes"; import { THEMES } from "../../Themes";
import CustomTextInput from "../../components/CustomTextInput"; import CustomTextInput from "../../components/CustomTextInput";
import { useEffect, useState } from "react"; import { useState } from "react";
import { CaldavConfigService } from "../../services/CaldavConfigService"; import { CaldavConfigService } from "../../services/CaldavConfigService";
import { useCaldavConfigStore } from "../../stores";
const handleLogout = async () => { const handleLogout = async () => {
await AuthService.logout(); await AuthService.logout();
@@ -34,38 +35,38 @@ type CaldavTextInputProps = {
onValueChange: (text: string) => void; onValueChange: (text: string) => void;
}; };
const CaldavTextInput = ({ title, value, onValueChange }: CaldavTextInputProps) => { const CaldavTextInput = ({
title,
value,
onValueChange,
}: CaldavTextInputProps) => {
return ( return (
<View className="flex flex-row items-center py-1"> <View className="flex flex-row items-center py-1">
<Text className="ml-4 w-24">{title}:</Text> <Text className="ml-4 w-24">{title}:</Text>
<CustomTextInput className="flex-1 mr-4" text={value} onValueChange={onValueChange} /> <CustomTextInput
className="flex-1 mr-4"
text={value}
onValueChange={onValueChange}
/>
</View> </View>
); );
}; };
const CaldavSettings = () => { const CaldavSettings = () => {
const { theme } = useThemeStore(); const { theme } = useThemeStore();
const { config, setConfig } = useCaldavConfigStore();
const [serverUrl, setServerUrl] = useState(""); const [serverUrl, setServerUrl] = useState(config?.serverUrl ?? "");
const [username, setUsername] = useState(""); const [username, setUsername] = useState(config?.username ?? "");
const [password, setPassword] = useState(""); const [password, setPassword] = useState(config?.password ?? "");
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 saveConfig = async () => { const saveConfig = async () => {
await CaldavConfigService.saveConfig(serverUrl, username, password); const saved = await CaldavConfigService.saveConfig(
serverUrl,
username,
password,
);
setConfig(saved);
}; };
const sync = async () => { const sync = async () => {
@@ -84,9 +85,21 @@ const CaldavSettings = () => {
</View> </View>
<View> <View>
<View className="pb-1"> <View className="pb-1">
<CaldavTextInput title="url" value={serverUrl} onValueChange={setServerUrl} /> <CaldavTextInput
<CaldavTextInput title="username" value={username} onValueChange={setUsername} /> title="url"
<CaldavTextInput title="password" value={password} onValueChange={setPassword} /> value={serverUrl}
onValueChange={setServerUrl}
/>
<CaldavTextInput
title="username"
value={username}
onValueChange={setUsername}
/>
<CaldavTextInput
title="password"
value={password}
onValueChange={setPassword}
/>
</View> </View>
<View className="flex flex-row"> <View className="flex flex-row">
<BaseButton className="mx-4 w-1/5" solid={true} onPress={saveConfig}> <BaseButton className="mx-4 w-1/5" solid={true} onPress={saveConfig}>

View File

@@ -19,7 +19,12 @@ import { Ionicons } from "@expo/vector-icons";
import { ScrollableDropdown } from "../components/ScrollableDropdown"; import { ScrollableDropdown } from "../components/ScrollableDropdown";
import { useDropdownPosition } from "../hooks/useDropdownPosition"; import { useDropdownPosition } from "../hooks/useDropdownPosition";
import { EventService, ChatService } from "../services"; 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 { useChatStore } from "../stores";
import CustomTextInput, { import CustomTextInput, {
CustomTextInputProps, CustomTextInputProps,

View File

@@ -5,6 +5,7 @@ import BaseBackground from "../components/BaseBackground";
import AuthButton from "../components/AuthButton"; import AuthButton from "../components/AuthButton";
import { AuthService } from "../services"; import { AuthService } from "../services";
import { CaldavConfigService } from "../services/CaldavConfigService"; import { CaldavConfigService } from "../services/CaldavConfigService";
import { preloadAppData } from "../components/AuthGuard";
import { useThemeStore } from "../stores/ThemeStore"; import { useThemeStore } from "../stores/ThemeStore";
const LoginScreen = () => { const LoginScreen = () => {
@@ -25,6 +26,7 @@ const LoginScreen = () => {
setIsLoading(true); setIsLoading(true);
try { try {
await AuthService.login({ identifier, password }); await AuthService.login({ identifier, password });
await preloadAppData();
try { try {
await CaldavConfigService.sync(); await CaldavConfigService.sync();
} catch { } catch {

View File

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

View File

@@ -9,7 +9,12 @@ export type BaseButtonProps = {
solid?: boolean; solid?: boolean;
}; };
const BaseButton = ({ className, children, onPress, solid = false }: BaseButtonProps) => { const BaseButton = ({
className,
children,
onPress,
solid = false,
}: BaseButtonProps) => {
const { theme } = useThemeStore(); const { theme } = useThemeStore();
return ( return (
<Pressable <Pressable

View File

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

View File

@@ -124,8 +124,12 @@ export const ProposedEventCard = ({
color={theme.confirmButton} color={theme.confirmButton}
style={{ marginRight: 8 }} style={{ marginRight: 8 }}
/> />
<Text style={{ color: theme.confirmButton }} className="font-medium"> <Text
Neue Ausnahme: {formatDate(new Date(proposedChange.occurrenceDate!))} style={{ color: theme.confirmButton }}
className="font-medium"
>
Neue Ausnahme:{" "}
{formatDate(new Date(proposedChange.occurrenceDate!))}
</Text> </Text>
</View> </View>
)} )}
@@ -138,7 +142,10 @@ export const ProposedEventCard = ({
color={theme.confirmButton} color={theme.confirmButton}
style={{ marginRight: 8 }} style={{ marginRight: 8 }}
/> />
<Text style={{ color: theme.confirmButton }} className="font-medium"> <Text
style={{ color: theme.confirmButton }}
className="font-medium"
>
Neues Ende: {formatDate(newUntilDate)} Neues Ende: {formatDate(newUntilDate)}
</Text> </Text>
</View> </View>

View File

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

View File

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

View File

@@ -83,7 +83,7 @@ app.use(
authController, authController,
chatController, chatController,
eventController, eventController,
caldavController caldavController,
}), }),
); );

View File

@@ -25,7 +25,7 @@ export class MongoCaldavRepository implements CaldavRepository {
} }
async deleteByUserId(userId: string): Promise<boolean> { async deleteByUserId(userId: string): Promise<boolean> {
const result = await CaldavConfigModel.findOneAndDelete({userId}); const result = await CaldavConfigModel.findOneAndDelete({ userId });
return result !== null; return result !== null;
} }
} }

View File

@@ -28,7 +28,10 @@ export class MongoEventRepository implements EventRepository {
return events.map((e) => e.toJSON() as unknown as CalendarEvent); return events.map((e) => e.toJSON() as unknown as CalendarEvent);
} }
async findByCaldavUUID(userId: string, caldavUUID: string): Promise<CalendarEvent | null> { async findByCaldavUUID(
userId: string,
caldavUUID: string,
): Promise<CalendarEvent | null> {
const event = await EventModel.findOne({ userId, caldavUUID }); const event = await EventModel.findOne({ userId, caldavUUID });
if (!event) return null; if (!event) return null;
return event.toJSON() as unknown as CalendarEvent; return event.toJSON() as unknown as CalendarEvent;

View File

@@ -2,9 +2,7 @@ import mongoose, { Schema, Document, Model } from "mongoose";
import { CalendarEvent } from "@calchat/shared"; import { CalendarEvent } from "@calchat/shared";
import { IdVirtual } from "./types"; import { IdVirtual } from "./types";
export interface EventDocument export interface EventDocument extends Omit<CalendarEvent, "id">, Document {
extends Omit<CalendarEvent, "id">,
Document {
toJSON(): CalendarEvent; toJSON(): CalendarEvent;
} }

View File

@@ -6,7 +6,7 @@ import {
AuthController, AuthController,
ChatController, ChatController,
EventController, EventController,
CaldavController CaldavController,
} from "../controllers"; } from "../controllers";
import { createCaldavRoutes } from "./caldav.routes"; import { createCaldavRoutes } from "./caldav.routes";

View File

@@ -363,9 +363,7 @@ async function getTestResponse(
event: { event: {
title: sportEvent.title, title: sportEvent.title,
startTime: exceptionDate, startTime: exceptionDate,
endTime: new Date( endTime: new Date(exceptionDate.getTime() + 90 * 60 * 1000), // +90 min
exceptionDate.getTime() + 90 * 60 * 1000,
), // +90 min
description: sportEvent.description, description: sportEvent.description,
recurrenceRule: sportEvent.recurrenceRule, recurrenceRule: sportEvent.recurrenceRule,
exceptionDates: sportEvent.exceptionDates, exceptionDates: sportEvent.exceptionDates,
@@ -375,7 +373,8 @@ async function getTestResponse(
}; };
} }
return { 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 // Calculate UNTIL date: 6 weeks from start
const untilDate = new Date(sportEvent.startTime); const untilDate = new Date(sportEvent.startTime);
untilDate.setDate(untilDate.getDate() + 42); // 6 weeks 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}`; const newRule = `FREQ=WEEKLY;BYDAY=WE;UNTIL=${untilStr}`;
return { return {
content: content: "Alles klar! Ich beende die Sport-Serie nach 6 Wochen:",
"Alles klar! Ich beende die Sport-Serie nach 6 Wochen:",
proposedChanges: [ proposedChanges: [
{ {
id: "sport-until", id: "sport-until",
@@ -413,7 +412,8 @@ async function getTestResponse(
}; };
} }
return { 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: { event: {
title: sportEvent.title, title: sportEvent.title,
startTime: exceptionDate, startTime: exceptionDate,
endTime: new Date( endTime: new Date(exceptionDate.getTime() + 90 * 60 * 1000), // +90 min
exceptionDate.getTime() + 90 * 60 * 1000,
), // +90 min
description: sportEvent.description, description: sportEvent.description,
recurrenceRule: sportEvent.recurrenceRule, recurrenceRule: sportEvent.recurrenceRule,
exceptionDates: sportEvent.exceptionDates, exceptionDates: sportEvent.exceptionDates,
@@ -452,7 +450,8 @@ async function getTestResponse(
}; };
} }
return { 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") { if (process.env.USE_TEST_RESPONSES === "true") {
// Test mode: use static responses // Test mode: use static responses
response = await getTestResponse(responseIndex, this.eventService, userId); response = await getTestResponse(
responseIndex,
this.eventService,
userId,
);
responseIndex++; responseIndex++;
} else { } else {
// Production mode: use real AI // Production mode: use real AI
@@ -642,7 +645,11 @@ export class ChatService {
const createdEvent = await this.eventService.create(userId, event); const createdEvent = await this.eventService.create(userId, event);
content = `Der Termin "${createdEvent.title}" wurde erstellt.`; content = `Der Termin "${createdEvent.title}" wurde erstellt.`;
} else if (action === "update" && eventId && updates) { } else if (action === "update" && eventId && updates) {
const updatedEvent = await this.eventService.update(eventId, userId, updates); const updatedEvent = await this.eventService.update(
eventId,
userId,
updates,
);
content = updatedEvent content = updatedEvent
? `Der Termin "${updatedEvent.title}" wurde aktualisiert.` ? `Der Termin "${updatedEvent.title}" wurde aktualisiert.`
: "Termin nicht gefunden."; : "Termin nicht gefunden.";

View File

@@ -24,7 +24,10 @@ export class EventService {
return event; return event;
} }
async findByCaldavUUID(userId: string, caldavUUID: string): Promise<CalendarEvent | null> { async findByCaldavUUID(
userId: string,
caldavUUID: string,
): Promise<CalendarEvent | null> {
return this.eventRepo.findByCaldavUUID(userId, caldavUUID); return this.eventRepo.findByCaldavUUID(userId, caldavUUID);
} }

View File

@@ -11,7 +11,10 @@ export interface AIContext {
currentDate: Date; currentDate: Date;
// Callback to load events from a specific date range // Callback to load events from a specific date range
// Returns ExpandedEvent[] with occurrenceStart/occurrenceEnd for recurring events // Returns ExpandedEvent[] with occurrenceStart/occurrenceEnd for recurring events
fetchEventsInRange: (startDate: Date, endDate: Date) => Promise<ExpandedEvent[]>; fetchEventsInRange: (
startDate: Date,
endDate: Date,
) => Promise<ExpandedEvent[]>;
// Callback to search events by title // Callback to search events by title
searchEvents: (query: string) => Promise<CalendarEvent[]>; searchEvents: (query: string) => Promise<CalendarEvent[]>;
// Callback to fetch a single event by ID // Callback to fetch a single event by ID

View File

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

View File

@@ -36,7 +36,10 @@ const REPEAT_TYPE_SINGULAR: Record<RepeatType, string> = {
* @param interval - The interval between repetitions (default: 1) * @param interval - The interval between repetitions (default: 1)
* @returns RRULE string like "FREQ=WEEKLY;INTERVAL=2" * @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]; const freq = REPEAT_TYPE_TO_FREQ[repeatType];
if (interval <= 1) { if (interval <= 1) {