Compare commits
2 Commits
b94b5f5ed8
...
868e1ba68d
| Author | SHA1 | Date | |
|---|---|---|---|
| 868e1ba68d | |||
| 0e406e4dca |
17
CLAUDE.md
17
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
|
||||
|
||||
@@ -405,7 +409,7 @@ CalDAV sync with external calendar servers (e.g., Radicale) using `tsdav` and `i
|
||||
**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`): 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
|
||||
|
||||
**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)
|
||||
- `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)
|
||||
@@ -618,7 +622,7 @@ NODE_ENV=development # development = pretty logs, production = JSON
|
||||
- Orange dot indicator for days with events
|
||||
- Tap-to-open modal overlay showing EventCards for selected day
|
||||
- Supports events from adjacent months visible in grid
|
||||
- Uses `useFocusEffect` for automatic reload on tab focus 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
|
||||
- EventOverlay hides when DeleteEventModal is open (fixes modal stacking on web)
|
||||
- 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
|
||||
- `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
|
||||
|
||||
@@ -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";
|
||||
@@ -85,15 +80,9 @@ const Calendar = () => {
|
||||
|
||||
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 () => {
|
||||
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)
|
||||
const firstOfMonth = new Date(currentYear, monthIndex, 1);
|
||||
const dayOfWeek = firstOfMonth.getDay();
|
||||
@@ -119,16 +108,25 @@ const Calendar = () => {
|
||||
}
|
||||
}, [monthIndex, currentYear, setEvents]);
|
||||
|
||||
// Load events when tab gains focus or month/year changes
|
||||
// NOTE: Wrapper needed because loadEvents is async (returns Promise)
|
||||
// and useFocusEffect expects a sync function (optionally returning cleanup)
|
||||
// Sync CalDAV in background, then reload events
|
||||
const syncAndReload = useCallback(async () => {
|
||||
try {
|
||||
await CaldavConfigService.sync();
|
||||
await loadEvents();
|
||||
} catch {
|
||||
// Sync failed — not critical
|
||||
}
|
||||
}, [loadEvents]);
|
||||
|
||||
// Load events instantly on focus, then sync in background periodically
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
loadEvents();
|
||||
syncAndReload();
|
||||
|
||||
const interval = setInterval(loadEvents, 10_000);
|
||||
const interval = setInterval(syncAndReload, 10_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadEvents]),
|
||||
}, [loadEvents, syncAndReload]),
|
||||
);
|
||||
|
||||
// Re-open overlay after back navigation from editEvent
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<View className="flex flex-row items-center py-1">
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
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 = () => {
|
||||
</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} />
|
||||
<CaldavTextInput
|
||||
title="url"
|
||||
value={serverUrl}
|
||||
onValueChange={setServerUrl}
|
||||
/>
|
||||
<CaldavTextInput
|
||||
title="username"
|
||||
value={username}
|
||||
onValueChange={setUsername}
|
||||
/>
|
||||
<CaldavTextInput
|
||||
title="password"
|
||||
value={password}
|
||||
onValueChange={setPassword}
|
||||
/>
|
||||
</View>
|
||||
<View className="flex flex-row">
|
||||
<BaseButton className="mx-4 w-1/5" solid={true} onPress={saveConfig}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
<View
|
||||
style={{
|
||||
|
||||
@@ -9,7 +9,12 @@ export type BaseButtonProps = {
|
||||
solid?: boolean;
|
||||
};
|
||||
|
||||
const BaseButton = ({ className, children, onPress, solid = false }: BaseButtonProps) => {
|
||||
const BaseButton = ({
|
||||
className,
|
||||
children,
|
||||
onPress,
|
||||
solid = false,
|
||||
}: BaseButtonProps) => {
|
||||
const { theme } = useThemeStore();
|
||||
return (
|
||||
<Pressable
|
||||
|
||||
@@ -126,11 +126,11 @@ const DateTimePickerButton = ({
|
||||
|
||||
// Convenience wrappers for simpler usage
|
||||
export const DatePickerButton = (
|
||||
props: Omit<DateTimePickerButtonProps, "mode">
|
||||
props: Omit<DateTimePickerButtonProps, "mode">,
|
||||
) => <DateTimePickerButton {...props} mode="date" />;
|
||||
|
||||
export const TimePickerButton = (
|
||||
props: Omit<DateTimePickerButtonProps, "mode">
|
||||
props: Omit<DateTimePickerButtonProps, "mode">,
|
||||
) => <DateTimePickerButton {...props} mode="time" />;
|
||||
|
||||
export default DateTimePickerButton;
|
||||
|
||||
@@ -124,8 +124,12 @@ export const ProposedEventCard = ({
|
||||
color={theme.confirmButton}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<Text style={{ color: theme.confirmButton }} className="font-medium">
|
||||
Neue Ausnahme: {formatDate(new Date(proposedChange.occurrenceDate!))}
|
||||
<Text
|
||||
style={{ color: theme.confirmButton }}
|
||||
className="font-medium"
|
||||
>
|
||||
Neue Ausnahme:{" "}
|
||||
{formatDate(new Date(proposedChange.occurrenceDate!))}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -138,7 +142,10 @@ export const ProposedEventCard = ({
|
||||
color={theme.confirmButton}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<Text style={{ color: theme.confirmButton }} className="font-medium">
|
||||
<Text
|
||||
style={{ color: theme.confirmButton }}
|
||||
className="font-medium"
|
||||
>
|
||||
Neues Ende: {formatDate(newUntilDate)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
14
apps/client/src/stores/CaldavConfigStore.ts
Normal file
14
apps/client/src/stores/CaldavConfigStore.ts
Normal 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 });
|
||||
},
|
||||
}));
|
||||
@@ -5,3 +5,4 @@ export {
|
||||
type MessageData,
|
||||
} from "./ChatStore";
|
||||
export { useEventsStore } from "./EventsStore";
|
||||
export { useCaldavConfigStore } from "./CaldavConfigStore";
|
||||
|
||||
@@ -83,7 +83,7 @@ app.use(
|
||||
authController,
|
||||
chatController,
|
||||
eventController,
|
||||
caldavController
|
||||
caldavController,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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<CalendarEvent | null> {
|
||||
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;
|
||||
|
||||
@@ -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<CalendarEvent, "id">,
|
||||
Document {
|
||||
export interface EventDocument extends Omit<CalendarEvent, "id">, Document {
|
||||
toJSON(): CalendarEvent;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
AuthController,
|
||||
ChatController,
|
||||
EventController,
|
||||
CaldavController
|
||||
CaldavController,
|
||||
} from "../controllers";
|
||||
import { createCaldavRoutes } from "./caldav.routes";
|
||||
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -24,7 +24,10 @@ export class EventService {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ExpandedEvent[]>;
|
||||
fetchEventsInRange: (
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
) => Promise<ExpandedEvent[]>;
|
||||
// Callback to search events by title
|
||||
searchEvents: (query: string) => Promise<CalendarEvent[]>;
|
||||
// Callback to fetch a single event by ID
|
||||
|
||||
@@ -8,7 +8,10 @@ export interface EventRepository {
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
): 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[]>;
|
||||
create(userId: string, data: CreateEventDTO): Promise<CalendarEvent>;
|
||||
update(id: string, data: UpdateEventDTO): Promise<CalendarEvent | null>;
|
||||
|
||||
@@ -36,7 +36,10 @@ const REPEAT_TYPE_SINGULAR: Record<RepeatType, string> = {
|
||||
* @param interval - The interval between repetitions (default: 1)
|
||||
* @returns RRULE string like "FREQ=WEEKLY;INTERVAL=2"
|
||||
*/
|
||||
export function buildRRule(repeatType: RepeatType, interval: number = 1): string {
|
||||
export function buildRRule(
|
||||
repeatType: RepeatType,
|
||||
interval: number = 1,
|
||||
): string {
|
||||
const freq = REPEAT_TYPE_TO_FREQ[repeatType];
|
||||
|
||||
if (interval <= 1) {
|
||||
|
||||
Reference in New Issue
Block a user