feat(local-first): migrate 9 apps to reactive useLiveQuery reads

Replace manual $state + fetchX() pattern with Dexie liveQuery hooks
across 9 apps. All data reads now auto-update on IndexedDB changes
(local writes, sync, other tabs). Stores reduced to mutation-only.

Apps migrated:
- Zitare: favorites, lists
- Contacts: contacts
- Calendar: calendars, events
- Chat: conversations, templates
- Clock: alarms, timers, worldClocks
- ManaDeck: decks, cards
- Presi: decks, slides
- Context: spaces, documents
- Storage: files, folders

Pattern per app:
1. New queries.ts with useLiveQuery hooks + pure filter helpers
2. Stores slimmed to mutation-only (no $state arrays, no fetch methods)
3. Layout sets context via setContext() for child components
4. Components use getContext() for reactive reads

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-28 02:27:46 +01:00
parent ced7dd7441
commit 30e124e609
87 changed files with 2528 additions and 3136 deletions

View file

@ -0,0 +1,106 @@
/**
* Reactive Queries & Pure Helpers for Clock
*
* Uses Dexie liveQuery to automatically re-render when IndexedDB changes
* (local writes, sync updates, other tabs). Components call these hooks
* at init time; no manual fetch/refresh needed.
*/
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
import {
alarmCollection,
timerCollection,
worldClockCollection,
type LocalAlarm,
type LocalTimer,
type LocalWorldClock,
} from './local-store';
import type { Alarm, Timer, WorldClock } from '@clock/shared';
// ─── Type Converters ───────────────────────────────────────
export 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(),
};
}
export function toTimer(local: LocalTimer): Timer {
return {
id: local.id,
userId: 'local',
label: local.label,
durationSeconds: local.durationSeconds,
remainingSeconds: local.remainingSeconds,
status: local.status,
startedAt: local.startedAt,
pausedAt: local.pausedAt,
sound: local.sound,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
export 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(),
};
}
// ─── Live Query Hooks (call during component init) ─────────
/** All alarms, auto-updates on any change. */
export function useAllAlarms() {
return useLiveQueryWithDefault(async () => {
const locals = await alarmCollection.getAll();
return locals.map(toAlarm);
}, [] as Alarm[]);
}
/** All timers, auto-updates on any change. */
export function useAllTimers() {
return useLiveQueryWithDefault(async () => {
const locals = await timerCollection.getAll();
return locals.map(toTimer);
}, [] as Timer[]);
}
/** All world clocks, sorted by sortOrder. Auto-updates on any change. */
export function useAllWorldClocks() {
return useLiveQueryWithDefault(async () => {
const locals = await worldClockCollection.getAll(undefined, {
sortBy: 'sortOrder',
sortDirection: 'asc',
});
return locals.map(toWorldClock);
}, [] as WorldClock[]);
}
// ─── Pure Filter Functions (for $derived) ──────────────────
export function filterEnabledAlarms(alarms: Alarm[]): Alarm[] {
return alarms.filter((a) => a.enabled);
}
export function filterActiveTimers(timers: Timer[]): Timer[] {
return timers.filter((t) => t.status === 'running' || t.status === 'paused');
}
export function sortWorldClocksByOrder(clocks: WorldClock[]): WorldClock[] {
return [...clocks].sort((a, b) => a.sortOrder - b.sortOrder);
}

View file

@ -1,76 +1,24 @@
/**
* Alarms Store Local-First with Dexie.js
* Alarms Store Mutation-Only Service
*
* 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.
* All reads are handled by useLiveQuery() hooks in queries.ts.
* This store only provides write operations (create, update, delete, toggle).
* IndexedDB writes automatically trigger UI updates via Dexie liveQuery.
*/
import { alarmCollection, type LocalAlarm } from '$lib/data/local-store';
import type { Alarm, CreateAlarmInput, UpdateAlarmInput } from '@clock/shared';
import { toAlarm } from '$lib/data/queries';
import type { CreateAlarmInput, UpdateAlarmInput, Alarm } from '@clock/shared';
// 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() {
return alarms;
},
get loading() {
return loading;
},
get error() {
return error;
},
get enabledAlarms() {
return alarms.filter((a) => a.enabled);
},
/**
* Fetch all alarms reads from IndexedDB.
*/
async fetchAlarms() {
loading = true;
error = null;
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 };
},
/**
* Create a new alarm writes to IndexedDB instantly.
* Create a new alarm -- writes to IndexedDB instantly.
*/
async createAlarm(input: CreateAlarmInput) {
error = null;
@ -87,9 +35,7 @@ export const alarmsStore = {
};
const inserted = await alarmCollection.insert(newLocal);
const newAlarm = toAlarm(inserted);
alarms = [...alarms, newAlarm];
return { success: true, data: newAlarm };
return { success: true, data: toAlarm(inserted) };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create alarm';
console.error('Failed to create alarm:', e);
@ -98,7 +44,7 @@ export const alarmsStore = {
},
/**
* Update an alarm writes to IndexedDB instantly.
* Update an alarm -- writes to IndexedDB instantly.
*/
async updateAlarm(id: string, input: UpdateAlarmInput) {
error = null;
@ -114,9 +60,7 @@ export const alarmsStore = {
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: true, data: toAlarm(updated) };
}
return { success: false, error: 'Alarm not found' };
} catch (e) {
@ -129,21 +73,20 @@ export const alarmsStore = {
/**
* Toggle alarm enabled state.
*/
async toggleAlarm(id: string) {
const alarm = alarms.find((a) => a.id === id);
async toggleAlarm(id: string, currentAlarms: Alarm[]) {
const alarm = currentAlarms.find((a) => a.id === id);
if (!alarm) return { success: false, error: 'Alarm not found' };
return this.updateAlarm(id, { enabled: !alarm.enabled });
},
/**
* Delete an alarm removes from IndexedDB instantly.
* Delete an alarm -- removes from IndexedDB instantly.
*/
async deleteAlarm(id: string) {
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';
@ -151,31 +94,4 @@ export const alarmsStore = {
return { success: false, error: error };
}
},
/**
* Clear all alarms (local state only).
*/
clear() {
alarms = [];
error = null;
},
/**
* No longer relevant all alarms are local and editable.
*/
get sessionAlarmCount(): number {
return 0;
},
get hasSessionAlarms(): boolean {
return false;
},
async migrateSessionAlarms(): Promise<void> {
// No-op: local-first mode handles data persistence automatically.
},
isSessionAlarm(_id: string): boolean {
return false;
},
};

View file

@ -1,77 +1,25 @@
/**
* Timers Store Local-First with Dexie.js
* Timers Store Mutation-Only Service
*
* 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.
* All reads are handled by useLiveQuery() hooks in queries.ts.
* This store only provides write operations (create, update, delete, start, pause, reset).
* IndexedDB writes automatically trigger UI updates via Dexie liveQuery.
*/
import { timerCollection, type LocalTimer } from '$lib/data/local-store';
import type { Timer, CreateTimerInput, UpdateTimerInput } from '@clock/shared';
import { toTimer } from '$lib/data/queries';
import type { CreateTimerInput, UpdateTimerInput } from '@clock/shared';
import { ClockEvents } from '@manacore/shared-utils/analytics';
// State — populated from IndexedDB
let timers = $state<Timer[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
/** Convert a LocalTimer (IndexedDB record) to the shared Timer type. */
function toTimer(local: LocalTimer): Timer {
return {
id: local.id,
userId: 'local',
label: local.label,
durationSeconds: local.durationSeconds,
remainingSeconds: local.remainingSeconds,
status: local.status,
startedAt: local.startedAt,
pausedAt: local.pausedAt,
sound: local.sound,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
/** Load timers from IndexedDB into the reactive state. */
async function refreshTimers() {
const localTimers = await timerCollection.getAll();
timers = localTimers.map(toTimer);
}
export const timersStore = {
// Getters
get timers() {
return timers;
},
get loading() {
return loading;
},
get error() {
return error;
},
get activeTimers() {
return timers.filter((t) => t.status === 'running' || t.status === 'paused');
},
/**
* Fetch all timers reads from IndexedDB.
*/
async fetchTimers() {
loading = true;
error = null;
try {
await refreshTimers();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch timers';
console.error('Failed to fetch timers:', e);
} finally {
loading = false;
}
return { success: true };
},
/**
* Create a new timer writes to IndexedDB instantly.
* Create a new timer -- writes to IndexedDB instantly.
*/
async createTimer(input: CreateTimerInput) {
error = null;
@ -88,9 +36,7 @@ export const timersStore = {
};
const inserted = await timerCollection.insert(newLocal);
const newTimer = toTimer(inserted);
timers = [...timers, newTimer];
return { success: true, data: newTimer };
return { success: true, data: toTimer(inserted) };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create timer';
console.error('Failed to create timer:', e);
@ -99,7 +45,7 @@ export const timersStore = {
},
/**
* Update a timer writes to IndexedDB instantly.
* Update a timer -- writes to IndexedDB instantly.
*/
async updateTimer(id: string, input: UpdateTimerInput) {
error = null;
@ -111,9 +57,7 @@ export const timersStore = {
const updated = await timerCollection.update(id, updateData);
if (updated) {
const updatedTimer = toTimer(updated);
timers = timers.map((t) => (t.id === id ? updatedTimer : t));
return { success: true, data: updatedTimer };
return { success: true, data: toTimer(updated) };
}
return { success: false, error: 'Timer not found' };
} catch (e) {
@ -124,7 +68,7 @@ export const timersStore = {
},
/**
* Start a timer sets status to running with current timestamp.
* Start a timer -- sets status to running with current timestamp.
*/
async startTimer(id: string) {
error = null;
@ -146,9 +90,8 @@ export const timersStore = {
const updated = await timerCollection.update(id, updateData);
if (updated) {
const updatedTimer = toTimer(updated);
timers = timers.map((t) => (t.id === id ? updatedTimer : t));
ClockEvents.timerStarted(
(updatedTimer as Timer & { type?: string }).type as 'pomodoro' | 'stopwatch' | 'countdown'
(updatedTimer as any).type as 'pomodoro' | 'stopwatch' | 'countdown'
);
return { success: true, data: updatedTimer };
}
@ -161,7 +104,7 @@ export const timersStore = {
},
/**
* Pause a timer calculates remaining seconds and saves.
* Pause a timer -- calculates remaining seconds and saves.
*/
async pauseTimer(id: string) {
error = null;
@ -185,9 +128,7 @@ export const timersStore = {
const updated = await timerCollection.update(id, updateData);
if (updated) {
const updatedTimer = toTimer(updated);
timers = timers.map((t) => (t.id === id ? updatedTimer : t));
return { success: true, data: updatedTimer };
return { success: true, data: toTimer(updated) };
}
return { success: false, error: 'Timer not found' };
} catch (e) {
@ -198,7 +139,7 @@ export const timersStore = {
},
/**
* Reset a timer back to idle with full duration.
* Reset a timer -- back to idle with full duration.
*/
async resetTimer(id: string) {
error = null;
@ -212,9 +153,7 @@ export const timersStore = {
const updated = await timerCollection.update(id, updateData);
if (updated) {
const updatedTimer = toTimer(updated);
timers = timers.map((t) => (t.id === id ? updatedTimer : t));
return { success: true, data: updatedTimer };
return { success: true, data: toTimer(updated) };
}
return { success: false, error: 'Timer not found' };
} catch (e) {
@ -225,13 +164,12 @@ export const timersStore = {
},
/**
* Delete a timer removes from IndexedDB instantly.
* Delete a timer -- removes from IndexedDB instantly.
*/
async deleteTimer(id: string) {
error = null;
try {
await timerCollection.delete(id);
timers = timers.filter((t) => t.id !== id);
return { success: true };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete timer';
@ -241,36 +179,13 @@ export const timersStore = {
},
/**
* Update local timer state (for countdown display).
* Update remaining seconds in IndexedDB (for countdown display).
*/
updateLocalState(id: string, updates: Partial<Timer>) {
timers = timers.map((t) => (t.id === id ? { ...t, ...updates } : t));
},
/**
* Clear all timers (local state only).
*/
clear() {
timers = [];
error = null;
},
/**
* No longer relevant all timers are local and editable.
*/
get sessionTimerCount(): number {
return 0;
},
get hasSessionTimers(): boolean {
return false;
},
async migrateSessionTimers(): Promise<void> {
// No-op: local-first mode handles data persistence automatically.
},
isSessionTimer(_id: string): boolean {
return false;
async updateLocalTimer(id: string, remainingSeconds: number) {
try {
await timerCollection.update(id, { remainingSeconds });
} catch (e) {
console.error('Failed to update local timer:', e);
}
},
};

View file

@ -1,86 +1,36 @@
/**
* World Clocks Store Local-First with Dexie.js
* World Clocks Store Mutation-Only Service
*
* 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.
* All reads are handled by useLiveQuery() hooks in queries.ts.
* This store only provides write operations (add, remove, reorder).
* IndexedDB writes automatically trigger UI updates via Dexie liveQuery.
*/
import { worldClockCollection, type LocalWorldClock } from '$lib/data/local-store';
import type { WorldClock, CreateWorldClockInput } from '@clock/shared';
import type { CreateWorldClockInput, WorldClock } from '@clock/shared';
// 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() {
return worldClocks;
},
get loading() {
return loading;
},
get error() {
return error;
},
/**
* Fetch all world clocks reads from IndexedDB.
* Add a new world clock -- writes to IndexedDB instantly.
*/
async fetchWorldClocks() {
loading = true;
error = null;
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: true };
},
/**
* Add a new world clock writes to IndexedDB instantly.
*/
async addWorldClock(input: CreateWorldClockInput) {
async addWorldClock(input: CreateWorldClockInput, currentCount: number = 0) {
error = null;
try {
const newLocal: LocalWorldClock = {
id: crypto.randomUUID(),
timezone: input.timezone,
cityName: input.cityName,
sortOrder: worldClocks.length,
sortOrder: currentCount,
};
const inserted = await worldClockCollection.insert(newLocal);
const newClock = toWorldClock(inserted);
worldClocks = [...worldClocks, newClock];
return { success: true, data: newClock };
await worldClockCollection.insert(newLocal);
return { success: true };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to add world clock';
console.error('Failed to add world clock:', e);
@ -89,13 +39,12 @@ export const worldClocksStore = {
},
/**
* Remove a world clock removes from IndexedDB instantly.
* Remove a world clock -- removes from IndexedDB instantly.
*/
async removeWorldClock(id: string) {
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';
@ -105,26 +54,16 @@ export const worldClocksStore = {
},
/**
* Reorder world clocks updates sortOrder in IndexedDB.
* Reorder world clocks -- updates sortOrder in IndexedDB.
*/
async reorder(ids: string[]) {
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);
// 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';
@ -132,12 +71,4 @@ export const worldClocksStore = {
return { success: false, error: error };
}
},
/**
* Clear all world clocks (local state only).
*/
clear() {
worldClocks = [];
error = null;
},
};

View file

@ -13,8 +13,7 @@
import { theme } from '$lib/stores/theme.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { userSettings } from '$lib/stores/user-settings.svelte';
import { alarmsStore } from '$lib/stores/alarms.svelte';
import { timersStore } from '$lib/stores/timers.svelte';
import { useAllAlarms, useAllTimers, useAllWorldClocks } from '$lib/data/queries';
import {
THEME_DEFINITIONS,
DEFAULT_THEME_VARIANTS,
@ -26,7 +25,6 @@
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
import { getPillAppItems } from '@manacore/shared-branding';
import { setLocale, supportedLocales } from '$lib/i18n';
import { alarmCollection, timerCollection } from '$lib/data/local-store';
import { clockOnboarding } from '$lib/stores/app-onboarding.svelte';
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
import { SessionExpiredBanner, AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
@ -38,8 +36,16 @@
useAllTags as useAllSharedTags,
} from '@manacore/shared-stores';
// Shared tag store (local-first)
// Live queries — auto-update when IndexedDB changes (local writes, sync, other tabs)
const allAlarms = useAllAlarms();
const allTimers = useAllTimers();
const allWorldClocks = useAllWorldClocks();
const allTags = useAllSharedTags();
// Provide data to child components via Svelte context
setContext('alarms', allAlarms);
setContext('timers', allTimers);
setContext('worldClocks', allWorldClocks);
setContext('tags', allTags);
// Guest welcome modal state
@ -81,44 +87,38 @@
{ id: 'settings', label: 'Einstellungen', icon: 'settings', href: '/settings' },
];
// CommandBar search - search alarms and timers
// CommandBar search - search alarms and timers using live query data
async function handleCommandBarSearch(query: string): Promise<CommandBarItem[]> {
if (!query.trim()) return [];
const queryLower = query.toLowerCase();
const results: CommandBarItem[] = [];
try {
// Search alarms (local-first — reads from IndexedDB)
const alarms = await alarmCollection.getAll();
const matchingAlarms = alarms
.filter((alarm) => alarm.label?.toLowerCase().includes(queryLower))
.slice(0, 5)
.map((alarm) => ({
id: `alarm-${alarm.id}`,
title: alarm.label || 'Wecker',
subtitle: `⏰ ${alarm.time} ${alarm.enabled ? '(aktiv)' : '(inaktiv)'}`,
}));
results.push(...matchingAlarms);
// Search alarms (from live query)
const matchingAlarms = allAlarms.value
.filter((alarm) => alarm.label?.toLowerCase().includes(queryLower))
.slice(0, 5)
.map((alarm) => ({
id: `alarm-${alarm.id}`,
title: alarm.label || 'Wecker',
subtitle: `${alarm.time.slice(0, 5)} ${alarm.enabled ? '(aktiv)' : '(inaktiv)'}`,
}));
results.push(...matchingAlarms);
// Search timers (local-first — reads from IndexedDB)
const timers = await timerCollection.getAll();
const matchingTimers = timers
.filter((timer) => timer.label?.toLowerCase().includes(queryLower))
.slice(0, 5)
.map((timer) => {
const mins = Math.floor(timer.durationSeconds / 60);
const secs = timer.durationSeconds % 60;
return {
id: `timer-${timer.id}`,
title: timer.label || 'Timer',
subtitle: `⏱️ ${mins}:${secs.toString().padStart(2, '0')} ${timer.status === 'running' ? '(läuft)' : ''}`,
};
});
results.push(...matchingTimers);
} catch {
// Ignore errors
}
// Search timers (from live query)
const matchingTimers = allTimers.value
.filter((timer) => timer.label?.toLowerCase().includes(queryLower))
.slice(0, 5)
.map((timer) => {
const mins = Math.floor(timer.durationSeconds / 60);
const secs = timer.durationSeconds % 60;
return {
id: `timer-${timer.id}`,
title: timer.label || 'Timer',
subtitle: `${mins}:${secs.toString().padStart(2, '0')} ${timer.status === 'running' ? '(läuft)' : ''}`,
};
});
results.push(...matchingTimers);
return results.slice(0, 10);
}

View file

@ -1,12 +1,13 @@
<script lang="ts">
import { onMount } from 'svelte';
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import { PageHeader, toast } from '@manacore/shared-ui';
import { alarmsStore } from '$lib/stores/alarms.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import type { CreateAlarmInput, Alarm } from '@clock/shared';
import type { Alarm } from '@clock/shared';
import { ALARM_SOUNDS, DEFAULT_ALARM_PRESETS } from '@clock/shared';
import { AlarmsSkeleton } from '$lib/components/skeletons';
// Get live query data from layout context
const allAlarms: { readonly value: Alarm[] } = getContext('alarms');
// Quick create form (inline)
let newTime = $state('07:00');
@ -27,7 +28,7 @@
// Find existing alarm for a preset time
function findAlarmForPreset(presetTime: string): Alarm | undefined {
return alarmsStore.alarms.find((a) => a.time.slice(0, 5) === presetTime);
return allAlarms.value.find((a) => a.time.slice(0, 5) === presetTime);
}
// Toggle a preset alarm
@ -35,7 +36,7 @@
const existingAlarm = findAlarmForPreset(presetTime);
if (existingAlarm) {
await alarmsStore.toggleAlarm(existingAlarm.id);
await alarmsStore.toggleAlarm(existingAlarm.id, allAlarms.value);
} else {
const result = await alarmsStore.createAlarm({
time: presetTime + ':00',
@ -79,11 +80,6 @@
}
}
onMount(async () => {
// Load alarms - works for both authenticated and guest mode
await alarmsStore.fetchAlarms();
});
function openEditModal(alarm: Alarm) {
editingId = alarm.id;
editTime = alarm.time.slice(0, 5);
@ -136,7 +132,7 @@
}
async function handleToggle(id: string) {
await alarmsStore.toggleAlarm(id);
await alarmsStore.toggleAlarm(id, allAlarms.value);
}
function getRepeatText(days: number[] | null) {
@ -191,63 +187,58 @@
</div>
{/if}
<!-- Loading State -->
{#if alarmsStore.loading}
<AlarmsSkeleton />
{:else}
<!-- Default Alarm Presets (Grid) -->
<div class="alarm-grid">
{#each DEFAULT_ALARM_PRESETS as preset}
{@const existingAlarm = findAlarmForPreset(preset.time)}
{@const isActive = existingAlarm?.enabled ?? false}
<div
class="alarm-tile"
class:active={isActive}
role="button"
tabindex="0"
onclick={() => togglePreset(preset.time, preset.label)}
onkeydown={(e) => e.key === 'Enter' && togglePreset(preset.time, preset.label)}
>
<div class="text-xl font-light text-foreground tabular-nums text-center">
{preset.time}
</div>
<div class="text-[10px] text-muted-foreground text-center truncate mt-0.5">
{existingAlarm?.label || preset.label}
</div>
<!-- Default Alarm Presets (Grid) -->
<div class="alarm-grid">
{#each DEFAULT_ALARM_PRESETS as preset}
{@const existingAlarm = findAlarmForPreset(preset.time)}
{@const isActive = existingAlarm?.enabled ?? false}
<div
class="alarm-tile"
class:active={isActive}
role="button"
tabindex="0"
onclick={() => togglePreset(preset.time, preset.label)}
onkeydown={(e) => e.key === 'Enter' && togglePreset(preset.time, preset.label)}
>
<div class="text-xl font-light text-foreground tabular-nums text-center">
{preset.time}
</div>
{/each}
</div>
<!-- Custom Alarms (Grid) -->
{@const customAlarms = alarmsStore.alarms.filter(
(a) => !DEFAULT_ALARM_PRESETS.some((p) => p.time === a.time.slice(0, 5))
)}
{#if customAlarms.length > 0}
<div class="mt-4">
<h2 class="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wide">
{$_('alarm.custom')}
</h2>
<div class="alarm-grid">
{#each customAlarms as alarm (alarm.id)}
<div
class="alarm-tile"
class:active={alarm.enabled}
role="button"
tabindex="0"
onclick={() => handleToggle(alarm.id)}
onkeydown={(e) => e.key === 'Enter' && handleToggle(alarm.id)}
>
<div class="text-xl font-light text-foreground tabular-nums text-center">
{alarm.time.slice(0, 5)}
</div>
<div class="text-[10px] text-muted-foreground text-center truncate mt-0.5">
{alarm.label || getRepeatText(alarm.repeatDays)}
</div>
</div>
{/each}
<div class="text-[10px] text-muted-foreground text-center truncate mt-0.5">
{existingAlarm?.label || preset.label}
</div>
</div>
{/if}
{/each}
</div>
<!-- Custom Alarms (Grid) -->
{@const customAlarms = allAlarms.value.filter(
(a) => !DEFAULT_ALARM_PRESETS.some((p) => p.time === a.time.slice(0, 5))
)}
{#if customAlarms.length > 0}
<div class="mt-4">
<h2 class="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wide">
{$_('alarm.custom')}
</h2>
<div class="alarm-grid">
{#each customAlarms as alarm (alarm.id)}
<div
class="alarm-tile"
class:active={alarm.enabled}
role="button"
tabindex="0"
onclick={() => handleToggle(alarm.id)}
onkeydown={(e) => e.key === 'Enter' && handleToggle(alarm.id)}
>
<div class="text-xl font-light text-foreground tabular-nums text-center">
{alarm.time.slice(0, 5)}
</div>
<div class="text-[10px] text-muted-foreground text-center truncate mt-0.5">
{alarm.label || getRepeatText(alarm.repeatDays)}
</div>
</div>
{/each}
</div>
</div>
{/if}
<!-- Edit Modal -->

View file

@ -1,12 +1,14 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { getContext, onDestroy } from 'svelte';
import { _ } from 'svelte-i18n';
import { browser } from '$app/environment';
import { PageHeader, toast } from '@manacore/shared-ui';
import { timersStore } from '$lib/stores/timers.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { QUICK_TIMER_PRESETS, formatDuration } from '@clock/shared';
import { TimersSkeleton } from '$lib/components/skeletons';
import type { Timer } from '@clock/shared';
// Get live query data from layout context
const allTimersQuery: { readonly value: Timer[] } = getContext('timers');
// Form state (inline on page)
let formMinutes = $state(5);
@ -24,12 +26,7 @@
}
let localTimers = $state<LocalTimer[]>([]);
let intervals: Map<string, ReturnType<typeof setInterval>> = new Map();
let allTimers = $derived([...timersStore.timers, ...localTimers]);
onMount(async () => {
// Load timers - works for both authenticated and guest mode
await timersStore.fetchTimers();
});
let allTimers = $derived([...allTimersQuery.value, ...localTimers]);
onDestroy(() => {
intervals.forEach((interval) => clearInterval(interval));
@ -69,7 +66,7 @@
}
}
} else {
const timer = timersStore.timers.find((t) => t.id === timerId);
const timer = allTimersQuery.value.find((t) => t.id === timerId);
if (!timer || timer.status !== 'running') {
clearInterval(interval);
intervals.delete(timerId);
@ -216,10 +213,7 @@
{/each}
</div>
<!-- Loading State -->
{#if timersStore.loading}
<TimersSkeleton />
{:else if allTimers.length > 0}
{#if allTimers.length > 0}
<!-- Active Timers -->
<div>
<h2 class="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wide">

View file

@ -1,12 +1,14 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { getContext, onDestroy } from 'svelte';
import { _ } from 'svelte-i18n';
import { PageHeader, toast } from '@manacore/shared-ui';
import { worldClocksStore } from '$lib/stores/world-clocks.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { POPULAR_TIMEZONES } from '@clock/shared';
import type { WorldClock } from '@clock/shared';
import WorldMap from '$lib/components/WorldMap.svelte';
import { WorldClockSkeleton } from '$lib/components/skeletons';
// Get live query data from layout context
const allWorldClocks: { readonly value: WorldClock[] } = getContext('worldClocks');
// State
let showAddModal = $state(false);
@ -16,11 +18,11 @@
let showMap = $state(true);
// Selected city timezones for map highlighting
let selectedTimezones = $derived(worldClocksStore.worldClocks.map((wc) => wc.timezone));
let selectedTimezones = $derived(allWorldClocks.value.map((wc) => wc.timezone));
// Handle map city click
function handleMapCityClick(timezone: string, cityName: string) {
const alreadyAdded = worldClocksStore.worldClocks.some((wc) => wc.timezone === timezone);
const alreadyAdded = allWorldClocks.value.some((wc) => wc.timezone === timezone);
if (alreadyAdded) {
toast.info(`${cityName} ist bereits hinzugefügt`);
} else {
@ -39,16 +41,10 @@
: POPULAR_TIMEZONES
);
onMount(async () => {
if (authStore.isAuthenticated) {
await worldClocksStore.fetchWorldClocks();
}
// Update time every second
interval = setInterval(() => {
currentTime = new Date();
}, 1000);
});
// Update time every second
interval = setInterval(() => {
currentTime = new Date();
}, 1000);
onDestroy(() => {
if (interval) {
@ -66,10 +62,10 @@
}
async function addCity(timezone: string, cityName: string) {
const result = await worldClocksStore.addWorldClock({
timezone,
cityName,
});
const result = await worldClocksStore.addWorldClock(
{ timezone, cityName },
allWorldClocks.value.length
);
if (result.success) {
toast.success(`${cityName} hinzugefügt`);
@ -204,9 +200,7 @@
{/if}
<!-- World Clock List -->
{#if worldClocksStore.loading}
<WorldClockSkeleton />
{:else if worldClocksStore.sortedWorldClocks.length === 0}
{#if allWorldClocks.value.length === 0}
<div class="card py-12 text-center">
<p class="text-lg text-muted-foreground">{$_('worldClock.noClocks')}</p>
<button class="btn btn-primary mt-4" onclick={openAddModal}>
@ -215,7 +209,7 @@
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each worldClocksStore.sortedWorldClocks as clock (clock.id)}
{#each allWorldClocks.value as clock (clock.id)}
{@const isDay = isDaytime(clock.timezone)}
<div class="world-clock-card relative">
<!-- Delete button -->
@ -295,9 +289,7 @@
<!-- Timezone list -->
<div class="flex-1 overflow-y-auto -mx-4 px-4">
{#each filteredTimezones as tz}
{@const alreadyAdded = worldClocksStore.worldClocks.some(
(wc) => wc.timezone === tz.timezone
)}
{@const alreadyAdded = allWorldClocks.value.some((wc) => wc.timezone === tz.timezone)}
<button
class="flex w-full items-center justify-between rounded-lg p-3 text-left hover:bg-muted transition-colors"
class:opacity-50={alreadyAdded}