Compare commits
2 Commits
b94b5f5ed8
...
868e1ba68d
| Author | SHA1 | Date | |
|---|---|---|---|
| 868e1ba68d | |||
| 0e406e4dca |
17
CLAUDE.md
17
CLAUDE.md
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
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,
|
type MessageData,
|
||||||
} from "./ChatStore";
|
} from "./ChatStore";
|
||||||
export { useEventsStore } from "./EventsStore";
|
export { useEventsStore } from "./EventsStore";
|
||||||
|
export { useCaldavConfigStore } from "./CaldavConfigStore";
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ app.use(
|
|||||||
authController,
|
authController,
|
||||||
chatController,
|
chatController,
|
||||||
eventController,
|
eventController,
|
||||||
caldavController
|
caldavController,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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.";
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user