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:
2026-02-09 18:59:03 +01:00
parent 0e406e4dca
commit 868e1ba68d
22 changed files with 178 additions and 69 deletions

View File

@@ -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";

View File

@@ -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,
}),
},
});
};

View File

@@ -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}>

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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={{

View File

@@ -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

View File

@@ -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;

View File

@@ -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>

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,
} from "./ChatStore";
export { useEventsStore } from "./EventsStore";
export { useCaldavConfigStore } from "./CaldavConfigStore";