diff --git a/CLAUDE.md b/CLAUDE.md index d54fb9504..d81233d86 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -558,7 +558,7 @@ Logged in: App → IndexedDB → UI → SyncEngine → mana-sync (Go) → Postg ← WebSocket push ← ``` -### Migrated Apps (20/23) +### Migrated Apps (21/23) | App | Collections | Status | |-----|------------|--------| @@ -584,8 +584,9 @@ Logged in: App → IndexedDB → UI → SyncEngine → mana-sync (Go) → Postg | Taktik | clients, projects, timeEntries, tags, templates, settings | Done | | uLoad | links, tags, folders, linkTags | Done | | Calc | calculations, savedFormulas | Done | +| ManaCore | userSettings, dashboardConfigs | Done | -**Not migrated (no CRUD data model):** ManaCore (hub), Matrix (protocol client), Playground (stateless) +**Not migrated (no CRUD data model):** Matrix (protocol client), Playground (stateless) ### Dev Commands (Local-First Stack) diff --git a/apps/manacore/apps/web/package.json b/apps/manacore/apps/web/package.json index 121c8fd7a..1ffb3610b 100644 --- a/apps/manacore/apps/web/package.json +++ b/apps/manacore/apps/web/package.json @@ -43,6 +43,7 @@ }, "dependencies": { "@manacore/credits": "workspace:^", + "@manacore/local-store": "workspace:*", "@manacore/qr-export": "workspace:*", "@manacore/shared-auth": "workspace:*", "@manacore/shared-auth-stores": "workspace:*", diff --git a/apps/manacore/apps/web/src/lib/data/guest-seed.ts b/apps/manacore/apps/web/src/lib/data/guest-seed.ts new file mode 100644 index 000000000..e212a4b48 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/data/guest-seed.ts @@ -0,0 +1,58 @@ +/** + * Guest seed data for ManaCore. + * + * These records are loaded into IndexedDB when a new guest visits the app. + * They provide sensible defaults for settings and dashboard layout. + */ + +import type { LocalUserSettings, LocalDashboardConfig } from './local-store'; + +// ─── Default Settings ────────────────────────────────────── + +export const guestSettings: LocalUserSettings[] = [ + { + id: 'settings-global', + key: 'global', + theme: 'system', + themeVariant: 'default', + language: 'de', + pinnedThemes: [], + hiddenNavItems: {}, + }, +]; + +// ─── Default Dashboard ───────────────────────────────────── + +export const guestDashboardConfigs: LocalDashboardConfig[] = [ + { + id: 'dashboard-default', + widgets: [ + { + id: 'clock-timers-1', + type: 'clock-timers', + title: 'dashboard.widgets.clock.title', + size: 'small', + position: { x: 0, y: 0 }, + visible: true, + }, + { + id: 'tasks-today-1', + type: 'tasks-today', + title: 'dashboard.widgets.tasks_today.title', + size: 'small', + position: { x: 4, y: 0 }, + visible: true, + }, + { + id: 'calendar-events-1', + type: 'calendar-events', + title: 'dashboard.widgets.calendar.title', + size: 'small', + position: { x: 8, y: 0 }, + visible: true, + }, + ], + gridColumns: 12, + lastModified: new Date().toISOString(), + }, +]; diff --git a/apps/manacore/apps/web/src/lib/data/local-store.ts b/apps/manacore/apps/web/src/lib/data/local-store.ts new file mode 100644 index 000000000..1caace183 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/data/local-store.ts @@ -0,0 +1,59 @@ +/** + * ManaCore App — Local-First Data Layer + * + * Defines the IndexedDB database, collections, and guest seed data. + * Collections: userSettings, dashboardConfigs + * Tags use the shared tagLocalStore from @manacore/shared-stores. + */ + +import { createLocalStore, type BaseRecord } from '@manacore/local-store'; +import type { WidgetConfig } from '$lib/types/dashboard'; +import { guestSettings, guestDashboardConfigs } from './guest-seed.js'; + +// ─── Types ────────────────────────────────────────────────── + +export interface LocalUserSettings extends BaseRecord { + /** Settings scope: 'global' for cross-app, or an appId for per-app overrides. */ + key: string; + theme?: string; + themeVariant?: string; + language?: string; + pinnedThemes?: string[]; + hiddenNavItems?: Record; + /** Catch-all for future settings fields. */ + extra?: Record; +} + +export interface LocalDashboardConfig extends BaseRecord { + widgets: WidgetConfig[]; + gridColumns: number; + lastModified: string; +} + +// ─── Store ────────────────────────────────────────────────── + +const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050'; + +export const manacoreStore = createLocalStore({ + appId: 'manacore', + collections: [ + { + name: 'userSettings', + indexes: ['key'], + guestSeed: guestSettings, + }, + { + name: 'dashboardConfigs', + indexes: [], + guestSeed: guestDashboardConfigs, + }, + ], + sync: { + serverUrl: SYNC_SERVER_URL, + }, +}); + +// Typed collection accessors +export const settingsCollection = manacoreStore.collection('userSettings'); +export const dashboardCollection = + manacoreStore.collection('dashboardConfigs'); diff --git a/apps/manacore/apps/web/src/lib/data/queries.ts b/apps/manacore/apps/web/src/lib/data/queries.ts new file mode 100644 index 000000000..58a51a8a8 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/data/queries.ts @@ -0,0 +1,36 @@ +/** + * ManaCore — Reactive Live Queries + * + * Svelte 5 reactive queries for settings and dashboard data. + * Auto-update when IndexedDB changes (local writes, sync, other tabs). + */ + +import { useLiveQuery, useLiveQueryWithDefault } from '@manacore/local-store/svelte'; +import { + settingsCollection, + dashboardCollection, + type LocalUserSettings, + type LocalDashboardConfig, +} from './local-store.js'; + +/** Global user settings. Auto-updates on any change. */ +export function useGlobalSettings() { + return useLiveQuery(() => + settingsCollection.get('settings-global') + ); +} + +/** Dashboard configuration. Auto-updates on any change. */ +export function useDashboardConfig() { + return useLiveQuery(() => + dashboardCollection.get('dashboard-default') + ); +} + +/** All user settings (global + per-app overrides). */ +export function useAllSettings() { + return useLiveQueryWithDefault( + () => settingsCollection.getAll(undefined, { sortBy: 'key', sortDirection: 'asc' }), + [] as LocalUserSettings[] + ); +} diff --git a/apps/manacore/apps/web/src/lib/stores/dashboard.svelte.ts b/apps/manacore/apps/web/src/lib/stores/dashboard.svelte.ts index 90210ad72..716553bb4 100644 --- a/apps/manacore/apps/web/src/lib/stores/dashboard.svelte.ts +++ b/apps/manacore/apps/web/src/lib/stores/dashboard.svelte.ts @@ -1,19 +1,48 @@ /** - * Dashboard Store - Manages dashboard configuration using Svelte 5 runes + * Dashboard Store - Manages dashboard configuration using local-first IndexedDB * - * Handles widget layout, edit mode, and persistence to localStorage. + * Reads/writes to the dashboardConfigs collection in @manacore/local-store. + * Changes sync across devices via mana-sync. */ import { browser } from '$app/environment'; import type { DashboardConfig, WidgetConfig, WidgetSize, WidgetType } from '$lib/types/dashboard'; -import { DEFAULT_DASHBOARD_CONFIG, DASHBOARD_STORAGE_KEY } from '$lib/config/default-dashboard'; +import { DEFAULT_DASHBOARD_CONFIG } from '$lib/config/default-dashboard'; import { getWidgetMeta } from '$lib/types/dashboard'; +import { dashboardCollection, type LocalDashboardConfig } from '$lib/data/local-store'; // State let config = $state(structuredClone(DEFAULT_DASHBOARD_CONFIG)); let isEditing = $state(false); let initialized = $state(false); +/** Write current config to IndexedDB. */ +async function persist() { + if (!browser) return; + + config.lastModified = new Date().toISOString(); + + const record: Partial = { + widgets: config.widgets, + gridColumns: config.gridColumns, + lastModified: config.lastModified, + }; + + try { + const existing = await dashboardCollection.get('dashboard-default'); + if (existing) { + await dashboardCollection.update('dashboard-default', record); + } else { + await dashboardCollection.insert({ + id: 'dashboard-default', + ...record, + } as LocalDashboardConfig); + } + } catch (e) { + console.error('Failed to save dashboard config:', e); + } +} + /** * Dashboard store with Svelte 5 runes */ @@ -36,19 +65,19 @@ export const dashboardStore = { }, /** - * Initialize dashboard from localStorage + * Initialize dashboard from IndexedDB */ - initialize() { + async initialize() { if (!browser || initialized) return; try { - const stored = localStorage.getItem(DASHBOARD_STORAGE_KEY); - if (stored) { - const parsed = JSON.parse(stored) as DashboardConfig; - // Validate structure - if (parsed.widgets && Array.isArray(parsed.widgets)) { - config = parsed; - } + const stored = await dashboardCollection.get('dashboard-default'); + if (stored && stored.widgets && Array.isArray(stored.widgets)) { + config = { + widgets: stored.widgets, + gridColumns: stored.gridColumns || 12, + lastModified: stored.lastModified || new Date().toISOString(), + }; } } catch (e) { console.error('Failed to load dashboard config:', e); @@ -58,18 +87,9 @@ export const dashboardStore = { }, /** - * Persist current config to localStorage + * Persist current config to IndexedDB */ - persist() { - if (!browser) return; - - try { - config.lastModified = new Date().toISOString(); - localStorage.setItem(DASHBOARD_STORAGE_KEY, JSON.stringify(config)); - } catch (e) { - console.error('Failed to save dashboard config:', e); - } - }, + persist, /** * Enter edit mode @@ -83,7 +103,7 @@ export const dashboardStore = { */ stopEditing() { isEditing = false; - this.persist(); + persist(); }, /** @@ -121,7 +141,7 @@ export const dashboardStore = { const widget = config.widgets.find((w) => w.id === widgetId); if (widget) { widget.size = size; - this.persist(); + persist(); } }, @@ -132,7 +152,7 @@ export const dashboardStore = { const widget = config.widgets.find((w) => w.id === widgetId); if (widget) { widget.visible = !widget.visible; - this.persist(); + persist(); } }, @@ -149,7 +169,7 @@ export const dashboardStore = { if (existing) { // Just make it visible existing.visible = true; - this.persist(); + persist(); return; } } @@ -171,7 +191,7 @@ export const dashboardStore = { }; config.widgets = [...config.widgets, newWidget]; - this.persist(); + persist(); }, /** @@ -179,7 +199,7 @@ export const dashboardStore = { */ removeWidget(widgetId: string) { config.widgets = config.widgets.filter((w) => w.id !== widgetId); - this.persist(); + persist(); }, /** @@ -187,7 +207,7 @@ export const dashboardStore = { */ resetToDefault() { config = structuredClone(DEFAULT_DASHBOARD_CONFIG); - this.persist(); + persist(); }, /** diff --git a/apps/manacore/apps/web/src/lib/stores/tags.svelte.ts b/apps/manacore/apps/web/src/lib/stores/tags.svelte.ts index 0e08b03ef..c9a8198ee 100644 --- a/apps/manacore/apps/web/src/lib/stores/tags.svelte.ts +++ b/apps/manacore/apps/web/src/lib/stores/tags.svelte.ts @@ -1,20 +1,19 @@ /** - * Tag Store - Uses shared createTagStore backed by central mana-core-auth + * Tag Store - Re-exports shared local-first tag store + * + * Tags use the shared IndexedDB ('manacore-tags') across all apps. + * This module re-exports for backward compatibility with existing imports. */ -import { browser } from '$app/environment'; -import { createTagStore } from '@manacore/shared-stores'; -import { authStore } from '$lib/stores/auth.svelte'; -function getAuthUrl(): string { - if (browser && typeof window !== 'undefined') { - const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string }) - .__PUBLIC_MANA_CORE_AUTH_URL__; - return injectedUrl || 'http://localhost:3001'; - } - return 'http://localhost:3001'; -} - -export const tagStore = createTagStore({ - authUrl: getAuthUrl(), - getToken: () => authStore.getValidToken(), -}); +export { + tagLocalStore, + tagMutations, + tagCollection, + tagGroupCollection, + useAllTags, + useAllTagGroups, + getTagById, + getTagsByIds, + getTagColor, + getTagsByGroup, +} from '@manacore/shared-stores'; diff --git a/apps/manacore/apps/web/src/routes/(app)/+layout.svelte b/apps/manacore/apps/web/src/routes/(app)/+layout.svelte index fb7f96a42..30163a97f 100644 --- a/apps/manacore/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/manacore/apps/web/src/routes/(app)/+layout.svelte @@ -8,7 +8,9 @@ import { locale } from 'svelte-i18n'; import { PillNavigation, TagStrip } from '@manacore/shared-ui'; import type { PillNavItem, PillDropdownItem } from '@manacore/shared-ui'; - import { tagStore } from '$lib/stores/tags.svelte'; + import { tagLocalStore, tagMutations, useAllTags } from '$lib/stores/tags.svelte'; + import { manacoreStore } from '$lib/data/local-store'; + import { dashboardStore } from '$lib/stores/dashboard.svelte'; import { THEME_DEFINITIONS, DEFAULT_THEME_VARIANTS, @@ -84,6 +86,9 @@ // User email for user dropdown let userEmail = $derived(authStore.user?.email); + // Tags (local-first reactive query) + const allTags = useAllTags(); + // TagStrip visibility let isTagStripVisible = $state(false); function handleTagStripToggle() { @@ -162,6 +167,8 @@ } async function handleSignOut() { + manacoreStore.stopSync(); + tagMutations.stopSync(); await authStore.signOut(); goto('/login'); } @@ -195,6 +202,17 @@ return; } + // Initialize local-first databases (opens IndexedDB, seeds guest data) + await Promise.all([manacoreStore.initialize(), tagLocalStore.initialize()]); + + // Start syncing to server + const getToken = () => authStore.getValidToken(); + manacoreStore.startSync(getToken); + tagMutations.startSync(getToken); + + // Initialize dashboard from IndexedDB + await dashboardStore.initialize(); + // Initialize collapsed state from localStorage const savedCollapsed = localStorage.getItem(STORAGE_KEYS.NAV_COLLAPSED); if (savedCollapsed === 'true') { @@ -202,14 +220,8 @@ collapsedStore.set(true); } - // Load user settings from server (don't await - let it load in background) - // Silently catch errors since settings endpoint may not exist yet - userSettings.load().catch(() => { - // Settings API not available - use defaults - }); - - // Load tags - tagStore.fetchTags(); + // Load user settings from server (still needed for shared-theme sync) + userSettings.load().catch(() => {}); // Load onboarding state and show wizard if needed onboardingStore.load(); @@ -281,7 +293,7 @@ {#if isTagStripVisible} ({ + tags={(allTags.value ?? []).map((t) => ({ id: t.id, name: t.name, color: t.color || '#3b82f6', @@ -290,7 +302,7 @@ onToggle={() => {}} onClear={() => {}} managementHref="/tags" - loading={tagStore.loading} + loading={allTags.loading} /> {/if} diff --git a/apps/manacore/apps/web/src/routes/(app)/dashboard/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/dashboard/+page.svelte index fd2f6f0a6..b027f0a1b 100644 --- a/apps/manacore/apps/web/src/routes/(app)/dashboard/+page.svelte +++ b/apps/manacore/apps/web/src/routes/(app)/dashboard/+page.svelte @@ -9,8 +9,8 @@ import { ManaCoreEvents } from '@manacore/shared-utils/analytics'; import DashboardGrid from '$lib/components/dashboard/DashboardGrid.svelte'; - onMount(() => { - dashboardStore.initialize(); + onMount(async () => { + await dashboardStore.initialize(); }); function handleToggleEditing() { diff --git a/apps/manacore/apps/web/src/routes/(app)/tags/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/tags/+page.svelte index da2d6e611..ac50ad99b 100644 --- a/apps/manacore/apps/web/src/routes/(app)/tags/+page.svelte +++ b/apps/manacore/apps/web/src/routes/(app)/tags/+page.svelte @@ -1,12 +1,7 @@ @@ -15,19 +10,19 @@

Tags verwalten

-

+

Tags sind app-übergreifend — Änderungen gelten in allen ManaCore-Apps.

- {#if tagStore.loading} + {#if tags.loading}

Lädt...

- {:else if tagStore.tags.length === 0} + {:else if (tags.value ?? []).length === 0}

Keine Tags vorhanden.

{:else}
- {#each tagStore.tags as tag} -
- + {#each tags.value ?? [] as tag} +
+ {tag.name}
{/each}