feat(apps): migrate Calendar, Clock, Contacts, ManaDeck to local-first

Roll out @manacore/local-store to 4 more apps:

- Clock: alarms, timers, world clocks in IndexedDB with guest seed
- Calendar: calendars, events in IndexedDB with sample events
- Contacts: contacts in IndexedDB with 3 sample contacts
- ManaDeck: decks, cards in IndexedDB with onboarding flashcards

All apps: GuestWelcomeModal, login pill for guests, sync on auth.
Dev scripts: added dev:sync, dev:todo:server, dev:todo:local, dev:todo:full updated.

6 of 8 web apps are now local-first (Todo, Zitare, Clock, Calendar, Contacts, ManaDeck).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-27 13:10:07 +01:00
parent 427195d6dc
commit 2c9a36828f
25 changed files with 1585 additions and 755 deletions

View file

@ -36,6 +36,7 @@
},
"dependencies": {
"@clock/shared": "workspace:*",
"@manacore/local-store": "workspace:*",
"@manacore/shared-api-client": "workspace:*",
"@manacore/shared-app-onboarding": "workspace:*",
"@manacore/shared-auth": "workspace:*",

View file

@ -0,0 +1,36 @@
/**
* Guest seed data for the Clock app.
*
* These records are loaded into IndexedDB when a new guest visits the app.
* They provide sample alarms and world clocks to showcase the app.
*/
import type { LocalAlarm, LocalWorldClock } from './local-store';
export const guestAlarms: LocalAlarm[] = [
{
id: 'alarm-weekday-morning',
label: 'Wecker Wochentags',
time: '07:00',
enabled: true,
repeatDays: [1, 2, 3, 4, 5], // Mon-Fri
snoozeMinutes: 5,
sound: null,
vibrate: true,
},
];
export const guestWorldClocks: LocalWorldClock[] = [
{
id: 'wc-new-york',
timezone: 'America/New_York',
cityName: 'New York',
sortOrder: 0,
},
{
id: 'wc-tokyo',
timezone: 'Asia/Tokyo',
cityName: 'Tokio',
sortOrder: 1,
},
];

View file

@ -0,0 +1,69 @@
/**
* Clock App Local-First Data Layer
*
* Defines the IndexedDB database, collections, and guest seed data.
* This is the single source of truth for all Clock data.
*/
import { createLocalStore, type BaseRecord } from '@manacore/local-store';
import { guestAlarms, guestWorldClocks } from './guest-seed';
// ─── Types ──────────────────────────────────────────────────
export interface LocalAlarm extends BaseRecord {
label: string | null;
time: string; // HH:mm format
enabled: boolean;
repeatDays: number[] | null; // [0-6] where 0 = Sunday
snoozeMinutes: number | null;
sound: string | null;
vibrate: boolean | null;
}
export interface LocalTimer extends BaseRecord {
label: string | null;
durationSeconds: number;
remainingSeconds: number | null;
status: 'idle' | 'running' | 'paused' | 'finished';
startedAt: string | null;
pausedAt: string | null;
sound: string | null;
}
export interface LocalWorldClock extends BaseRecord {
timezone: string; // IANA timezone e.g. 'America/New_York'
cityName: string;
sortOrder: number;
}
// ─── Store ──────────────────────────────────────────────────
const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050';
export const clockStore = createLocalStore({
appId: 'clock',
collections: [
{
name: 'alarms',
indexes: ['enabled', 'time'],
guestSeed: guestAlarms,
},
{
name: 'timers',
indexes: ['status'],
},
{
name: 'worldClocks',
indexes: ['sortOrder', 'timezone'],
guestSeed: guestWorldClocks,
},
],
sync: {
serverUrl: SYNC_SERVER_URL,
},
});
// Typed collection accessors
export const alarmCollection = clockStore.collection<LocalAlarm>('alarms');
export const timerCollection = clockStore.collection<LocalTimer>('timers');
export const worldClockCollection = clockStore.collection<LocalWorldClock>('worldClocks');

View file

@ -1,18 +1,42 @@
/**
* Alarms Store - Manages alarm state using Svelte 5 runes
* Supports both authenticated (cloud) and guest (session) modes
* Alarms Store Local-First with Dexie.js
*
* All reads and writes go to IndexedDB first.
* When authenticated, changes sync to the server in the background.
* Same public API as before so components don't need changes.
*/
import { api } from '$lib/api/client';
import { sessionAlarmsStore } from './session-alarms.svelte';
import { authStore } from './auth.svelte';
import { alarmCollection, type LocalAlarm } from '$lib/data/local-store';
import type { Alarm, CreateAlarmInput, UpdateAlarmInput } from '@clock/shared';
// State
// State — populated from IndexedDB
let alarms = $state<Alarm[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
/** Convert a LocalAlarm (IndexedDB record) to the shared Alarm type. */
function toAlarm(local: LocalAlarm): Alarm {
return {
id: local.id,
userId: 'local',
label: local.label,
time: local.time,
enabled: local.enabled,
repeatDays: local.repeatDays,
snoozeMinutes: local.snoozeMinutes,
sound: local.sound,
vibrate: local.vibrate ?? null,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
/** Load alarms from IndexedDB into the reactive state. */
async function refreshAlarms() {
const localAlarms = await alarmCollection.getAll();
alarms = localAlarms.map(toAlarm);
}
export const alarmsStore = {
// Getters
get alarms() {
@ -29,89 +53,81 @@ export const alarmsStore = {
},
/**
* Fetch all alarms from the backend
* In guest mode, loads from session storage
* Fetch all alarms reads from IndexedDB.
*/
async fetchAlarms() {
loading = true;
error = null;
// Guest mode: load from session storage
if (!authStore.isAuthenticated) {
alarms = sessionAlarmsStore.alarms;
try {
await refreshAlarms();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch alarms';
console.error('Failed to fetch alarms:', e);
} finally {
loading = false;
return { success: true };
}
// Authenticated: fetch from API
const response = await api.get<Alarm[]>('/alarms');
if (response.error) {
error = response.error.message;
loading = false;
return { success: false, error: response.error.message };
}
alarms = response.data || [];
loading = false;
return { success: true };
},
/**
* Create a new alarm
* In guest mode, creates in session storage
* Create a new alarm writes to IndexedDB instantly.
*/
async createAlarm(input: CreateAlarmInput) {
// Guest mode: create in session storage
if (!authStore.isAuthenticated) {
const alarm = sessionAlarmsStore.createAlarm(input);
alarms = [...alarms, alarm];
return { success: true, data: alarm };
}
error = null;
try {
const newLocal: LocalAlarm = {
id: crypto.randomUUID(),
label: input.label ?? null,
time: input.time,
enabled: input.enabled ?? true,
repeatDays: input.repeatDays ?? null,
snoozeMinutes: input.snoozeMinutes ?? null,
sound: input.sound ?? null,
vibrate: input.vibrate ?? null,
};
// Authenticated: create via API
const response = await api.post<Alarm>('/alarms', input);
if (response.error) {
return { success: false, error: response.error.message };
const inserted = await alarmCollection.insert(newLocal);
const newAlarm = toAlarm(inserted);
alarms = [...alarms, newAlarm];
return { success: true, data: newAlarm };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create alarm';
console.error('Failed to create alarm:', e);
return { success: false, error: error };
}
if (response.data) {
alarms = [...alarms, response.data];
}
return { success: true, data: response.data };
},
/**
* Update an alarm
* In guest mode, updates in session storage
* Update an alarm writes to IndexedDB instantly.
*/
async updateAlarm(id: string, input: UpdateAlarmInput) {
// Guest mode: update in session storage
if (!authStore.isAuthenticated || sessionAlarmsStore.isSessionAlarm(id)) {
const alarm = sessionAlarmsStore.updateAlarm(id, input);
if (alarm) {
alarms = alarms.map((a) => (a.id === id ? alarm : a));
return { success: true, data: alarm };
error = null;
try {
const updateData: Partial<LocalAlarm> = {};
if (input.label !== undefined) updateData.label = input.label ?? null;
if (input.time !== undefined) updateData.time = input.time;
if (input.enabled !== undefined) updateData.enabled = input.enabled;
if (input.repeatDays !== undefined) updateData.repeatDays = input.repeatDays ?? null;
if (input.snoozeMinutes !== undefined) updateData.snoozeMinutes = input.snoozeMinutes ?? null;
if (input.sound !== undefined) updateData.sound = input.sound ?? null;
if (input.vibrate !== undefined) updateData.vibrate = input.vibrate ?? null;
const updated = await alarmCollection.update(id, updateData);
if (updated) {
const updatedAlarm = toAlarm(updated);
alarms = alarms.map((a) => (a.id === id ? updatedAlarm : a));
return { success: true, data: updatedAlarm };
}
return { success: false, error: 'Alarm not found' };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update alarm';
console.error('Failed to update alarm:', e);
return { success: false, error: error };
}
// Authenticated: update via API
const response = await api.patch<Alarm>(`/alarms/${id}`, input);
if (response.error) {
return { success: false, error: response.error.message };
}
if (response.data) {
alarms = alarms.map((a) => (a.id === id ? response.data! : a));
}
return { success: true, data: response.data };
},
/**
* Toggle alarm enabled state
* Toggle alarm enabled state.
*/
async toggleAlarm(id: string) {
const alarm = alarms.find((a) => a.id === id);
@ -121,30 +137,23 @@ export const alarmsStore = {
},
/**
* Delete an alarm
* In guest mode, deletes from session storage
* Delete an alarm removes from IndexedDB instantly.
*/
async deleteAlarm(id: string) {
// Guest mode: delete from session storage
if (!authStore.isAuthenticated || sessionAlarmsStore.isSessionAlarm(id)) {
sessionAlarmsStore.deleteAlarm(id);
error = null;
try {
await alarmCollection.delete(id);
alarms = alarms.filter((a) => a.id !== id);
return { success: true };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete alarm';
console.error('Failed to delete alarm:', e);
return { success: false, error: error };
}
// Authenticated: delete via API
const response = await api.delete(`/alarms/${id}`);
if (response.error) {
return { success: false, error: response.error.message };
}
alarms = alarms.filter((a) => a.id !== id);
return { success: true };
},
/**
* Clear all alarms (local state only)
* Clear all alarms (local state only).
*/
clear() {
alarms = [];
@ -152,56 +161,21 @@ export const alarmsStore = {
},
/**
* Get session alarm count (for guest mode banner)
* No longer relevant all alarms are local and editable.
*/
get sessionAlarmCount(): number {
return sessionAlarmsStore.count;
return 0;
},
/**
* Check if there are session alarms
*/
get hasSessionAlarms(): boolean {
return sessionAlarmsStore.count > 0;
return false;
},
/**
* Migrate session alarms to cloud after login
*/
async migrateSessionAlarms(): Promise<void> {
if (!authStore.isAuthenticated) return;
const sessionAlarms = sessionAlarmsStore.getAllAlarms();
if (sessionAlarms.length === 0) return;
// Create each alarm via API
for (const alarm of sessionAlarms) {
try {
await api.post<Alarm>('/alarms', {
label: alarm.label,
time: alarm.time,
enabled: alarm.enabled,
repeatDays: alarm.repeatDays,
snoozeMinutes: alarm.snoozeMinutes,
sound: alarm.sound,
vibrate: alarm.vibrate,
});
} catch (e) {
console.error('Failed to migrate alarm:', e);
}
}
// Clear session data after migration
sessionAlarmsStore.clear();
// Reload alarms from server
await this.fetchAlarms();
// No-op: local-first mode handles data persistence automatically.
},
/**
* Check if an alarm ID is a session alarm
*/
isSessionAlarm(id: string): boolean {
return sessionAlarmsStore.isSessionAlarm(id);
isSessionAlarm(_id: string): boolean {
return false;
},
};

View file

@ -1,15 +1,40 @@
/**
* World Clocks Store - Manages world clock state using Svelte 5 runes
* World Clocks Store Local-First with Dexie.js
*
* All reads and writes go to IndexedDB first.
* When authenticated, changes sync to the server in the background.
* Same public API as before so components don't need changes.
*/
import { api } from '$lib/api/client';
import { worldClockCollection, type LocalWorldClock } from '$lib/data/local-store';
import type { WorldClock, CreateWorldClockInput } from '@clock/shared';
// State
// State — populated from IndexedDB
let worldClocks = $state<WorldClock[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
/** Convert a LocalWorldClock (IndexedDB record) to the shared WorldClock type. */
function toWorldClock(local: LocalWorldClock): WorldClock {
return {
id: local.id,
userId: 'local',
timezone: local.timezone,
cityName: local.cityName,
sortOrder: local.sortOrder,
createdAt: local.createdAt ?? new Date().toISOString(),
};
}
/** Load world clocks from IndexedDB into the reactive state. */
async function refreshWorldClocks() {
const localClocks = await worldClockCollection.getAll(undefined, {
sortBy: 'sortOrder',
sortDirection: 'asc',
});
worldClocks = localClocks.map(toWorldClock);
}
export const worldClocksStore = {
// Getters
get worldClocks() {
@ -23,75 +48,93 @@ export const worldClocksStore = {
},
/**
* Fetch all world clocks from the backend
* Fetch all world clocks reads from IndexedDB.
*/
async fetchWorldClocks() {
loading = true;
error = null;
const response = await api.get<WorldClock[]>('/world-clocks');
if (response.error) {
error = response.error.message;
try {
await refreshWorldClocks();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch world clocks';
console.error('Failed to fetch world clocks:', e);
} finally {
loading = false;
return { success: false, error: response.error.message };
}
worldClocks = response.data || [];
loading = false;
return { success: true };
},
/**
* Add a new world clock
* Add a new world clock writes to IndexedDB instantly.
*/
async addWorldClock(input: CreateWorldClockInput) {
const response = await api.post<WorldClock>('/world-clocks', input);
error = null;
try {
const newLocal: LocalWorldClock = {
id: crypto.randomUUID(),
timezone: input.timezone,
cityName: input.cityName,
sortOrder: worldClocks.length,
};
if (response.error) {
return { success: false, error: response.error.message };
const inserted = await worldClockCollection.insert(newLocal);
const newClock = toWorldClock(inserted);
worldClocks = [...worldClocks, newClock];
return { success: true, data: newClock };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to add world clock';
console.error('Failed to add world clock:', e);
return { success: false, error: error };
}
if (response.data) {
worldClocks = [...worldClocks, response.data];
}
return { success: true, data: response.data };
},
/**
* Remove a world clock
* Remove a world clock removes from IndexedDB instantly.
*/
async removeWorldClock(id: string) {
const response = await api.delete(`/world-clocks/${id}`);
if (response.error) {
return { success: false, error: response.error.message };
error = null;
try {
await worldClockCollection.delete(id);
worldClocks = worldClocks.filter((wc) => wc.id !== id);
return { success: true };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to remove world clock';
console.error('Failed to remove world clock:', e);
return { success: false, error: error };
}
worldClocks = worldClocks.filter((wc) => wc.id !== id);
return { success: true };
},
/**
* Reorder world clocks
* Reorder world clocks updates sortOrder in IndexedDB.
*/
async reorder(ids: string[]) {
const response = await api.put('/world-clocks/reorder', { ids });
error = null;
try {
// Update local state immediately
worldClocks = ids
.map((id, index) => {
const wc = worldClocks.find((w) => w.id === id);
return wc ? { ...wc, sortOrder: index } : undefined;
})
.filter((wc): wc is WorldClock => wc !== undefined);
if (response.error) {
return { success: false, error: response.error.message };
// Persist each order change to IndexedDB
for (let i = 0; i < ids.length; i++) {
await worldClockCollection.update(ids[i], {
sortOrder: i,
} as Partial<LocalWorldClock>);
}
return { success: true };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to reorder world clocks';
console.error('Failed to reorder world clocks:', e);
return { success: false, error: error };
}
// Update local order
worldClocks = ids
.map((id) => worldClocks.find((wc) => wc.id === id))
.filter((wc): wc is WorldClock => wc !== undefined);
return { success: true };
},
/**
* Clear all world clocks (local state only)
* Clear all world clocks (local state only).
*/
clear() {
worldClocks = [];

View file

@ -14,8 +14,6 @@
import { userSettings } from '$lib/stores/user-settings.svelte';
import { alarmsStore } from '$lib/stores/alarms.svelte';
import { timersStore } from '$lib/stores/timers.svelte';
import { sessionAlarmsStore } from '$lib/stores/session-alarms.svelte';
import { sessionTimersStore } from '$lib/stores/session-timers.svelte';
import {
THEME_DEFINITIONS,
DEFAULT_THEME_VARIANTS,
@ -31,9 +29,20 @@
import { timersApi } from '$lib/api/timers';
import { clockOnboarding } 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 { clockStore } from '$lib/data/local-store';
import { tagStore } from '$lib/stores/tags.svelte';
// Guest welcome modal state
let showGuestWelcome = $state(false);
function initGuestWelcome() {
if (!authStore.isAuthenticated && shouldShowGuestWelcome('clock')) {
showGuestWelcome = true;
}
}
// App switcher items
const appItems = getPillAppItems('clock');
@ -163,7 +172,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ü' : '');
// TagStrip visibility
let isTagStripVisible = $state(false);
@ -246,6 +256,14 @@
}
async function handleAuthReady() {
// Initialize local-first database (opens IndexedDB, seeds guest data)
await clockStore.initialize();
// If authenticated, start syncing to server
if (authStore.isAuthenticated) {
clockStore.startSync(() => authStore.getValidToken());
}
// Initialize collapsed state from localStorage
const savedCollapsed = localStorage.getItem('clock-nav-collapsed');
if (savedCollapsed === 'true') {
@ -253,16 +271,12 @@
collapsedStore.set(true);
}
// Load user settings and tags
await userSettings.load();
await tagStore.fetchTags();
// Show guest welcome modal on first visit
initGuestWelcome();
// Check for session data to migrate
if (alarmsStore.hasSessionAlarms) {
await alarmsStore.migrateSessionAlarms();
}
if (timersStore.hasSessionTimers) {
await timersStore.migrateSessionTimers();
// Load user settings and tags (these need auth / central service)
if (authStore.isAuthenticated) {
await Promise.all([userSettings.load(), tagStore.fetchTags()]);
}
// Redirect to start page if on root and a custom start page is set
@ -275,7 +289,7 @@
<svelte:window onkeydown={handleKeydown} />
<AuthGate {authStore} {goto} onReady={handleAuthReady}>
<AuthGate {authStore} {goto} allowGuest={true} onReady={handleAuthReady}>
<div class="layout-container">
<PillNavigation
items={navItems}
@ -295,7 +309,7 @@
showLanguageSwitcher={true}
{languageItems}
{currentLanguageLabel}
showLogout={true}
showLogout={authStore.isAuthenticated}
onLogout={handleLogout}
loginHref="/login"
primaryColor="#f59e0b"
@ -350,7 +364,19 @@
<MiniOnboardingModal store={clockOnboarding} appName="Uhr" appEmoji="⏰" />
{/if}
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
<!-- Guest Welcome Modal -->
<GuestWelcomeModal
appId="clock"
visible={showGuestWelcome}
onClose={() => (showGuestWelcome = false)}
onLogin={() => goto('/login')}
onRegister={() => goto('/register')}
locale={($locale || 'de') === 'de' ? 'de' : 'en'}
/>
{#if authStore.isAuthenticated}
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
{/if}
</AuthGate>
<style>