feat(manacore): migrate settings, dashboard, and tags to local-first

Move ManaCore from server-only data fetching to local-first architecture
using @manacore/local-store (IndexedDB + mana-sync). Dashboard config
now syncs across devices instead of being localStorage-only, and tags
use the shared local-first tag store consistent with all other apps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-29 19:36:08 +02:00
parent 4d390be5af
commit 62e1353503
10 changed files with 256 additions and 75 deletions

View file

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

View file

@ -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:*",

View file

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

View file

@ -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<string, boolean>;
/** Catch-all for future settings fields. */
extra?: Record<string, unknown>;
}
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<LocalUserSettings>('userSettings');
export const dashboardCollection =
manacoreStore.collection<LocalDashboardConfig>('dashboardConfigs');

View file

@ -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<LocalUserSettings | undefined>(() =>
settingsCollection.get('settings-global')
);
}
/** Dashboard configuration. Auto-updates on any change. */
export function useDashboardConfig() {
return useLiveQuery<LocalDashboardConfig | undefined>(() =>
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[]
);
}

View file

@ -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<DashboardConfig>(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<LocalDashboardConfig> = {
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();
},
/**

View file

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

View file

@ -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 @@
<!-- TagStrip (above PillNav, toggled via Tags pill) -->
{#if isTagStripVisible}
<TagStrip
tags={tagStore.tags.map((t) => ({
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}

View file

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

View file

@ -1,12 +1,7 @@
<script lang="ts">
import { tagStore } from '$lib/stores/tags.svelte';
import { onMount } from 'svelte';
import { useAllTags } from '$lib/stores/tags.svelte';
onMount(() => {
if (tagStore.tags.length === 0) {
tagStore.fetchTags();
}
});
const tags = useAllTags();
</script>
<svelte:head>
@ -15,19 +10,19 @@
<div class="tags-page">
<h1>Tags verwalten</h1>
<p class="text-sm text-muted-foreground mb-4">
<p class="mb-4 text-sm text-muted-foreground">
Tags sind app-übergreifend — Änderungen gelten in allen ManaCore-Apps.
</p>
{#if tagStore.loading}
{#if tags.loading}
<p>Lädt...</p>
{:else if tagStore.tags.length === 0}
{:else if (tags.value ?? []).length === 0}
<p>Keine Tags vorhanden.</p>
{:else}
<div class="grid gap-2">
{#each tagStore.tags as tag}
<div class="flex items-center gap-2 p-2 rounded-lg bg-card">
<span class="w-3 h-3 rounded-full" style="background-color: {tag.color}"></span>
{#each tags.value ?? [] as tag}
<div class="flex items-center gap-2 rounded-lg bg-card p-2">
<span class="h-3 w-3 rounded-full" style="background-color: {tag.color}"></span>
<span>{tag.name}</span>
</div>
{/each}