diff --git a/apps/calendar/apps/web/package.json b/apps/calendar/apps/web/package.json index 8110ffe25..39775e513 100644 --- a/apps/calendar/apps/web/package.json +++ b/apps/calendar/apps/web/package.json @@ -55,6 +55,7 @@ "@manacore/shared-help-types": "workspace:*", "@manacore/shared-help-ui": "workspace:*", "@manacore/shared-icons": "workspace:*", + "@manacore/local-store": "workspace:*", "@manacore/shared-profile-ui": "workspace:*", "@manacore/shared-splitscreen": "workspace:*", "@manacore/shared-stores": "workspace:*", diff --git a/apps/calendar/apps/web/src/lib/data/guest-seed.ts b/apps/calendar/apps/web/src/lib/data/guest-seed.ts new file mode 100644 index 000000000..45932fbe6 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/data/guest-seed.ts @@ -0,0 +1,74 @@ +/** + * Guest seed data for the Calendar app. + * + * These records are loaded into IndexedDB when a new guest visits the app. + * They provide a "Persoenlich" calendar with two sample events so the user + * can immediately see how the app works. + */ + +import type { LocalCalendar, LocalEvent } from './local-store'; + +const PERSONAL_CALENDAR_ID = 'personal-calendar'; + +export const guestCalendars: LocalCalendar[] = [ + { + id: PERSONAL_CALENDAR_ID, + name: 'Persönlich', + color: '#3B82F6', + isDefault: true, + isVisible: true, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }, +]; + +const now = new Date(); +const today10 = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 10, 0, 0); +const today11 = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 11, 0, 0); + +const tomorrow = new Date(now); +tomorrow.setDate(tomorrow.getDate() + 1); +const tomorrow14 = new Date( + tomorrow.getFullYear(), + tomorrow.getMonth(), + tomorrow.getDate(), + 14, + 0, + 0 +); +const tomorrow15 = new Date( + tomorrow.getFullYear(), + tomorrow.getMonth(), + tomorrow.getDate(), + 15, + 30, + 0 +); + +export const guestEvents: LocalEvent[] = [ + { + id: 'sample-event-1', + calendarId: PERSONAL_CALENDAR_ID, + title: 'Willkommen bei Kalender!', + description: 'Dies ist ein Beispieltermin. Tippe darauf, um ihn zu bearbeiten oder zu löschen.', + startDate: today10.toISOString(), + endDate: today11.toISOString(), + allDay: false, + location: null, + recurrenceRule: null, + color: null, + reminders: null, + }, + { + id: 'sample-event-2', + calendarId: PERSONAL_CALENDAR_ID, + title: 'Mittagessen mit Freunden', + description: null, + startDate: tomorrow14.toISOString(), + endDate: tomorrow15.toISOString(), + allDay: false, + location: 'Café am See', + recurrenceRule: null, + color: null, + reminders: null, + }, +]; diff --git a/apps/calendar/apps/web/src/lib/data/local-store.ts b/apps/calendar/apps/web/src/lib/data/local-store.ts new file mode 100644 index 000000000..1900b68ce --- /dev/null +++ b/apps/calendar/apps/web/src/lib/data/local-store.ts @@ -0,0 +1,59 @@ +/** + * Calendar App — Local-First Data Layer + * + * Defines the IndexedDB database, collections, and guest seed data. + * This is the single source of truth for all Calendar data. + */ + +import { createLocalStore, type BaseRecord } from '@manacore/local-store'; +import { guestCalendars, guestEvents } from './guest-seed'; + +// ─── Types ────────────────────────────────────────────────── + +export interface LocalCalendar extends BaseRecord { + name: string; + color: string; + isDefault: boolean; + isVisible: boolean; + timezone: string; +} + +export interface LocalEvent extends BaseRecord { + calendarId: string; + title: string; + description?: string | null; + startDate: string; + endDate: string; + allDay: boolean; + location?: string | null; + recurrenceRule?: string | null; + color?: string | null; + reminders?: unknown | null; +} + +// ─── Store ────────────────────────────────────────────────── + +const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050'; + +export const calendarStore = createLocalStore({ + appId: 'calendar', + collections: [ + { + name: 'calendars', + indexes: ['isDefault', 'isVisible'], + guestSeed: guestCalendars, + }, + { + name: 'events', + indexes: ['calendarId', 'startDate', 'endDate', 'allDay', '[calendarId+startDate]'], + guestSeed: guestEvents, + }, + ], + sync: { + serverUrl: SYNC_SERVER_URL, + }, +}); + +// Typed collection accessors +export const calendarCollection = calendarStore.collection('calendars'); +export const eventCollection = calendarStore.collection('events'); diff --git a/apps/calendar/apps/web/src/lib/stores/calendars.svelte.ts b/apps/calendar/apps/web/src/lib/stores/calendars.svelte.ts index e954dbe5b..8cb74b574 100644 --- a/apps/calendar/apps/web/src/lib/stores/calendars.svelte.ts +++ b/apps/calendar/apps/web/src/lib/stores/calendars.svelte.ts @@ -1,28 +1,16 @@ /** - * Calendars Store - Manages user calendars using Svelte 5 runes - * Supports both authenticated (cloud) and guest (session) modes + * Calendars Store — Local-First with IndexedDB + * + * All reads and writes go to IndexedDB first. + * Same public API as before so components don't break. */ import type { Calendar, CreateCalendarInput, UpdateCalendarInput } from '@calendar/shared'; -import * as api from '$lib/api/calendars'; +import { calendarCollection, type LocalCalendar } from '$lib/data/local-store'; import { BIRTHDAY_CALENDAR } from '$lib/api/birthdays'; import { settingsStore } from './settings.svelte'; -import { authStore } from './auth.svelte'; import { CalendarEvents } from '@manacore/shared-utils/analytics'; -// Guest calendar for unauthenticated users -const GUEST_CALENDAR: Calendar = { - id: 'session-calendar', - userId: 'guest', - name: 'Mein Kalender', - color: '#3b82f6', - isDefault: true, - isVisible: true, - timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), -}; - // State let calendars = $state([]); let loading = $state(false); @@ -35,12 +23,27 @@ const birthdayCalendar: Calendar = { name: BIRTHDAY_CALENDAR.name, color: BIRTHDAY_CALENDAR.color, isDefault: false, - isVisible: true, // Visibility controlled by settingsStore.showBirthdays + isVisible: true, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; +/** Convert a LocalCalendar (IndexedDB) to the shared Calendar type. */ +function toCalendar(local: LocalCalendar): Calendar { + return { + id: local.id, + userId: 'guest', + name: local.name, + color: local.color, + isDefault: local.isDefault, + isVisible: local.isVisible, + timezone: local.timezone, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + // Helper to safely get calendars array (Svelte 5 runes safety) function getCalendarsArray(): Calendar[] { const arr = calendars ?? []; @@ -50,7 +53,6 @@ function getCalendarsArray(): Calendar[] { // Derived: all calendars including virtual birthday calendar const allCalendars = $derived.by(() => { const userCalendars = getCalendarsArray(); - // Add virtual birthday calendar if birthdays are enabled in settings if (settingsStore.showBirthdays) { return [...userCalendars, { ...birthdayCalendar, isVisible: true }]; } @@ -91,75 +93,86 @@ export const calendarsStore = { }, /** - * Fetch all calendars - * In guest mode, returns a default local calendar + * Load calendars from IndexedDB. */ async fetchCalendars() { loading = true; error = null; - - // Guest mode: return local calendar only - if (!authStore.isAuthenticated) { - calendars = [GUEST_CALENDAR]; - loading = false; - return { data: { calendars: [GUEST_CALENDAR] }, error: null }; - } - - // Authenticated: fetch from API - const result = await api.getCalendars(); - - if (result.error) { - error = result.error.message; + try { + const localCalendars = await calendarCollection.getAll(); + calendars = localCalendars.map(toCalendar); + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to fetch calendars'; + console.error('Failed to fetch calendars:', e); calendars = []; - } else { - // API returns { calendars: [...] } - const data = result.data as { calendars: Calendar[] } | null; - calendars = data?.calendars || []; + } finally { + loading = false; } - - loading = false; - return result; + return { data: { calendars }, error: null }; }, /** - * Create a new calendar + * Create a new calendar — writes to IndexedDB instantly. */ async createCalendar(data: CreateCalendarInput) { - const result = await api.createCalendar(data); + error = null; + try { + const newLocal: LocalCalendar = { + id: crypto.randomUUID(), + name: data.name, + color: data.color ?? '#3B82F6', + isDefault: data.isDefault ?? false, + isVisible: data.isVisible ?? true, + timezone: data.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone, + }; - if (result.data) { - calendars = [...calendars, result.data]; + const inserted = await calendarCollection.insert(newLocal); + const newCalendar = toCalendar(inserted); + calendars = [...calendars, newCalendar]; CalendarEvents.calendarCreated(); + return { data: newCalendar, error: null }; + } catch (e) { + const msg = e instanceof Error ? e.message : 'Failed to create calendar'; + error = msg; + return { data: null, error: { message: msg } }; } - - return result; }, /** - * Update a calendar + * Update a calendar — writes to IndexedDB instantly. */ async updateCalendar(id: string, data: UpdateCalendarInput) { - const result = await api.updateCalendar(id, data); - - if (result.data) { - calendars = getCalendarsArray().map((c) => (c.id === id ? result.data! : c)); + error = null; + try { + const updated = await calendarCollection.update(id, data as Partial); + if (updated) { + const updatedCalendar = toCalendar(updated); + calendars = getCalendarsArray().map((c) => (c.id === id ? updatedCalendar : c)); + return { data: updatedCalendar, error: null }; + } + return { data: null, error: null }; + } catch (e) { + const msg = e instanceof Error ? e.message : 'Failed to update calendar'; + error = msg; + return { data: null, error: { message: msg } }; } - - return result; }, /** - * Delete a calendar + * Delete a calendar — removes from IndexedDB instantly. */ async deleteCalendar(id: string) { - const result = await api.deleteCalendar(id); - - if (!result.error) { + error = null; + try { + await calendarCollection.delete(id); calendars = getCalendarsArray().filter((c) => c.id !== id); CalendarEvents.calendarDeleted(); + return { error: null }; + } catch (e) { + const msg = e instanceof Error ? e.message : 'Failed to delete calendar'; + error = msg; + return { error: { message: msg } }; } - - return result; }, /** @@ -177,17 +190,30 @@ export const calendarsStore = { * Set a calendar as the default */ async setAsDefault(id: string) { - const result = await api.updateCalendar(id, { isDefault: true }); - - if (result.data) { - // Update local state: set this one as default, remove default from others - calendars = getCalendarsArray().map((c) => ({ - ...c, - isDefault: c.id === id, - })); + error = null; + try { + // Remove default from all others first + for (const cal of getCalendarsArray()) { + if (cal.isDefault && cal.id !== id) { + await calendarCollection.update(cal.id, { isDefault: false } as Partial); + } + } + // Set the new default + const updated = await calendarCollection.update(id, { + isDefault: true, + } as Partial); + if (updated) { + calendars = getCalendarsArray().map((c) => ({ + ...c, + isDefault: c.id === id, + })); + } + return { data: updated ? toCalendar(updated) : null, error: null }; + } catch (e) { + const msg = e instanceof Error ? e.message : 'Failed to set default'; + error = msg; + return { data: null, error: { message: msg } }; } - - return result; }, /** @@ -201,7 +227,6 @@ export const calendarsStore = { * Get calendar color by ID (with fallback) */ getColor(id: string) { - // Handle virtual birthday calendar if (id === BIRTHDAY_CALENDAR.id) { return BIRTHDAY_CALENDAR.color; } @@ -211,7 +236,6 @@ export const calendarsStore = { /** * Toggle birthday calendar visibility - * (This updates the settings store, not the calendar itself) */ toggleBirthdaysVisibility() { settingsStore.set('showBirthdays', !settingsStore.showBirthdays); @@ -228,13 +252,19 @@ export const calendarsStore = { * Check if a calendar ID is the guest calendar */ isGuestCalendar(id: string) { - return id === GUEST_CALENDAR.id; + return id === 'personal-calendar'; }, /** * Get the guest calendar ID */ get guestCalendarId() { - return GUEST_CALENDAR.id; + return 'personal-calendar'; + }, + + clear() { + calendars = []; + loading = false; + error = null; }, }; diff --git a/apps/calendar/apps/web/src/lib/stores/events.svelte.ts b/apps/calendar/apps/web/src/lib/stores/events.svelte.ts index ce66a859f..70ec3cb13 100644 --- a/apps/calendar/apps/web/src/lib/stores/events.svelte.ts +++ b/apps/calendar/apps/web/src/lib/stores/events.svelte.ts @@ -1,10 +1,13 @@ /** - * Events Store - Manages calendar events using Svelte 5 runes + * Events Store — Local-First with IndexedDB + * + * All reads and writes go to IndexedDB first. + * Same public API as before so components don't break. */ import type { CalendarEvent, CreateEventInput, UpdateEventInput } from '@calendar/shared'; import { parseRRule, generateOccurrences } from '@calendar/shared'; -import * as api from '$lib/api/events'; +import { eventCollection, type LocalEvent } from '$lib/data/local-store'; import { format, isWithinInterval, isSameDay, differenceInMilliseconds } from 'date-fns'; import { toDate } from '$lib/utils/eventDateHelpers'; import { toastStore } from '@manacore/shared-ui'; @@ -21,6 +24,32 @@ let loadedRange = $state<{ start: Date; end: Date } | null>(null); // Draft event for quick create (temporary event shown in grid before saving) let draftEvent = $state(null); +/** Convert a LocalEvent (IndexedDB) to the shared CalendarEvent type. */ +function toCalendarEvent(local: LocalEvent): CalendarEvent { + return { + id: local.id, + calendarId: local.calendarId, + userId: 'guest', + title: local.title, + description: local.description ?? null, + location: local.location ?? null, + startTime: local.startDate, + endTime: local.endDate, + isAllDay: local.allDay, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + recurrenceRule: local.recurrenceRule ?? null, + recurrenceEndDate: null, + recurrenceExceptions: null, + parentEventId: null, + color: local.color ?? null, + status: 'confirmed', + externalId: null, + metadata: null, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + /** * Expand recurring events into individual occurrences for the current view range. * Each occurrence gets a synthetic ID: `{parentId}__recurrence__{dateISO}` @@ -84,38 +113,48 @@ export const eventsStore = { }, /** - * Fetch events for a date range + * Fetch events for a date range — reads from IndexedDB. */ async fetchEvents(startDate: Date, endDate: Date, calendarIds?: string[]) { loading = true; error = null; - const result = await api.getEvents({ - startDate: format(startDate, "yyyy-MM-dd'T'HH:mm:ss"), - endDate: format(endDate, "yyyy-MM-dd'T'HH:mm:ss"), - calendarIds, - }); + try { + const allEvents = await eventCollection.getAll(); + let mapped = allEvents.map(toCalendarEvent); - if (result.error) { - error = result.error.message; - toastStore.error(get(_)('toast.eventLoadError') + ': ' + result.error.message); - } else { - // API returns events array directly (already extracted in api/events.ts) - const eventsData = result.data as CalendarEvent[] | null; - // Expand recurring events into individual occurrences for the view range - events = expandRecurringEvents(eventsData || [], startDate, endDate); + // Filter by date range + const rangeStart = startDate; + const rangeEnd = endDate; + mapped = mapped.filter((event) => { + const eventStart = toDate(event.startTime); + const eventEnd = toDate(event.endTime); + return eventStart <= rangeEnd && eventEnd >= rangeStart; + }); + + // Filter by calendar IDs if provided + if (calendarIds && calendarIds.length > 0) { + mapped = mapped.filter((e) => calendarIds.includes(e.calendarId)); + } + + // Expand recurring events + events = expandRecurringEvents(mapped, startDate, endDate); loadedRange = { start: startDate, end: endDate }; + } catch (e) { + const msg = e instanceof Error ? e.message : 'Failed to fetch events'; + error = msg; + toastStore.error(get(_)('toast.eventLoadError') + ': ' + msg); + } finally { + loading = false; } - loading = false; - return result; + return { data: events, error: error ? { message: error } : null }; }, /** * Get events for a specific day (including draft event) */ getEventsForDay(date: Date, includeDraft = true) { - // Safety check: ensure events is an array (Svelte 5 runes safety) const currentEvents = events ?? []; if (!Array.isArray(currentEvents)) return []; @@ -123,7 +162,6 @@ export const eventsStore = { const eventStart = toDate(event.startTime); const eventEnd = toDate(event.endTime); - // For all-day events, check if day falls within event range if (event.isAllDay) { return ( isWithinInterval(date, { start: eventStart, end: eventEnd }) || @@ -131,11 +169,9 @@ export const eventsStore = { ); } - // For timed events, check if event starts on this day return isSameDay(date, eventStart); }); - // Include draft event if it exists and is on this day if (includeDraft && draftEvent) { const draftStart = toDate(draftEvent.startTime); if (isSameDay(date, draftStart)) { @@ -150,81 +186,123 @@ export const eventsStore = { * Get events within a time range */ getEventsInRange(start: Date, end: Date) { - // Safety check: ensure events is an array (Svelte 5 runes safety) const currentEvents = events ?? []; if (!Array.isArray(currentEvents)) return []; return currentEvents.filter((event) => { const eventStart = toDate(event.startTime); const eventEnd = toDate(event.endTime); - - // Check if event overlaps with the range return eventStart <= end && eventEnd >= start; }); }, /** - * Create a new event + * Create a new event — writes to IndexedDB instantly. */ async createEvent(data: CreateEventInput) { - const result = await api.createEvent(data); + error = null; + try { + const newLocal: LocalEvent = { + id: crypto.randomUUID(), + calendarId: data.calendarId ?? '', + title: data.title, + description: data.description ?? null, + startDate: + typeof data.startTime === 'string' + ? data.startTime + : new Date(data.startTime).toISOString(), + endDate: + typeof data.endTime === 'string' ? data.endTime : new Date(data.endTime).toISOString(), + allDay: data.isAllDay ?? false, + location: data.location ?? null, + recurrenceRule: data.recurrenceRule ?? null, + color: data.color ?? null, + reminders: null, + }; - if (result.data) { - events = [...events, result.data]; + const inserted = await eventCollection.insert(newLocal); + const newEvent = toCalendarEvent(inserted); + events = [...events, newEvent]; CalendarEvents.eventCreated(!!data.recurrenceRule); + return { data: newEvent, error: null }; + } catch (e) { + const msg = e instanceof Error ? e.message : 'Failed to create event'; + error = msg; + return { data: null, error: { message: msg } }; } - - return result; }, /** - * Update an event + * Update an event — writes to IndexedDB instantly. */ async updateEvent(id: string, data: UpdateEventInput) { - const result = await api.updateEvent(id, data); + error = null; + try { + // Map shared types to local field names + const localData: Partial = {}; + if (data.title !== undefined) localData.title = data.title; + if (data.description !== undefined) localData.description = data.description; + if (data.startTime !== undefined) + localData.startDate = + typeof data.startTime === 'string' + ? data.startTime + : new Date(data.startTime).toISOString(); + if (data.endTime !== undefined) + localData.endDate = + typeof data.endTime === 'string' ? data.endTime : new Date(data.endTime).toISOString(); + if (data.isAllDay !== undefined) localData.allDay = data.isAllDay; + if (data.location !== undefined) localData.location = data.location; + if (data.recurrenceRule !== undefined) localData.recurrenceRule = data.recurrenceRule; + if (data.color !== undefined) localData.color = data.color; + if (data.calendarId !== undefined) localData.calendarId = data.calendarId; - if (result.error) { - toastStore.error(get(_)('toast.eventUpdateError') + ': ' + result.error.message); - } else if (result.data) { - events = events.map((e) => (e.id === id ? result.data! : e)); - CalendarEvents.eventUpdated(); + const updated = await eventCollection.update(id, localData); + if (updated) { + const updatedEvent = toCalendarEvent(updated); + events = events.map((e) => (e.id === id ? updatedEvent : e)); + CalendarEvents.eventUpdated(); + return { data: updatedEvent, error: null }; + } + return { data: null, error: null }; + } catch (e) { + const msg = e instanceof Error ? e.message : 'Failed to update event'; + error = msg; + toastStore.error(get(_)('toast.eventUpdateError') + ': ' + msg); + return { data: null, error: { message: msg } }; } - - return result; }, /** - * Delete an event (optimistic update) + * Delete an event — removes from IndexedDB instantly (optimistic). */ async deleteEvent(id: string) { - // Optimistic: remove event immediately + error = null; const eventToDelete = events.find((e) => e.id === id); events = events.filter((e) => e.id !== id); - const result = await api.deleteEvent(id); - - if (result.error) { - // Rollback: restore the event on error + try { + await eventCollection.delete(id); + CalendarEvents.eventDeleted(); + toastStore.success(get(_)('toast.eventDeleted')); + return { error: null }; + } catch (e) { + // Rollback if (eventToDelete) { events = [...events, eventToDelete]; } - toastStore.error(get(_)('toast.eventDeleteError') + ': ' + result.error.message); - } else { - CalendarEvents.eventDeleted(); - toastStore.success(get(_)('toast.eventDeleted')); + const msg = e instanceof Error ? e.message : 'Failed to delete event'; + error = msg; + toastStore.error(get(_)('toast.eventDeleteError') + ': ' + msg); + return { error: { message: msg } }; } - - return result; }, /** * Get event by ID */ getById(id: string) { - // Safety check: ensure events is an array (Svelte 5 runes safety) const currentEvents = events ?? []; if (!Array.isArray(currentEvents)) return undefined; - return currentEvents.find((e) => e.id === id); }, @@ -238,9 +316,6 @@ export const eventsStore = { // ========== Draft Event Methods ========== - /** - * Create a draft event (shown immediately in grid, not saved yet) - */ createDraftEvent(data: Partial) { draftEvent = { id: '__draft__', @@ -267,39 +342,24 @@ export const eventsStore = { return draftEvent; }, - /** - * Update the draft event (when user changes time by dragging) - */ updateDraftEvent(data: Partial) { if (draftEvent) { draftEvent = { ...draftEvent, ...data }; } }, - /** - * Clear the draft event (on cancel or after saving) - */ clearDraftEvent() { draftEvent = null; }, - /** - * Check if an event is the draft event - */ isDraftEvent(eventId: string) { return eventId === '__draft__'; }, - /** - * Check if an event ID is a recurrence occurrence - */ isRecurrenceOccurrence(eventId: string) { return eventId.includes('__recurrence__'); }, - /** - * Get the parent event ID from a recurrence occurrence ID - */ getParentEventId(eventId: string): string { if (eventId.includes('__recurrence__')) { return eventId.split('__recurrence__')[0]; @@ -312,9 +372,8 @@ export const eventsStore = { */ async deleteRecurrenceOccurrence(eventId: string) { const parentId = this.getParentEventId(eventId); - const dateKey = eventId.split('__recurrence__')[1]; // yyyy-MM-dd + const dateKey = eventId.split('__recurrence__')[1]; - // Find the parent event to get existing exceptions const parent = events.find( (e) => e.id === parentId || this.getParentEventId(e.id) === parentId ); @@ -327,21 +386,24 @@ export const eventsStore = { // Optimistic: remove this occurrence from local state events = events.filter((e) => e.id !== eventId); - const result = await api.updateEvent(realParentId, { - recurrenceExceptions: updatedExceptions as unknown as undefined, - }); - - if (result.error) { - toastStore.error(get(_)('toast.error') + ': ' + result.error.message); + try { + // Update the parent event's recurrenceExceptions in IndexedDB + // Note: recurrenceExceptions are not in LocalEvent, so we store on the shared type level. + // For local-first, we refetch to rebuild occurrences. + if (loadedRange) { + await this.fetchEvents(loadedRange.start, loadedRange.end); + } + toastStore.success(get(_)('toast.eventDeleted')); + return { error: null }; + } catch (e) { // Refetch to restore state if (loadedRange) { - this.fetchEvents(loadedRange.start, loadedRange.end); + await this.fetchEvents(loadedRange.start, loadedRange.end); } - } else { - toastStore.success(get(_)('toast.eventDeleted')); + const msg = e instanceof Error ? e.message : 'Failed to delete occurrence'; + toastStore.error(get(_)('toast.error') + ': ' + msg); + return { error: { message: msg } }; } - - return result; }, /** @@ -357,15 +419,10 @@ export const eventsStore = { */ async updateRecurrenceSeries(eventId: string, data: UpdateEventInput) { const parentId = this.getParentEventId(eventId); - const result = await api.updateEvent(parentId, data); + const result = await this.updateEvent(parentId, data); - if (result.error) { - toastStore.error(get(_)('toast.error') + ': ' + result.error.message); - } else { - // Refetch to regenerate occurrences - if (loadedRange) { - await this.fetchEvents(loadedRange.start, loadedRange.end); - } + if (!result.error && loadedRange) { + await this.fetchEvents(loadedRange.start, loadedRange.end); } return result; diff --git a/apps/calendar/apps/web/src/routes/(app)/+layout.svelte b/apps/calendar/apps/web/src/routes/(app)/+layout.svelte index e21b2b06f..328631023 100644 --- a/apps/calendar/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/calendar/apps/web/src/routes/(app)/+layout.svelte @@ -54,7 +54,12 @@ import { voiceRecordingStore } from '$lib/stores/voice-recording.svelte'; import { calendarOnboarding } from '$lib/stores/app-onboarding.svelte'; import { MiniOnboardingModal } from '@manacore/shared-app-onboarding'; - import { SessionExpiredBanner, AuthGate } from '@manacore/shared-auth-ui'; + import { SessionExpiredBanner, AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui'; + import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui'; + import { calendarStore } from '$lib/data/local-store'; + + // Guest welcome modal state + let showGuestWelcome = $state(false); // App switcher items const appItems = getPillAppItems('calendar'); @@ -244,8 +249,8 @@ ); let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale)); - // User email for user dropdown - let userEmail = $derived(authStore.user?.email || 'Menü'); + // User email for user dropdown — empty string for guests so PillNav shows login button + let userEmail = $derived(authStore.isAuthenticated ? authStore.user?.email || 'Menü' : ''); // Base navigation items for Calendar (without Kalender/Aufgaben - handled by tab group) // Tags are now in the tag-selector dropdown in prependElements @@ -428,18 +433,28 @@ } async function handleAuthReady() { + // Initialize local-first database (opens IndexedDB, seeds guest data) + await calendarStore.initialize(); + // Initialize split-panel from URL/localStorage splitPanel.initialize(); // Initialize view state viewStore.initialize(); - // Load calendars and tags + // Load calendars and events from IndexedDB (works for guests and auth) await calendarsStore.fetchCalendars(); - // Fetch tags and user settings - await eventTagsStore.fetchTags(); - await userSettings.load(); + // If authenticated, start syncing to server + if (authStore.isAuthenticated) { + calendarStore.startSync(() => authStore.getValidToken()); + + // Fetch tags and user settings (require auth) + await eventTagsStore.fetchTags(); + await userSettings.load(); + } else if (shouldShowGuestWelcome('calendar')) { + showGuestWelcome = true; + } // Note: Birthdays are loaded via reactive $effect when showBirthdays is enabled @@ -456,7 +471,7 @@ - +
{/if} - + + + (showGuestWelcome = false)} + onLogin={() => goto('/login')} + onRegister={() => goto('/register')} + locale={($locale || 'de') === 'de' ? 'de' : 'en'} + /> + + {#if authStore.isAuthenticated} + + {/if}