Compare commits

...

2 Commits

Author SHA1 Message Date
e5cd64367d feat: add sync and logout toolbar to calendar screen
- Add CalendarToolbar component between header and weekdays in calendar.tsx
- Sync button with CalDAV sync, spinner during sync, green checkmark on success, red X on error (3s feedback)
- Sync button disabled/greyed out when no CalDAV config present
- Logout button with redirect to login screen
- Buttons styled with border and shadow
- Update CLAUDE.md with CalendarToolbar documentation
2026-02-09 23:51:43 +01:00
b9ffc6c908 refactor: reduce CalDAV sync to login and manual sync button only
- Remove auto-login sync in AuthGuard
- Remove 10s interval sync and syncAndReload in calendar tab
- Remove lazy syncOnce pattern in ChatService AI callbacks
- Remove CaldavService dependency from ChatService constructor
2026-02-09 23:32:04 +01:00
5 changed files with 135 additions and 48 deletions

View File

@@ -76,7 +76,7 @@ src/
│ ├── (tabs)/ # Tab navigation group │ ├── (tabs)/ # Tab navigation group
│ │ ├── _layout.tsx # Tab bar configuration (themed) │ │ ├── _layout.tsx # Tab bar configuration (themed)
│ │ ├── chat.tsx # Chat screen (AI conversation) │ │ ├── chat.tsx # Chat screen (AI conversation)
│ │ ├── calendar.tsx # Calendar overview │ │ ├── calendar.tsx # Calendar overview (with CalendarToolbar: sync + logout)
│ │ └── settings.tsx # Settings screen (theme switcher, logout, CalDAV config with feedback) │ │ └── settings.tsx # Settings screen (theme switcher, logout, CalDAV config with feedback)
│ ├── editEvent.tsx # Event edit screen (dual-mode: calendar/chat) │ ├── editEvent.tsx # Event edit screen (dual-mode: calendar/chat)
│ ├── event/ │ ├── event/
@@ -637,6 +637,7 @@ NODE_ENV=development # development = pretty logs, production = JSON
- `EventCard`: Uses EventCardBase + edit/delete buttons (TouchableOpacity with delayPressIn for scroll-friendly touch handling) - `EventCard`: Uses EventCardBase + edit/delete buttons (TouchableOpacity with delayPressIn for scroll-friendly touch handling)
- `ProposedEventCard`: Uses EventCardBase + confirm/reject/edit buttons for chat proposals, displays green highlighted text for new changes ("Neue Ausnahme: [date]" for single delete, "Neues Ende: [date]" for UNTIL updates), shows yellow conflict warnings when proposed time overlaps with existing events. Edit button allows modifying proposals before confirming. - `ProposedEventCard`: Uses EventCardBase + confirm/reject/edit buttons for chat proposals, displays green highlighted text for new changes ("Neue Ausnahme: [date]" for single delete, "Neues Ende: [date]" for UNTIL updates), shows yellow conflict warnings when proposed time overlaps with existing events. Edit button allows modifying proposals before confirming.
- `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
- `CalendarToolbar` (in calendar.tsx): Toolbar between header and weekdays with Sync button (CalDAV sync with spinner/green checkmark/red X feedback, disabled without config) and Logout button
- `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[], preloaded by AuthGuard - `EventsStore`: Zustand store with setEvents(), addEvent(), updateEvent(), deleteEvent() - stores ExpandedEvent[], preloaded by AuthGuard

View File

@@ -1,4 +1,4 @@
import { Pressable, Text, View } from "react-native"; import { ActivityIndicator, Pressable, Text, View } from "react-native";
import { import {
DAYS, DAYS,
MONTHS, MONTHS,
@@ -16,10 +16,11 @@ 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";
import BaseBackground from "../../components/BaseBackground"; import BaseBackground from "../../components/BaseBackground";
import { EventService } from "../../services"; import { AuthService, EventService } from "../../services";
import { CaldavConfigService } from "../../services/CaldavConfigService";
import { useEventsStore } from "../../stores"; import { useEventsStore } from "../../stores";
import { useDropdownPosition } from "../../hooks/useDropdownPosition"; import { useDropdownPosition } from "../../hooks/useDropdownPosition";
import { CaldavConfigService } from "../../services/CaldavConfigService";
import { useCaldavConfigStore } from "../../stores/CaldavConfigStore";
// MonthSelector types and helpers // MonthSelector types and helpers
type MonthItem = { type MonthItem = {
@@ -108,25 +109,11 @@ const Calendar = () => {
} }
}, [monthIndex, currentYear, setEvents]); }, [monthIndex, currentYear, setEvents]);
// Sync CalDAV in background, then reload events // Load events from DB on focus
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( useFocusEffect(
useCallback(() => { useCallback(() => {
loadEvents(); loadEvents();
syncAndReload(); }, [loadEvents]),
const interval = setInterval(syncAndReload, 10_000);
return () => clearInterval(interval);
}, [loadEvents, syncAndReload]),
); );
// Re-open overlay after back navigation from editEvent // Re-open overlay after back navigation from editEvent
@@ -254,6 +241,7 @@ const Calendar = () => {
setMonthIndex={setMonthIndex} setMonthIndex={setMonthIndex}
setYear={setCurrentYear} setYear={setCurrentYear}
/> />
<CalendarToolbar loadEvents={loadEvents} />
<WeekDaysLine /> <WeekDaysLine />
<CalendarGrid <CalendarGrid
month={MONTHS[monthIndex]} month={MONTHS[monthIndex]}
@@ -558,6 +546,131 @@ const ChangeMonthButton = (props: ChangeMonthButtonProps) => {
); );
}; };
type CalendarToolbarProps = {
loadEvents: () => Promise<void>;
};
const CalendarToolbar = ({ loadEvents }: CalendarToolbarProps) => {
const { theme } = useThemeStore();
const { config } = useCaldavConfigStore();
const [isSyncing, setIsSyncing] = useState(false);
const [syncResult, setSyncResult] = useState<"success" | "error" | null>(
null,
);
const handleSync = async () => {
if (!config || isSyncing) return;
setSyncResult(null);
setIsSyncing(true);
try {
await CaldavConfigService.sync();
await loadEvents();
setSyncResult("success");
} catch (error) {
console.error("CalDAV sync failed:", error);
setSyncResult("error");
} finally {
setIsSyncing(false);
}
};
useEffect(() => {
if (!syncResult) return;
const timer = setTimeout(() => setSyncResult(null), 3000);
return () => clearTimeout(timer);
}, [syncResult]);
const handleLogout = async () => {
await AuthService.logout();
router.replace("/login");
};
const syncIcon = () => {
if (isSyncing) {
return <ActivityIndicator size="small" color={theme.primeFg} />;
}
if (syncResult === "success") {
return (
<Ionicons
name="checkmark-circle"
size={20}
color={theme.confirmButton}
/>
);
}
if (syncResult === "error") {
return (
<Ionicons name="close-circle" size={20} color={theme.rejectButton} />
);
}
return (
<Ionicons
name="sync-outline"
size={20}
color={config ? theme.primeFg : theme.textMuted}
/>
);
};
const buttonStyle = {
backgroundColor: theme.chatBot,
borderColor: theme.borderPrimary,
borderWidth: 1,
shadowColor: theme.shadowColor,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 4,
elevation: 4,
};
const className = "flex flex-row items-center gap-2 px-3 py-1 rounded-lg"
return (
<View
className="flex flex-row items-center justify-around py-2"
style={{
backgroundColor: theme.primeBg,
borderBottomWidth: 0,
borderBottomColor: theme.borderPrimary,
}}
>
<Pressable
onPress={handleSync}
disabled={!config || isSyncing}
className={className}
style={{
...buttonStyle,
...(config
? {}
: {
backgroundColor: theme.disabledButton,
borderColor: theme.disabledButton,
}),
}}
>
{syncIcon()}
<Text
style={{ color: config ? theme.textPrimary : theme.textMuted }}
className="font-medium"
>
Sync
</Text>
</Pressable>
<Pressable
onPress={handleLogout}
className={className}
style={buttonStyle}
>
<Ionicons name="log-out-outline" size={20} color={theme.primeFg} />
<Text style={{ color: theme.textPrimary }} className="font-medium">
Logout
</Text>
</Pressable>
</View>
);
};
const WeekDaysLine = () => { const WeekDaysLine = () => {
const { theme } = useThemeStore(); const { theme } = useThemeStore();
return ( return (

View File

@@ -62,11 +62,6 @@ export const AuthGuard = ({ children }: AuthGuardProps) => {
if (!useAuthStore.getState().isAuthenticated) return; if (!useAuthStore.getState().isAuthenticated) return;
await preloadAppData(); await preloadAppData();
setDataReady(true); setDataReady(true);
try {
await CaldavConfigService.sync();
} catch {
// No CalDAV config or sync failed — not critical
}
}; };
init(); init();
}, [loadStoredUser]); }, [loadStoredUser]);

View File

@@ -63,12 +63,7 @@ const aiProvider = new GPTAdapter();
const authService = new AuthService(userRepo); const authService = new AuthService(userRepo);
const eventService = new EventService(eventRepo); const eventService = new EventService(eventRepo);
const caldavService = new CaldavService(caldavRepo, eventService); const caldavService = new CaldavService(caldavRepo, eventService);
const chatService = new ChatService( const chatService = new ChatService(chatRepo, eventService, aiProvider);
chatRepo,
eventService,
aiProvider,
caldavService,
);
// Initialize controllers // Initialize controllers
const authController = new AuthController(authService); const authController = new AuthController(authService);

View File

@@ -14,7 +14,6 @@ import {
} from "@calchat/shared"; } from "@calchat/shared";
import { ChatRepository, AIProvider } from "./interfaces"; import { ChatRepository, AIProvider } from "./interfaces";
import { EventService } from "./EventService"; import { EventService } from "./EventService";
import { CaldavService } from "./CaldavService";
import { getWeeksOverview, getMonthOverview } from "../utils/eventFormatters"; import { getWeeksOverview, getMonthOverview } from "../utils/eventFormatters";
type TestResponse = { type TestResponse = {
@@ -543,7 +542,6 @@ export class ChatService {
private chatRepo: ChatRepository, private chatRepo: ChatRepository,
private eventService: EventService, private eventService: EventService,
private aiProvider: AIProvider, private aiProvider: AIProvider,
private caldavService: CaldavService,
) {} ) {}
async processMessage( async processMessage(
@@ -578,32 +576,17 @@ export class ChatService {
limit: 20, limit: 20,
}); });
// Lazy CalDAV sync: only sync once when the AI first accesses event data
let hasSynced = false;
const syncOnce = async () => {
if (hasSynced) return;
hasSynced = true;
try {
await this.caldavService.sync(userId);
} catch {
// CalDAV sync is not critical for AI responses
}
};
response = await this.aiProvider.processMessage(data.content, { response = await this.aiProvider.processMessage(data.content, {
userId, userId,
conversationHistory: history, conversationHistory: history,
currentDate: new Date(), currentDate: new Date(),
fetchEventsInRange: async (start, end) => { fetchEventsInRange: async (start, end) => {
await syncOnce();
return this.eventService.getByDateRange(userId, start, end); return this.eventService.getByDateRange(userId, start, end);
}, },
searchEvents: async (query) => { searchEvents: async (query) => {
await syncOnce();
return this.eventService.searchByTitle(userId, query); return this.eventService.searchByTitle(userId, query);
}, },
fetchEventById: async (eventId) => { fetchEventById: async (eventId) => {
await syncOnce();
return this.eventService.getById(eventId, userId); return this.eventService.getById(eventId, userId);
}, },
}); });