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.
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ export class MongoCaldavRepository implements CaldavRepository {
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<boolean> {
|
||||
const result = await CaldavConfigModel.findOneAndDelete({userId});
|
||||
const result = await CaldavConfigModel.findOneAndDelete({ userId });
|
||||
return result !== null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user