mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-23 02:46:42 +02:00
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:
parent
ced7dd7441
commit
30e124e609
87 changed files with 2528 additions and 3136 deletions
106
apps/clock/apps/web/src/lib/data/queries.ts
Normal file
106
apps/clock/apps/web/src/lib/data/queries.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue