diff --git a/apps/manacore/apps/landing/src/components/sections/EcosystemOverview.astro b/apps/manacore/apps/landing/src/components/sections/EcosystemOverview.astro index 8b1682427..0275d343e 100644 --- a/apps/manacore/apps/landing/src/components/sections/EcosystemOverview.astro +++ b/apps/manacore/apps/landing/src/components/sections/EcosystemOverview.astro @@ -67,7 +67,7 @@ const { class: className } = Astro.props;
- Q2 2025 + Q3 2026
diff --git a/apps/manacore/apps/landing/src/content/apps/cards-de.md b/apps/manacore/apps/landing/src/content/apps/cards-de.md index 935449cf1..7ccf26898 100644 --- a/apps/manacore/apps/landing/src/content/apps/cards-de.md +++ b/apps/manacore/apps/landing/src/content/apps/cards-de.md @@ -18,7 +18,7 @@ features: - Offline-Modus für unterwegs - Import/Export von Anki und Quizlet status: coming-soon -releaseDate: Geplant Q2 2025 +releaseDate: Geplant Q3 2026 order: 3 website: https://cards.ai --- diff --git a/apps/manacore/apps/web/src/lib/modules/clock/collections.ts b/apps/manacore/apps/web/src/lib/modules/clock/collections.ts new file mode 100644 index 000000000..9db6d8dca --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/clock/collections.ts @@ -0,0 +1,43 @@ +/** + * Clock module — collection accessors and guest seed data. + */ + +import { db } from '$lib/data/database'; +import type { LocalAlarm, LocalTimer, LocalWorldClock } from './types'; + +// ─── Collection Accessors ────────────────────────────────── + +export const alarmTable = db.table('alarms'); +export const timerTable = db.table('timers'); +export const worldClockTable = db.table('worldClocks'); + +// ─── Guest Seed ──────────────────────────────────────────── + +export const CLOCK_GUEST_SEED = { + alarms: [ + { + 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, + }, + ] satisfies LocalAlarm[], + worldClocks: [ + { + id: 'wc-new-york', + timezone: 'America/New_York', + cityName: 'New York', + sortOrder: 0, + }, + { + id: 'wc-tokyo', + timezone: 'Asia/Tokyo', + cityName: 'Tokio', + sortOrder: 1, + }, + ] satisfies LocalWorldClock[], +}; diff --git a/apps/manacore/apps/web/src/lib/modules/clock/components/CircularProgress.svelte b/apps/manacore/apps/web/src/lib/modules/clock/components/CircularProgress.svelte new file mode 100644 index 000000000..8327c6928 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/clock/components/CircularProgress.svelte @@ -0,0 +1,204 @@ + + +
+
+ + + + + + + + + {#each Array(8) as _, i} + {@const angle = (i / 8) * 360 - 90} + {@const markerRadius = radius + strokeWidth / 2 + 8} + {@const x = size / 2 + markerRadius * Math.cos((angle * Math.PI) / 180)} + {@const y = size / 2 + markerRadius * Math.sin((angle * Math.PI) / 180)} + + {i * 10} + + {/each} + + + +
+ {percentage.toFixed(1)}% + gelebt +
+
+ +
+
+
+ {daysLived.toLocaleString('de-DE')} + Tage gelebt +
+
+
+ {remainingDays.toLocaleString('de-DE')} + Tage verbleibend +
+
+

Basierend auf {lifeExpectancyYears} Jahren Lebenserwartung

+
+
+ + diff --git a/apps/manacore/apps/web/src/lib/modules/clock/components/WorldMap.svelte b/apps/manacore/apps/web/src/lib/modules/clock/components/WorldMap.svelte new file mode 100644 index 000000000..1f2c56dad --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/clock/components/WorldMap.svelte @@ -0,0 +1,111 @@ + + +
+ {#if mapLoaded} +
+ + + + + + {#each cities as city} + {@const x = ((city.lng + 180) / 360) * 800} + {@const y = ((90 - city.lat) / 180) * 400} + {@const isSelected = selectedTimezones.includes(city.timezone)} + handleCityClick(city.timezone, city.city)}> + + {#if isSelected} + + {city.city} + + {/if} + + {/each} + +
+ {:else} +
+ Karte wird geladen... +
+ {/if} +
+ + diff --git a/apps/manacore/apps/web/src/lib/modules/clock/index.ts b/apps/manacore/apps/web/src/lib/modules/clock/index.ts new file mode 100644 index 000000000..2373825c1 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/clock/index.ts @@ -0,0 +1,26 @@ +/** + * Clock module — barrel exports. + */ + +export { alarmsStore } from './stores/alarms.svelte'; +export { timersStore } from './stores/timers.svelte'; +export { worldClocksStore } from './stores/world-clocks.svelte'; +export { stopwatchesStore, formatTime, formatLapTime } from './stores/stopwatch.svelte'; +export { sessionAlarmsStore } from './stores/session-alarms.svelte'; +export { sessionTimersStore } from './stores/session-timers.svelte'; +export { + useAllAlarms, + useAllTimers, + useAllWorldClocks, + allAlarms$, + allTimers$, + allWorldClocks$, + toAlarm, + toTimer, + toWorldClock, + filterEnabledAlarms, + filterActiveTimers, + sortWorldClocksByOrder, +} from './queries'; +export { alarmTable, timerTable, worldClockTable, CLOCK_GUEST_SEED } from './collections'; +export type { LocalAlarm, LocalTimer, LocalWorldClock } from './types'; diff --git a/apps/manacore/apps/web/src/lib/modules/clock/queries.ts b/apps/manacore/apps/web/src/lib/modules/clock/queries.ts new file mode 100644 index 000000000..df62e4f0d --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/clock/queries.ts @@ -0,0 +1,124 @@ +/** + * Reactive Queries & Pure Helpers for Clock module. + * + * 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 { liveQuery } from 'dexie'; +import { useLiveQueryWithDefault } from '@manacore/local-store/svelte'; +import { db } from '$lib/data/database'; +import type { LocalAlarm, LocalTimer, LocalWorldClock } from './types'; +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(), + }; +} + +// ─── Raw Observable Queries (for Svelte $ auto-subscribe) ── + +/** All alarms as raw observable. */ +export function allAlarms$() { + return liveQuery(async () => { + const locals = await db.table('alarms').toArray(); + return locals.filter((a) => !a.deletedAt).map(toAlarm); + }); +} + +/** All timers as raw observable. */ +export function allTimers$() { + return liveQuery(async () => { + const locals = await db.table('timers').toArray(); + return locals.filter((t) => !t.deletedAt).map(toTimer); + }); +} + +/** All world clocks as raw observable, sorted by sortOrder. */ +export function allWorldClocks$() { + return liveQuery(async () => { + const locals = await db.table('worldClocks').orderBy('sortOrder').toArray(); + return locals.filter((wc) => !wc.deletedAt).map(toWorldClock); + }); +} + +// ─── Svelte 5 Reactive Hooks (call during component init) ── + +/** All alarms, auto-updates on any change. Returns { value, loading, error }. */ +export function useAllAlarms() { + return useLiveQueryWithDefault(async () => { + const locals = await db.table('alarms').toArray(); + return locals.filter((a) => !a.deletedAt).map(toAlarm); + }, [] as Alarm[]); +} + +/** All timers, auto-updates on any change. Returns { value, loading, error }. */ +export function useAllTimers() { + return useLiveQueryWithDefault(async () => { + const locals = await db.table('timers').toArray(); + return locals.filter((t) => !t.deletedAt).map(toTimer); + }, [] as Timer[]); +} + +/** All world clocks, sorted by sortOrder. Returns { value, loading, error }. */ +export function useAllWorldClocks() { + return useLiveQueryWithDefault(async () => { + const locals = await db.table('worldClocks').orderBy('sortOrder').toArray(); + return locals.filter((wc) => !wc.deletedAt).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); +} diff --git a/apps/manacore/apps/web/src/lib/modules/clock/stores/alarms.svelte.ts b/apps/manacore/apps/web/src/lib/modules/clock/stores/alarms.svelte.ts new file mode 100644 index 000000000..73a5a058d --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/clock/stores/alarms.svelte.ts @@ -0,0 +1,106 @@ +/** + * Alarms Store — Mutation-Only Service + * + * All reads are handled by liveQuery hooks in queries.ts. + * This store only provides write operations (create, update, delete, toggle). + * IndexedDB writes automatically trigger UI updates via Dexie liveQuery. + */ + +import { db } from '$lib/data/database'; +import type { LocalAlarm } from '../types'; +import { toAlarm } from '../queries'; +import type { CreateAlarmInput, UpdateAlarmInput, Alarm } from '@clock/shared'; + +let error = $state(null); + +export const alarmsStore = { + get error() { + return error; + }, + + /** + * Create a new alarm -- writes to IndexedDB instantly. + */ + async createAlarm(input: CreateAlarmInput) { + 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, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + await db.table('alarms').add(newLocal); + return { success: true, data: toAlarm(newLocal) }; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to create alarm'; + console.error('Failed to create alarm:', e); + return { success: false, error: error }; + } + }, + + /** + * Update an alarm -- writes to IndexedDB instantly. + */ + async updateAlarm(id: string, input: UpdateAlarmInput) { + error = null; + try { + const updateData: Partial = { + updatedAt: new Date().toISOString(), + }; + 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; + + await db.table('alarms').update(id, updateData); + const updated = await db.table('alarms').get(id); + if (updated) { + return { success: true, data: toAlarm(updated) }; + } + 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 }; + } + }, + + /** + * Toggle alarm enabled state. + */ + 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 -- soft-deletes from IndexedDB instantly. + */ + async deleteAlarm(id: string) { + error = null; + try { + await db.table('alarms').update(id, { + deletedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + 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 }; + } + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/clock/stores/session-alarms.svelte.ts b/apps/manacore/apps/web/src/lib/modules/clock/stores/session-alarms.svelte.ts new file mode 100644 index 000000000..04e334e34 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/clock/stores/session-alarms.svelte.ts @@ -0,0 +1,150 @@ +/** + * Session Alarms Store - Manages alarms in sessionStorage for guest users + * This allows users to try the app without signing in. + * Data is stored in sessionStorage (lost when tab closes). + */ + +import type { Alarm, CreateAlarmInput, UpdateAlarmInput } from '@clock/shared'; + +const STORAGE_KEY = 'clock-session-alarms'; + +// State +let alarms = $state([]); + +// Generate session ID +function generateSessionId(): string { + return `session_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; +} + +// Load from sessionStorage +function loadFromStorage(): void { + if (typeof window === 'undefined') return; + + try { + const stored = sessionStorage.getItem(STORAGE_KEY); + if (stored) { + alarms = JSON.parse(stored); + } + } catch (e) { + console.error('Failed to load session alarms:', e); + } +} + +// Save to sessionStorage +function saveToStorage(): void { + if (typeof window === 'undefined') return; + + try { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(alarms)); + } catch (e) { + console.error('Failed to save session alarms:', e); + } +} + +// Initialize on load +if (typeof window !== 'undefined') { + loadFromStorage(); +} + +export const sessionAlarmsStore = { + // Getters + get alarms() { + return alarms; + }, + get enabledAlarms() { + return alarms.filter((a) => a.enabled); + }, + + /** + * Create a new session alarm + */ + createAlarm(input: CreateAlarmInput): Alarm { + const now = new Date().toISOString(); + const alarm: Alarm = { + id: generateSessionId(), + userId: 'guest', + 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, + createdAt: now, + updatedAt: now, + }; + + alarms = [...alarms, alarm]; + saveToStorage(); + + return alarm; + }, + + /** + * Update a session alarm + */ + updateAlarm(id: string, input: UpdateAlarmInput): Alarm | null { + const index = alarms.findIndex((a) => a.id === id); + if (index === -1) return null; + + const updated: Alarm = { + ...alarms[index], + ...input, + updatedAt: new Date().toISOString(), + }; + + alarms = alarms.map((a) => (a.id === id ? updated : a)); + saveToStorage(); + + return updated; + }, + + /** + * Toggle alarm enabled state + */ + toggleAlarm(id: string): Alarm | null { + const alarm = alarms.find((a) => a.id === id); + if (!alarm) return null; + + return this.updateAlarm(id, { enabled: !alarm.enabled }); + }, + + /** + * Delete a session alarm + */ + deleteAlarm(id: string): void { + alarms = alarms.filter((a) => a.id !== id); + saveToStorage(); + }, + + /** + * Check if ID is a session alarm + */ + isSessionAlarm(id: string): boolean { + return id.startsWith('session_'); + }, + + /** + * Get all alarms for migration + */ + getAllAlarms(): Alarm[] { + return [...alarms]; + }, + + /** + * Clear all session data + */ + clear(): void { + alarms = []; + if (typeof window !== 'undefined') { + sessionStorage.removeItem(STORAGE_KEY); + } + }, + + /** + * Get count of session alarms + */ + get count(): number { + return alarms.length; + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/clock/stores/session-timers.svelte.ts b/apps/manacore/apps/web/src/lib/modules/clock/stores/session-timers.svelte.ts new file mode 100644 index 000000000..d2953a383 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/clock/stores/session-timers.svelte.ts @@ -0,0 +1,214 @@ +/** + * Session Timers Store - Manages timers in sessionStorage for guest users + * This allows users to try the app without signing in. + * Data is stored in sessionStorage (lost when tab closes). + */ + +import type { Timer, CreateTimerInput, UpdateTimerInput, TimerStatus } from '@clock/shared'; + +const STORAGE_KEY = 'clock-session-timers'; + +// State +let timers = $state([]); + +// Generate session ID +function generateSessionId(): string { + return `session_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; +} + +// Load from sessionStorage +function loadFromStorage(): void { + if (typeof window === 'undefined') return; + + try { + const stored = sessionStorage.getItem(STORAGE_KEY); + if (stored) { + timers = JSON.parse(stored); + } + } catch (e) { + console.error('Failed to load session timers:', e); + } +} + +// Save to sessionStorage +function saveToStorage(): void { + if (typeof window === 'undefined') return; + + try { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(timers)); + } catch (e) { + console.error('Failed to save session timers:', e); + } +} + +// Initialize on load +if (typeof window !== 'undefined') { + loadFromStorage(); +} + +export const sessionTimersStore = { + // Getters + get timers() { + return timers; + }, + get activeTimers() { + return timers.filter((t) => t.status === 'running' || t.status === 'paused'); + }, + + /** + * Create a new session timer + */ + createTimer(input: CreateTimerInput): Timer { + const now = new Date().toISOString(); + const timer: Timer = { + id: generateSessionId(), + userId: 'guest', + label: input.label || null, + durationSeconds: input.durationSeconds, + remainingSeconds: input.durationSeconds, + status: 'idle' as TimerStatus, + startedAt: null, + pausedAt: null, + sound: input.sound || null, + createdAt: now, + updatedAt: now, + }; + + timers = [...timers, timer]; + saveToStorage(); + + return timer; + }, + + /** + * Update a session timer + */ + updateTimer(id: string, input: UpdateTimerInput): Timer | null { + const index = timers.findIndex((t) => t.id === id); + if (index === -1) return null; + + const updated: Timer = { + ...timers[index], + ...input, + updatedAt: new Date().toISOString(), + }; + + timers = timers.map((t) => (t.id === id ? updated : t)); + saveToStorage(); + + return updated; + }, + + /** + * Start a timer + */ + startTimer(id: string): Timer | null { + const timer = timers.find((t) => t.id === id); + if (!timer) return null; + + const now = new Date().toISOString(); + const updated: Timer = { + ...timer, + status: 'running', + startedAt: now, + pausedAt: null, + updatedAt: now, + }; + + timers = timers.map((t) => (t.id === id ? updated : t)); + saveToStorage(); + + return updated; + }, + + /** + * Pause a timer + */ + pauseTimer(id: string): Timer | null { + const timer = timers.find((t) => t.id === id); + if (!timer) return null; + + const now = new Date().toISOString(); + const updated: Timer = { + ...timer, + status: 'paused', + pausedAt: now, + updatedAt: now, + }; + + timers = timers.map((t) => (t.id === id ? updated : t)); + saveToStorage(); + + return updated; + }, + + /** + * Reset a timer + */ + resetTimer(id: string): Timer | null { + const timer = timers.find((t) => t.id === id); + if (!timer) return null; + + const now = new Date().toISOString(); + const updated: Timer = { + ...timer, + status: 'idle', + remainingSeconds: timer.durationSeconds, + startedAt: null, + pausedAt: null, + updatedAt: now, + }; + + timers = timers.map((t) => (t.id === id ? updated : t)); + saveToStorage(); + + return updated; + }, + + /** + * Update local timer state (for countdown display) + */ + updateLocalState(id: string, updates: Partial): void { + timers = timers.map((t) => (t.id === id ? { ...t, ...updates } : t)); + saveToStorage(); + }, + + /** + * Delete a session timer + */ + deleteTimer(id: string): void { + timers = timers.filter((t) => t.id !== id); + saveToStorage(); + }, + + /** + * Check if ID is a session timer + */ + isSessionTimer(id: string): boolean { + return id.startsWith('session_'); + }, + + /** + * Get all timers for migration + */ + getAllTimers(): Timer[] { + return [...timers]; + }, + + /** + * Clear all session data + */ + clear(): void { + timers = []; + if (typeof window !== 'undefined') { + sessionStorage.removeItem(STORAGE_KEY); + } + }, + + /** + * Get count of session timers + */ + get count(): number { + return timers.length; + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/clock/stores/stopwatch.svelte.ts b/apps/manacore/apps/web/src/lib/modules/clock/stores/stopwatch.svelte.ts new file mode 100644 index 000000000..5573dfbc8 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/clock/stores/stopwatch.svelte.ts @@ -0,0 +1,231 @@ +/** + * Stopwatch Store - Manages stopwatch state using Svelte 5 runes + * Stopwatches are local-only (no backend sync) + */ + +export interface Lap { + number: number; + time: number; // milliseconds since start + delta: number; // milliseconds since last lap +} + +export interface Stopwatch { + id: string; + label: string; + startTime: number | null; // timestamp when started + elapsedTime: number; // accumulated milliseconds when paused + status: 'idle' | 'running' | 'paused'; + laps: Lap[]; + color: string; +} + +export const STOPWATCH_COLORS = [ + '#3B82F6', // blue + '#10B981', // green + '#F59E0B', // amber + '#EF4444', // red + '#8B5CF6', // violet + '#EC4899', // pink + '#14B8A6', // teal + '#F97316', // orange +]; + +// State +let stopwatches = $state([]); +let focusedId = $state(null); +let colorIndex = 0; + +// Tick interval for updating display +let tickInterval: ReturnType | null = null; + +function getNextColor(): string { + const color = STOPWATCH_COLORS[colorIndex % STOPWATCH_COLORS.length]; + colorIndex++; + return color; +} + +function startTicking() { + if (tickInterval) return; + tickInterval = setInterval(() => { + // Force reactivity update by reassigning + stopwatches = [...stopwatches]; + }, 100); +} + +function stopTickingIfNoRunning() { + const hasRunning = stopwatches.some((sw) => sw.status === 'running'); + if (!hasRunning && tickInterval) { + clearInterval(tickInterval); + tickInterval = null; + } +} + +export const stopwatchesStore = { + // Getters + get stopwatches() { + return stopwatches; + }, + get focusedId() { + return focusedId; + }, + get focusedStopwatch() { + return stopwatches.find((sw) => sw.id === focusedId) || null; + }, + + /** + * Create a new stopwatch + */ + create(label?: string): string { + const id = crypto.randomUUID(); + const newStopwatch: Stopwatch = { + id, + label: label || `Stopwatch ${stopwatches.length + 1}`, + startTime: null, + elapsedTime: 0, + status: 'idle', + laps: [], + color: getNextColor(), + }; + stopwatches = [...stopwatches, newStopwatch]; + if (!focusedId) { + focusedId = id; + } + return id; + }, + + /** + * Start a stopwatch + */ + start(id: string) { + stopwatches = stopwatches.map((sw) => { + if (sw.id !== id) return sw; + return { + ...sw, + startTime: Date.now(), + status: 'running' as const, + }; + }); + startTicking(); + }, + + /** + * Pause a stopwatch + */ + pause(id: string) { + stopwatches = stopwatches.map((sw) => { + if (sw.id !== id || sw.status !== 'running') return sw; + const elapsed = sw.startTime ? Date.now() - sw.startTime : 0; + return { + ...sw, + startTime: null, + elapsedTime: sw.elapsedTime + elapsed, + status: 'paused' as const, + }; + }); + stopTickingIfNoRunning(); + }, + + /** + * Reset a stopwatch + */ + reset(id: string) { + stopwatches = stopwatches.map((sw) => { + if (sw.id !== id) return sw; + return { + ...sw, + startTime: null, + elapsedTime: 0, + status: 'idle' as const, + laps: [], + }; + }); + stopTickingIfNoRunning(); + }, + + /** + * Add a lap to a stopwatch + */ + addLap(id: string) { + stopwatches = stopwatches.map((sw) => { + if (sw.id !== id || sw.status !== 'running') return sw; + const currentTime = this.getElapsed(sw); + const lastLap = sw.laps[sw.laps.length - 1]; + const delta = lastLap ? currentTime - lastLap.time : currentTime; + const newLap: Lap = { + number: sw.laps.length + 1, + time: currentTime, + delta, + }; + return { + ...sw, + laps: [...sw.laps, newLap], + }; + }); + }, + + /** + * Delete a stopwatch + */ + delete(id: string) { + stopwatches = stopwatches.filter((sw) => sw.id !== id); + if (focusedId === id) { + focusedId = stopwatches[0]?.id || null; + } + stopTickingIfNoRunning(); + }, + + /** + * Set focused stopwatch + */ + setFocused(id: string | null) { + focusedId = id; + }, + + /** + * Update stopwatch label + */ + updateLabel(id: string, label: string) { + stopwatches = stopwatches.map((sw) => (sw.id === id ? { ...sw, label } : sw)); + }, + + /** + * Get elapsed time for a stopwatch + */ + getElapsed(sw: Stopwatch): number { + if (sw.status === 'running' && sw.startTime) { + return sw.elapsedTime + (Date.now() - sw.startTime); + } + return sw.elapsedTime; + }, +}; + +/** + * Format time in milliseconds to display string + */ +export function formatTime(ms: number): string { + const totalSeconds = Math.floor(ms / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + const centiseconds = Math.floor((ms % 1000) / 10); + + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`; + } + return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`; +} + +/** + * Format lap time (delta) for display + */ +export function formatLapTime(ms: number): string { + const totalSeconds = Math.floor(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + const centiseconds = Math.floor((ms % 1000) / 10); + + if (minutes > 0) { + return `+${minutes}:${seconds.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`; + } + return `+${seconds}.${centiseconds.toString().padStart(2, '0')}`; +} diff --git a/apps/manacore/apps/web/src/lib/modules/clock/stores/timers.svelte.ts b/apps/manacore/apps/web/src/lib/modules/clock/stores/timers.svelte.ts new file mode 100644 index 000000000..8b6ef1c08 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/clock/stores/timers.svelte.ts @@ -0,0 +1,206 @@ +/** + * Timers Store — Mutation-Only Service + * + * All reads are handled by liveQuery 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 { db } from '$lib/data/database'; +import type { LocalTimer } from '../types'; +import { toTimer } from '../queries'; +import type { CreateTimerInput, UpdateTimerInput } from '@clock/shared'; +import { ClockEvents } from '@manacore/shared-utils/analytics'; + +let error = $state(null); + +export const timersStore = { + get error() { + return error; + }, + + /** + * Create a new timer -- writes to IndexedDB instantly. + */ + async createTimer(input: CreateTimerInput) { + error = null; + try { + const newLocal: LocalTimer = { + id: crypto.randomUUID(), + label: input.label ?? null, + durationSeconds: input.durationSeconds, + remainingSeconds: null, + status: 'idle', + startedAt: null, + pausedAt: null, + sound: input.sound ?? null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + await db.table('timers').add(newLocal); + return { success: true, data: toTimer(newLocal) }; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to create timer'; + console.error('Failed to create timer:', e); + return { success: false, error: error }; + } + }, + + /** + * Update a timer -- writes to IndexedDB instantly. + */ + async updateTimer(id: string, input: UpdateTimerInput) { + error = null; + try { + const updateData: Partial = { + updatedAt: new Date().toISOString(), + }; + if (input.label !== undefined) updateData.label = input.label ?? null; + if (input.durationSeconds !== undefined) updateData.durationSeconds = input.durationSeconds; + if (input.sound !== undefined) updateData.sound = input.sound ?? null; + + await db.table('timers').update(id, updateData); + const updated = await db.table('timers').get(id); + if (updated) { + return { success: true, data: toTimer(updated) }; + } + return { success: false, error: 'Timer not found' }; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to update timer'; + console.error('Failed to update timer:', e); + return { success: false, error: error }; + } + }, + + /** + * Start a timer -- sets status to running with current timestamp. + */ + async startTimer(id: string) { + error = null; + try { + const existing = await db.table('timers').get(id); + if (!existing) return { success: false, error: 'Timer not found' }; + + const updateData: Partial = { + status: 'running', + startedAt: new Date().toISOString(), + pausedAt: null, + updatedAt: new Date().toISOString(), + }; + + // If resuming from pause, keep remaining seconds + if (existing.status !== 'paused') { + updateData.remainingSeconds = existing.durationSeconds; + } + + await db.table('timers').update(id, updateData); + const updated = await db.table('timers').get(id); + if (updated) { + const updatedTimer = toTimer(updated); + ClockEvents.timerStarted( + (updatedTimer as any).type as 'pomodoro' | 'stopwatch' | 'countdown' + ); + return { success: true, data: updatedTimer }; + } + return { success: false, error: 'Timer not found' }; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to start timer'; + console.error('Failed to start timer:', e); + return { success: false, error: error }; + } + }, + + /** + * Pause a timer -- calculates remaining seconds and saves. + */ + async pauseTimer(id: string) { + error = null; + try { + const existing = await db.table('timers').get(id); + if (!existing) return { success: false, error: 'Timer not found' }; + + // Calculate remaining seconds + let remaining = existing.remainingSeconds ?? existing.durationSeconds; + if (existing.startedAt) { + const elapsed = (Date.now() - new Date(existing.startedAt).getTime()) / 1000; + remaining = Math.max(0, remaining - elapsed); + } + + const updateData: Partial = { + status: 'paused', + pausedAt: new Date().toISOString(), + remainingSeconds: Math.round(remaining), + startedAt: null, + updatedAt: new Date().toISOString(), + }; + + await db.table('timers').update(id, updateData); + const updated = await db.table('timers').get(id); + if (updated) { + return { success: true, data: toTimer(updated) }; + } + return { success: false, error: 'Timer not found' }; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to pause timer'; + console.error('Failed to pause timer:', e); + return { success: false, error: error }; + } + }, + + /** + * Reset a timer -- back to idle with full duration. + */ + async resetTimer(id: string) { + error = null; + try { + const updateData: Partial = { + status: 'idle', + remainingSeconds: null, + startedAt: null, + pausedAt: null, + updatedAt: new Date().toISOString(), + }; + + await db.table('timers').update(id, updateData); + const updated = await db.table('timers').get(id); + if (updated) { + return { success: true, data: toTimer(updated) }; + } + return { success: false, error: 'Timer not found' }; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to reset timer'; + console.error('Failed to reset timer:', e); + return { success: false, error: error }; + } + }, + + /** + * Delete a timer -- soft-deletes from IndexedDB instantly. + */ + async deleteTimer(id: string) { + error = null; + try { + await db.table('timers').update(id, { + deletedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + return { success: true }; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to delete timer'; + console.error('Failed to delete timer:', e); + return { success: false, error: error }; + } + }, + + /** + * Update remaining seconds in IndexedDB (for countdown display). + */ + async updateLocalTimer(id: string, remainingSeconds: number) { + try { + await db.table('timers').update(id, { remainingSeconds }); + } catch (e) { + console.error('Failed to update local timer:', e); + } + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/clock/stores/world-clocks.svelte.ts b/apps/manacore/apps/web/src/lib/modules/clock/stores/world-clocks.svelte.ts new file mode 100644 index 000000000..99c5ecd1c --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/clock/stores/world-clocks.svelte.ts @@ -0,0 +1,82 @@ +/** + * World Clocks Store — Mutation-Only Service + * + * All reads are handled by liveQuery hooks in queries.ts. + * This store only provides write operations (add, remove, reorder). + * IndexedDB writes automatically trigger UI updates via Dexie liveQuery. + */ + +import { db } from '$lib/data/database'; +import type { LocalWorldClock } from '../types'; +import type { CreateWorldClockInput } from '@clock/shared'; + +let error = $state(null); + +export const worldClocksStore = { + get error() { + return error; + }, + + /** + * Add a new world clock -- writes to IndexedDB instantly. + */ + async addWorldClock(input: CreateWorldClockInput, currentCount: number = 0) { + error = null; + try { + const newLocal: LocalWorldClock = { + id: crypto.randomUUID(), + timezone: input.timezone, + cityName: input.cityName, + sortOrder: currentCount, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + await db.table('worldClocks').add(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); + return { success: false, error: error }; + } + }, + + /** + * Remove a world clock -- soft-deletes from IndexedDB instantly. + */ + async removeWorldClock(id: string) { + error = null; + try { + await db.table('worldClocks').update(id, { + deletedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + 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 }; + } + }, + + /** + * Reorder world clocks -- updates sortOrder in IndexedDB. + */ + async reorder(ids: string[]) { + error = null; + try { + const now = new Date().toISOString(); + for (let i = 0; i < ids.length; i++) { + await db.table('worldClocks').update(ids[i], { + sortOrder: i, + updatedAt: now, + }); + } + 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 }; + } + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/clock/types.ts b/apps/manacore/apps/web/src/lib/modules/clock/types.ts new file mode 100644 index 000000000..f9fc3a7fe --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/clock/types.ts @@ -0,0 +1,31 @@ +/** + * Clock module types for the unified ManaCore app. + */ + +import type { BaseRecord } from '@manacore/local-store'; + +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; +} diff --git a/apps/manacore/apps/web/src/lib/modules/inventar/collections.ts b/apps/manacore/apps/web/src/lib/modules/inventar/collections.ts new file mode 100644 index 000000000..f102fa772 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/inventar/collections.ts @@ -0,0 +1,109 @@ +/** + * Inventar module — collection accessors and guest seed data. + * + * Uses prefixed table names in the unified DB: invCollections, invItems, invLocations, invCategories. + */ + +import { db } from '$lib/data/database'; +import type { LocalCollection, LocalItem, LocalLocation, LocalCategory } from './types'; + +// ─── Collection Accessors ────────────────────────────────── + +export const invCollectionTable = db.table('invCollections'); +export const invItemTable = db.table('invItems'); +export const invLocationTable = db.table('invLocations'); +export const invCategoryTable = db.table('invCategories'); + +// ─── Guest Seed ──────────────────────────────────────────── + +const DEMO_COLLECTION_ID = 'demo-electronics'; +const DEMO_LOCATION_ID = 'demo-home'; +const DEMO_CATEGORY_ID = 'demo-tech'; + +export const INVENTAR_GUEST_SEED = { + invCollections: [ + { + id: DEMO_COLLECTION_ID, + name: 'Meine Elektronik', + description: 'Beispiel-Sammlung zum Kennenlernen von Inventar.', + icon: '💻', + color: '#3b82f6', + schema: { + fields: [ + { id: 'brand', name: 'Marke', type: 'text', order: 0 }, + { id: 'model', name: 'Modell', type: 'text', order: 1 }, + { id: 'serial', name: 'Seriennummer', type: 'text', order: 2 }, + ], + }, + order: 0, + itemCount: 2, + }, + ], + invItems: [ + { + id: 'item-laptop', + collectionId: DEMO_COLLECTION_ID, + locationId: DEMO_LOCATION_ID, + categoryId: DEMO_CATEGORY_ID, + name: 'MacBook Pro', + description: 'Arbeits-Laptop', + status: 'owned' as const, + quantity: 1, + fieldValues: { brand: 'Apple', model: 'MacBook Pro 14"', serial: 'ABC123' }, + photos: [], + notes: [], + tags: ['arbeit'], + order: 0, + }, + { + id: 'item-headphones', + collectionId: DEMO_COLLECTION_ID, + locationId: DEMO_LOCATION_ID, + name: 'Kopfhorer', + description: 'Noise-Cancelling Kopfhorer', + status: 'owned' as const, + quantity: 1, + fieldValues: { brand: 'Sony', model: 'WH-1000XM5' }, + photos: [], + notes: [], + tags: ['audio'], + order: 1, + }, + ], + invLocations: [ + { + id: DEMO_LOCATION_ID, + name: 'Zuhause', + description: 'Mein Zuhause', + icon: '🏠', + path: 'Zuhause', + depth: 0, + order: 0, + }, + { + id: 'demo-office', + parentId: DEMO_LOCATION_ID, + name: 'Buro', + icon: '🖥️', + path: 'Zuhause/Buro', + depth: 1, + order: 0, + }, + ], + invCategories: [ + { + id: DEMO_CATEGORY_ID, + name: 'Technik', + icon: '⚡', + color: '#6366f1', + order: 0, + }, + { + id: 'demo-audio', + name: 'Audio', + icon: '🎧', + color: '#ec4899', + order: 1, + }, + ], +}; diff --git a/apps/manacore/apps/web/src/lib/modules/inventar/queries.ts b/apps/manacore/apps/web/src/lib/modules/inventar/queries.ts new file mode 100644 index 000000000..a19df6be4 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/inventar/queries.ts @@ -0,0 +1,324 @@ +/** + * Reactive queries & pure helpers for Inventar — uses Dexie liveQuery on the unified DB. + * + * Uses prefixed table names: invCollections, invItems, invLocations, invCategories. + */ + +import { liveQuery } from 'dexie'; +import { db } from '$lib/data/database'; +import type { LocalCollection, LocalItem, LocalLocation, LocalCategory } from './types'; + +// ─── Shared Types (inline to avoid @inventar/shared dependency) ─── + +export interface Collection { + id: string; + name: string; + description?: string; + icon?: string; + color?: string; + schema: LocalCollection['schema']; + templateId?: string; + order: number; + itemCount: number; + createdAt: string; + updatedAt: string; +} + +export interface Item { + id: string; + collectionId: string; + locationId?: string; + categoryId?: string; + name: string; + description?: string; + status: 'owned' | 'lent' | 'stored' | 'for_sale' | 'disposed'; + quantity: number; + fieldValues: Record; + purchaseData?: LocalItem['purchaseData']; + photos: LocalItem['photos']; + notes: LocalItem['notes']; + documents: never[]; + tags: string[]; + order: number; + createdAt: string; + updatedAt: string; +} + +export type ItemStatus = Item['status']; + +export interface Location { + id: string; + parentId?: string; + name: string; + description?: string; + icon?: string; + path: string; + depth: number; + order: number; + createdAt: string; + updatedAt: string; + children?: Location[]; +} + +export interface Category { + id: string; + parentId?: string; + name: string; + icon?: string; + color?: string; + order: number; + createdAt: string; + updatedAt: string; + children?: Category[]; +} + +export type ViewMode = 'list' | 'grid' | 'table'; + +export interface SortOption { + field: 'name' | 'createdAt' | 'updatedAt' | 'status' | 'quantity'; + direction: 'asc' | 'desc'; +} + +export interface FilterCriteria { + search?: string; + collectionId?: string; + locationId?: string; + categoryId?: string; + status?: ItemStatus[]; + tagIds?: string[]; +} + +// ─── Type Converters ─────────────────────────────────────── + +export function toCollection(local: LocalCollection): Collection { + return { + id: local.id, + name: local.name, + description: local.description ?? undefined, + icon: local.icon ?? undefined, + color: local.color ?? undefined, + schema: local.schema, + templateId: local.templateId ?? undefined, + order: local.order, + itemCount: local.itemCount, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +export function toItem(local: LocalItem): Item { + return { + id: local.id, + collectionId: local.collectionId, + locationId: local.locationId ?? undefined, + categoryId: local.categoryId ?? undefined, + name: local.name, + description: local.description ?? undefined, + status: local.status, + quantity: local.quantity, + fieldValues: local.fieldValues, + purchaseData: local.purchaseData ?? undefined, + photos: local.photos, + notes: local.notes, + documents: [], + tags: local.tags, + order: local.order, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +export function toLocation(local: LocalLocation): Location { + return { + id: local.id, + parentId: local.parentId ?? undefined, + name: local.name, + description: local.description ?? undefined, + icon: local.icon ?? undefined, + path: local.path, + depth: local.depth, + order: local.order, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +export function toCategory(local: LocalCategory): Category { + return { + id: local.id, + parentId: local.parentId ?? undefined, + name: local.name, + icon: local.icon ?? undefined, + color: local.color ?? undefined, + order: local.order, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +// ─── Live Queries ────────────────────────────────────────── + +export function useAllCollections() { + return liveQuery(async () => { + const locals = await db.table('invCollections').toArray(); + return locals.filter((c) => !c.deletedAt).map(toCollection); + }); +} + +export function useAllItems() { + return liveQuery(async () => { + const locals = await db.table('invItems').toArray(); + return locals.filter((i) => !i.deletedAt).map(toItem); + }); +} + +export function useAllLocations() { + return liveQuery(async () => { + const locals = await db.table('invLocations').toArray(); + return locals.filter((l) => !l.deletedAt).map(toLocation); + }); +} + +export function useAllCategories() { + return liveQuery(async () => { + const locals = await db.table('invCategories').toArray(); + return locals.filter((c) => !c.deletedAt).map(toCategory); + }); +} + +// ─── Pure Collection Helpers ────────────────────────────── + +export function getCollectionById(collections: Collection[], id: string): Collection | undefined { + return collections.find((c) => c.id === id); +} + +export function getSortedCollections(collections: Collection[]): Collection[] { + return [...collections].sort((a, b) => a.order - b.order); +} + +// ─── Pure Item Helpers ──────────────────────────────────── + +export function getItemById(items: Item[], id: string): Item | undefined { + return items.find((i) => i.id === id); +} + +export function getItemsByCollection(items: Item[], collectionId: string): Item[] { + return items.filter((i) => i.collectionId === collectionId); +} + +export function getItemCountByCollection(items: Item[], collectionId: string): number { + return items.filter((i) => i.collectionId === collectionId).length; +} + +export function getTotalItemCount(items: Item[]): number { + return items.length; +} + +export function getFilteredItems(items: Item[], filters: FilterCriteria): Item[] { + let result = items; + + if (filters.collectionId) { + result = result.filter((i) => i.collectionId === filters.collectionId); + } + if (filters.locationId) { + result = result.filter((i) => i.locationId === filters.locationId); + } + if (filters.categoryId) { + result = result.filter((i) => i.categoryId === filters.categoryId); + } + if (filters.status?.length) { + result = result.filter((i) => filters.status!.includes(i.status)); + } + if (filters.tagIds?.length) { + result = result.filter((i) => filters.tagIds!.some((t) => i.tags.includes(t))); + } + if (filters.search) { + const q = filters.search.toLowerCase(); + result = result.filter( + (i) => + i.name.toLowerCase().includes(q) || + i.description?.toLowerCase().includes(q) || + Object.values(i.fieldValues).some((v) => String(v).toLowerCase().includes(q)) + ); + } + + return result; +} + +export function getSortedItems(itemList: Item[], sort: SortOption): Item[] { + return [...itemList].sort((a, b) => { + let cmp = 0; + switch (sort.field) { + case 'name': + cmp = a.name.localeCompare(b.name); + break; + case 'createdAt': + cmp = a.createdAt.localeCompare(b.createdAt); + break; + case 'updatedAt': + cmp = a.updatedAt.localeCompare(b.updatedAt); + break; + case 'status': + cmp = a.status.localeCompare(b.status); + break; + case 'quantity': + cmp = a.quantity - b.quantity; + break; + } + return sort.direction === 'desc' ? -cmp : cmp; + }); +} + +// ─── Pure Location Helpers ──────────────────────────────── + +export function getLocationById(locations: Location[], id: string): Location | undefined { + return locations.find((l) => l.id === id); +} + +export function getRootLocations(locations: Location[]): Location[] { + return locations.filter((l) => !l.parentId).sort((a, b) => a.order - b.order); +} + +export function getLocationChildren(locations: Location[], parentId: string): Location[] { + return locations.filter((l) => l.parentId === parentId).sort((a, b) => a.order - b.order); +} + +export function getLocationTree(locations: Location[]): Location[] { + const buildTree = (parentId?: string): Location[] => { + return locations + .filter((l) => l.parentId === parentId) + .sort((a, b) => a.order - b.order) + .map((l) => ({ ...l, children: buildTree(l.id) })); + }; + return buildTree(undefined); +} + +export function getLocationFullPath(locations: Location[], id: string): string { + const location = locations.find((l) => l.id === id); + if (!location) return ''; + return location.path ? `${location.path}/${location.name}` : location.name; +} + +// ─── Pure Category Helpers ──────────────────────────────── + +export function getCategoryById(categories: Category[], id: string): Category | undefined { + return categories.find((c) => c.id === id); +} + +export function getRootCategories(categories: Category[]): Category[] { + return categories.filter((c) => !c.parentId).sort((a, b) => a.order - b.order); +} + +export function getCategoryChildren(categories: Category[], parentId: string): Category[] { + return categories.filter((c) => c.parentId === parentId).sort((a, b) => a.order - b.order); +} + +export function getCategoryTree(categories: Category[]): Category[] { + const buildTree = (parentId?: string): Category[] => { + return categories + .filter((c) => c.parentId === parentId) + .sort((a, b) => a.order - b.order) + .map((c) => ({ ...c, children: buildTree(c.id) })); + }; + return buildTree(undefined); +} diff --git a/apps/manacore/apps/web/src/lib/modules/inventar/types.ts b/apps/manacore/apps/web/src/lib/modules/inventar/types.ts new file mode 100644 index 000000000..77d89f58a --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/inventar/types.ts @@ -0,0 +1,68 @@ +/** + * Inventar module types for the unified app. + */ + +import type { BaseRecord } from '@manacore/local-store'; + +export interface LocalCollection extends BaseRecord { + name: string; + description?: string | null; + icon?: string | null; + color?: string | null; + schema: { + fields: Array<{ + id: string; + name: string; + type: string; + required?: boolean; + defaultValue?: unknown; + options?: string[]; + currencyCode?: string; + placeholder?: string; + order: number; + }>; + }; + templateId?: string | null; + order: number; + itemCount: number; +} + +export interface LocalItem extends BaseRecord { + collectionId: string; + locationId?: string | null; + categoryId?: string | null; + name: string; + description?: string | null; + status: 'owned' | 'lent' | 'stored' | 'for_sale' | 'disposed'; + quantity: number; + fieldValues: Record; + purchaseData?: { + price?: number; + currency?: string; + date?: string; + retailer?: string; + warrantyExpiry?: string; + } | null; + photos: Array<{ id: string; url: string; caption?: string; order: number }>; + notes: Array<{ id: string; content: string; createdAt: string }>; + tags: string[]; + order: number; +} + +export interface LocalLocation extends BaseRecord { + parentId?: string | null; + name: string; + description?: string | null; + icon?: string | null; + path: string; + depth: number; + order: number; +} + +export interface LocalCategory extends BaseRecord { + parentId?: string | null; + name: string; + icon?: string | null; + color?: string | null; + order: number; +} diff --git a/apps/manacore/apps/web/src/lib/modules/moodlit/collections.ts b/apps/manacore/apps/web/src/lib/modules/moodlit/collections.ts new file mode 100644 index 000000000..d9fc48b0f --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/moodlit/collections.ts @@ -0,0 +1,88 @@ +/** + * Moodlit module — collection accessors and guest seed data. + */ + +import { db } from '$lib/data/database'; +import type { LocalMood, LocalSequence } from './types'; + +// ─── Collection Accessors ────────────────────────────────── + +export const moodTable = db.table('moods'); +export const sequenceTable = db.table('sequences'); + +// ─── Guest Seed ──────────────────────────────────────────── + +export const MOODLIT_GUEST_SEED = { + moods: [ + { + id: 'fire', + name: 'Fire', + colors: ['#ff6b35', '#f72585', '#ff006e'], + animation: 'flicker', + isDefault: true, + }, + { + id: 'breath', + name: 'Breath', + colors: ['#4361ee', '#3a0ca3', '#7209b7'], + animation: 'pulse', + isDefault: true, + }, + { + id: 'northern-lights', + name: 'Northern Lights', + colors: ['#06d6a0', '#118ab2', '#073b4c'], + animation: 'aurora', + isDefault: true, + }, + { + id: 'sunset', + name: 'Sunset', + colors: ['#ff6b6b', '#feca57', '#ff9ff3'], + animation: 'gradient', + isDefault: true, + }, + { + id: 'ocean', + name: 'Ocean', + colors: ['#0077b6', '#00b4d8', '#90e0ef'], + animation: 'wave', + isDefault: true, + }, + { + id: 'forest', + name: 'Forest', + colors: ['#2d6a4f', '#40916c', '#52b788'], + animation: 'sway', + isDefault: true, + }, + { + id: 'lavender', + name: 'Lavender', + colors: ['#7b2cbf', '#9d4edd', '#c77dff'], + animation: 'pulse', + isDefault: true, + }, + { + id: 'thunder', + name: 'Thunder', + colors: ['#14213d', '#fca311', '#e5e5e5'], + animation: 'flash', + isDefault: true, + }, + ], + sequences: [ + { + id: 'evening-flow', + name: 'Evening Flow', + moodIds: ['sunset', 'lavender', 'breath'], + duration: 30, + }, + { + id: 'nature', + name: 'Nature', + moodIds: ['forest', 'ocean', 'northern-lights'], + duration: 45, + }, + ], +}; diff --git a/apps/manacore/apps/web/src/lib/modules/moodlit/components/mood/CreateMoodDialog.svelte b/apps/manacore/apps/web/src/lib/modules/moodlit/components/mood/CreateMoodDialog.svelte new file mode 100644 index 000000000..129fb8483 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/moodlit/components/mood/CreateMoodDialog.svelte @@ -0,0 +1,215 @@ + + + + +{#if isOpen} + + + + +
+ +
+{/if} diff --git a/apps/manacore/apps/web/src/lib/modules/moodlit/components/mood/MoodCard.svelte b/apps/manacore/apps/web/src/lib/modules/moodlit/components/mood/MoodCard.svelte new file mode 100644 index 000000000..5a2dc202b --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/moodlit/components/mood/MoodCard.svelte @@ -0,0 +1,188 @@ + + + + {/if} + + + + + {#if mood.isCustom} +
+ + Custom + +
+ {/if} + + + diff --git a/apps/manacore/apps/web/src/lib/modules/moodlit/components/mood/MoodFullscreen.svelte b/apps/manacore/apps/web/src/lib/modules/moodlit/components/mood/MoodFullscreen.svelte new file mode 100644 index 000000000..761fde954 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/moodlit/components/mood/MoodFullscreen.svelte @@ -0,0 +1,583 @@ + + + + + + + diff --git a/apps/manacore/apps/web/src/lib/modules/moodlit/default-moods.ts b/apps/manacore/apps/web/src/lib/modules/moodlit/default-moods.ts new file mode 100644 index 000000000..6b34f5c40 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/moodlit/default-moods.ts @@ -0,0 +1,198 @@ +/** + * 24 preset moods matching the mobile app. + */ + +import type { Mood } from './types'; + +export const DEFAULT_MOODS: Mood[] = [ + { + id: 'fire', + name: 'Fire', + colors: ['#ff6b35', '#ff4500', '#dc143c', '#8b0000'], + animationType: 'candle', + order: 0, + }, + { + id: 'breath', + name: 'Breath', + colors: ['#667eea', '#764ba2', '#f093fb'], + animationType: 'breath', + order: 1, + }, + { + id: 'northern-lights', + name: 'Northern Lights', + colors: ['#5f27cd', '#341f97', '#8854d0', '#a29bfe'], + animationType: 'wave', + order: 2, + }, + { + id: 'thunder', + name: 'Thunder', + colors: ['#2c3e50', '#34495e', '#ffffff', '#95a5a6'], + animationType: 'thunder', + order: 3, + }, + { + id: 'light', + name: 'Light', + colors: ['#ffffff', '#f8f9fa', '#e9ecef'], + animationType: 'gradient', + order: 4, + }, + { + id: 'flash', + name: 'Flash', + colors: ['#ffffff'], + animationType: 'flash', + order: 5, + }, + { + id: 'sos', + name: 'SOS', + colors: ['#ffffff'], + animationType: 'sos', + order: 6, + }, + { + id: 'ocean', + name: 'Ocean', + colors: ['#48dbfb', '#0abde3', '#10ac84', '#1dd1a1'], + animationType: 'wave', + order: 7, + }, + { + id: 'candle', + name: 'Candle', + colors: ['#ff9f43', '#ee5a24', '#ffeaa7'], + animationType: 'candle', + order: 8, + }, + { + id: 'police', + name: 'Police', + colors: ['#e74c3c', '#3498db'], + animationType: 'police', + order: 9, + }, + { + id: 'warning', + name: 'Warning', + colors: ['#f39c12', '#e67e22'], + animationType: 'warning', + order: 10, + }, + { + id: 'disco', + name: 'Disco', + colors: ['#e74c3c', '#9b59b6', '#3498db', '#1abc9c', '#f1c40f', '#e67e22'], + animationType: 'disco', + order: 11, + }, + { + id: 'sunrise', + name: 'Sunrise', + colors: ['#1a1a2e', '#16213e', '#e94560', '#ff6b6b', '#feca57', '#fffacd'], + animationType: 'sunrise', + order: 12, + }, + { + id: 'sunset', + name: 'Sunset', + colors: ['#ff6b6b', '#feca57', '#ff9ff3', '#a29bfe', '#341f97', '#1a1a2e'], + animationType: 'sunset', + order: 13, + }, + { + id: 'forest', + name: 'Forest', + colors: ['#27ae60', '#2ecc71', '#1abc9c', '#16a085'], + animationType: 'pulse', + order: 14, + }, + { + id: 'rave', + name: 'Rave', + colors: [ + '#ff0000', + '#ff00ff', + '#00ffff', + '#00ff00', + '#ffff00', + '#ff6600', + '#0066ff', + '#ff0066', + ], + animationType: 'rave', + order: 15, + }, + { + id: 'scanner', + name: 'Scanner', + colors: ['#e74c3c'], + animationType: 'scanner', + order: 16, + }, + { + id: 'matrix', + name: 'Matrix', + colors: ['#00ff00'], + animationType: 'matrix', + order: 17, + }, + { + id: 'lavender', + name: 'Lavender', + colors: ['#e6e6fa', '#dda0dd', '#da70d6', '#ba55d3'], + animationType: 'pulse', + order: 18, + }, + { + id: 'cherry-blossom', + name: 'Cherry Blossom', + colors: ['#ffb7c5', '#ff69b4', '#ff1493', '#db7093'], + animationType: 'wave', + order: 19, + }, + { + id: 'autumn', + name: 'Autumn', + colors: ['#d35400', '#e67e22', '#f39c12', '#c0392b'], + animationType: 'gradient', + order: 20, + }, + { + id: 'ice', + name: 'Ice', + colors: ['#74b9ff', '#0984e3', '#81ecec', '#00cec9'], + animationType: 'wave', + order: 21, + }, + { + id: 'romance', + name: 'Romance', + colors: ['#fd79a8', '#e84393', '#d63031', '#ff7675'], + animationType: 'pulse', + order: 22, + }, + { + id: 'midnight', + name: 'Midnight', + colors: ['#0c0c0c', '#1a1a2e', '#16213e', '#0f3460'], + animationType: 'breath', + order: 23, + }, +]; + +/** Get mood by ID. */ +export function getDefaultMoodById(id: string): Mood | undefined { + return DEFAULT_MOODS.find((m) => m.id === id); +} + +/** Get gradient CSS for a mood. */ +export function getMoodGradient(mood: Mood): string { + if (mood.colors.length === 1) { + return mood.colors[0]; + } + return `linear-gradient(135deg, ${mood.colors.join(', ')})`; +} diff --git a/apps/manacore/apps/web/src/lib/modules/moodlit/index.ts b/apps/manacore/apps/web/src/lib/modules/moodlit/index.ts new file mode 100644 index 000000000..d9092eb67 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/moodlit/index.ts @@ -0,0 +1,20 @@ +/** + * Moodlit module — barrel exports. + */ + +export { moodsStore } from './stores/moods.svelte'; +export { sequencesStore } from './stores/sequences.svelte'; +export { useAllMoods, useAllSequences, getMoodGradient, getMoodById } from './queries'; +export { moodTable, sequenceTable, MOODLIT_GUEST_SEED } from './collections'; +export { DEFAULT_MOODS, getDefaultMoodById } from './default-moods'; +export type { + LocalMood, + LocalSequence, + Mood, + MoodSequence, + MoodSequenceItem, + MoodSettings, + AnimationType, + AnimationInfo, +} from './types'; +export { ANIMATIONS } from './types'; diff --git a/apps/manacore/apps/web/src/lib/modules/moodlit/queries.ts b/apps/manacore/apps/web/src/lib/modules/moodlit/queries.ts new file mode 100644 index 000000000..6375a2737 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/moodlit/queries.ts @@ -0,0 +1,40 @@ +/** + * Reactive queries for Moodlit — uses Dexie liveQuery on the unified DB. + */ + +import { liveQuery } from 'dexie'; +import { db } from '$lib/data/database'; +import type { LocalMood, LocalSequence, Mood } from './types'; + +// ─── Helpers ────────────────────────────────────────────── + +/** Get gradient CSS for a mood. */ +export function getMoodGradient(mood: Mood): string { + if (mood.colors.length === 1) { + return mood.colors[0]; + } + return `linear-gradient(135deg, ${mood.colors.join(', ')})`; +} + +/** Get mood by ID from a list. */ +export function getMoodById(moods: Mood[], id: string): Mood | undefined { + return moods.find((m) => m.id === id); +} + +// ─── Live Queries ────────────────────────────────────────── + +/** All moods, sorted by name. */ +export function useAllMoods() { + return liveQuery(async () => { + const locals = await db.table('moods').toArray(); + return locals.filter((m) => !m.deletedAt); + }); +} + +/** All sequences, sorted by name. */ +export function useAllSequences() { + return liveQuery(async () => { + const locals = await db.table('sequences').toArray(); + return locals.filter((s) => !s.deletedAt); + }); +} diff --git a/apps/manacore/apps/web/src/lib/modules/moodlit/stores/moods.svelte.ts b/apps/manacore/apps/web/src/lib/modules/moodlit/stores/moods.svelte.ts new file mode 100644 index 000000000..4f5502c5a --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/moodlit/stores/moods.svelte.ts @@ -0,0 +1,119 @@ +/** + * Moods mutation store — write operations for the unified DB. + */ + +import { db } from '$lib/data/database'; +import type { LocalMood } from '../types'; +import type { Mood, MoodSettings } from '../types'; + +// Default settings +const DEFAULT_SETTINGS: MoodSettings = { + animationSpeed: 'normal', + brightness: 100, + autoTimer: 0, + autoMoodSwitch: false, + autoMoodSwitchInterval: 5, +}; + +function createMoodsStore() { + let customMoods = $state([]); + let favoriteIds = $state([]); + let settings = $state({ ...DEFAULT_SETTINGS }); + let activeMood = $state(null); + + // Load from localStorage on init + if (typeof window !== 'undefined') { + const saved = localStorage.getItem('moodlit-store'); + if (saved) { + try { + const parsed = JSON.parse(saved); + if (parsed.customMoods) customMoods = parsed.customMoods; + if (parsed.favoriteIds) favoriteIds = parsed.favoriteIds; + if (parsed.settings) settings = { ...DEFAULT_SETTINGS, ...parsed.settings }; + } catch (e) { + console.error('Failed to load moods from localStorage', e); + } + } + } + + function persist() { + if (typeof window !== 'undefined') { + localStorage.setItem('moodlit-store', JSON.stringify({ customMoods, favoriteIds, settings })); + } + } + + return { + get customMoods() { + return customMoods; + }, + get favoriteIds() { + return favoriteIds; + }, + get settings() { + return settings; + }, + get activeMood() { + return activeMood; + }, + + isFavorite(moodId: string): boolean { + return favoriteIds.includes(moodId); + }, + + setActiveMood(mood: Mood | null) { + activeMood = mood; + }, + + addMood(mood: Mood) { + customMoods = [...customMoods, mood]; + persist(); + }, + + updateMood(id: string, updates: Partial) { + customMoods = customMoods.map((m) => (m.id === id ? { ...m, ...updates } : m)); + persist(); + }, + + removeMood(id: string) { + customMoods = customMoods.filter((m) => m.id !== id); + favoriteIds = favoriteIds.filter((fid) => fid !== id); + persist(); + }, + + toggleFavorite(moodId: string) { + if (favoriteIds.includes(moodId)) { + favoriteIds = favoriteIds.filter((id) => id !== moodId); + } else { + favoriteIds = [...favoriteIds, moodId]; + } + persist(); + }, + + updateSettings(updates: Partial) { + settings = { ...settings, ...updates }; + persist(); + }, + + // IndexedDB mutation methods + async createMood(data: { name: string; colors: string[]; animation: string }) { + await db.table('moods').add({ + id: crypto.randomUUID(), + name: data.name, + colors: data.colors, + animation: data.animation, + isDefault: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + }, + + async deleteMood(id: string) { + await db.table('moods').update(id, { + deletedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + }, + }; +} + +export const moodsStore = createMoodsStore(); diff --git a/apps/manacore/apps/web/src/lib/modules/moodlit/stores/sequences.svelte.ts b/apps/manacore/apps/web/src/lib/modules/moodlit/stores/sequences.svelte.ts new file mode 100644 index 000000000..6fed5caef --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/moodlit/stores/sequences.svelte.ts @@ -0,0 +1,151 @@ +/** + * Sequences mutation store — write operations for the unified DB. + */ + +import { db } from '$lib/data/database'; +import type { LocalSequence } from '../types'; +import type { MoodSequence } from '../types'; + +// Default sequences for demo purposes +const DEFAULT_SEQUENCES: MoodSequence[] = [ + { + id: 'relaxation', + name: 'Relaxation', + items: [ + { moodId: 'breath', duration: 60 }, + { moodId: 'ocean', duration: 60 }, + { moodId: 'lavender', duration: 60 }, + ], + transitionDuration: 5, + }, + { + id: 'focus', + name: 'Focus Flow', + items: [ + { moodId: 'forest', duration: 120 }, + { moodId: 'northern-lights', duration: 120 }, + ], + transitionDuration: 10, + }, + { + id: 'party', + name: 'Party Mode', + items: [ + { moodId: 'disco', duration: 30 }, + { moodId: 'rave', duration: 30 }, + { moodId: 'police', duration: 15 }, + ], + transitionDuration: 2, + }, +]; + +function createSequencesStore() { + let sequences = $state([...DEFAULT_SEQUENCES]); + let customSequences = $state([]); + let activeSequence = $state(null); + let currentItemIndex = $state(0); + let isPlaying = $state(false); + + // Load from localStorage on init + if (typeof window !== 'undefined') { + const saved = localStorage.getItem('moodlit-sequences'); + if (saved) { + try { + const parsed = JSON.parse(saved); + if (parsed.customSequences) customSequences = parsed.customSequences; + } catch (e) { + console.error('Failed to load sequences from localStorage', e); + } + } + } + + function persist() { + if (typeof window !== 'undefined') { + localStorage.setItem('moodlit-sequences', JSON.stringify({ customSequences })); + } + } + + return { + get sequences() { + return [...sequences, ...customSequences]; + }, + get customSequences() { + return customSequences; + }, + get activeSequence() { + return activeSequence; + }, + get currentItemIndex() { + return currentItemIndex; + }, + get isPlaying() { + return isPlaying; + }, + + addSequence(sequence: MoodSequence) { + customSequences = [...customSequences, { ...sequence, isCustom: true }]; + persist(); + }, + + updateSequence(id: string, updates: Partial) { + customSequences = customSequences.map((s) => (s.id === id ? { ...s, ...updates } : s)); + persist(); + }, + + removeSequence(id: string) { + customSequences = customSequences.filter((s) => s.id !== id); + persist(); + }, + + playSequence(sequence: MoodSequence) { + activeSequence = sequence; + currentItemIndex = 0; + isPlaying = true; + }, + + stopSequence() { + activeSequence = null; + currentItemIndex = 0; + isPlaying = false; + }, + + nextItem() { + if (activeSequence && currentItemIndex < activeSequence.items.length - 1) { + currentItemIndex++; + } else { + currentItemIndex = 0; + } + }, + + previousItem() { + if (currentItemIndex > 0) { + currentItemIndex--; + } + }, + + togglePlay() { + isPlaying = !isPlaying; + }, + + // IndexedDB mutation methods + async createSequence(data: { name: string; moodIds: string[]; duration: number }) { + await db.table('sequences').add({ + id: crypto.randomUUID(), + name: data.name, + moodIds: data.moodIds, + duration: data.duration, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + }, + + async deleteSequence(id: string) { + await db.table('sequences').update(id, { + deletedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + }, + }; +} + +export const sequencesStore = createSequencesStore(); diff --git a/apps/manacore/apps/web/src/lib/modules/moodlit/types.ts b/apps/manacore/apps/web/src/lib/modules/moodlit/types.ts new file mode 100644 index 000000000..cd0d4edf6 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/moodlit/types.ts @@ -0,0 +1,109 @@ +/** + * Moodlit module types for the unified app. + */ + +import type { BaseRecord } from '@manacore/local-store'; + +// Animation types available for moods +export type AnimationType = + | 'gradient' + | 'pulse' + | 'wave' + | 'flash' + | 'sos' + | 'candle' + | 'police' + | 'warning' + | 'disco' + | 'thunder' + | 'breath' + | 'rave' + | 'scanner' + | 'matrix' + | 'sunrise' + | 'sunset' + | 'aurora' + | 'fire' + | 'ocean' + | 'forest' + | 'sparkle'; + +export interface LocalMood extends BaseRecord { + name: string; + colors: string[]; + animation: string; + isDefault: boolean; +} + +export interface LocalSequence extends BaseRecord { + name: string; + moodIds: string[]; + duration: number; +} + +// Mood interface (UI-facing) +export interface Mood { + id: string; + name: string; + colors: string[]; + animationType: AnimationType; + isCustom?: boolean; + order?: number; + createdAt?: string; +} + +// Sequence item (mood with duration) +export interface MoodSequenceItem { + moodId: string; + duration: number; // seconds +} + +// Mood sequence +export interface MoodSequence { + id: string; + name: string; + items: MoodSequenceItem[]; + transitionDuration: number; // 2, 5, or 10 seconds + isCustom?: boolean; +} + +// Settings +export interface MoodSettings { + animationSpeed: 'slow' | 'normal' | 'fast'; + brightness: number; // 0-100 + autoTimer: number; // 0 = off, else minutes + autoMoodSwitch: boolean; + autoMoodSwitchInterval: number; // minutes +} + +// Animation metadata for UI +export interface AnimationInfo { + id: AnimationType; + name: string; + description: string; +} + +// Available animations with descriptions +export const ANIMATIONS: AnimationInfo[] = [ + { id: 'gradient', name: 'Gradient', description: 'Smooth color gradient' }, + { id: 'pulse', name: 'Pulse', description: 'Breathing opacity effect' }, + { id: 'wave', name: 'Wave', description: 'Smooth wave oscillation' }, + { id: 'breath', name: 'Breath', description: '4-second breathing cycle' }, + { id: 'aurora', name: 'Aurora', description: 'Northern lights effect' }, + { id: 'fire', name: 'Fire', description: 'Warm flickering flames' }, + { id: 'candle', name: 'Candle', description: 'Soft candlelight flicker' }, + { id: 'ocean', name: 'Ocean', description: 'Calm ocean waves' }, + { id: 'forest', name: 'Forest', description: 'Peaceful forest ambience' }, + { id: 'thunder', name: 'Thunder', description: 'Random lightning flashes' }, + { id: 'sparkle', name: 'Sparkle', description: 'Twinkling star effect' }, + { id: 'sunrise', name: 'Sunrise', description: 'Slow warming colors' }, + { id: 'sunset', name: 'Sunset', description: 'Evening color transition' }, + { id: 'disco', name: 'Disco', description: 'Fast color cycling' }, + { id: 'rave', name: 'Rave', description: 'Very fast chaotic colors' }, + { id: 'scanner', name: 'Scanner', description: 'Light wave sweep' }, + { id: 'matrix', name: 'Matrix', description: 'Digital green blinking' }, + { id: 'flash', name: 'Flash', description: 'Quick white flashes' }, + { id: 'sos', name: 'SOS', description: 'Morse code pattern' }, + { id: 'police', name: 'Police', description: 'Red/blue alternating' }, + { id: 'warning', name: 'Warning', description: 'Blinking orange/yellow' }, +]; diff --git a/apps/manacore/apps/web/src/lib/modules/skilltree/collections.ts b/apps/manacore/apps/web/src/lib/modules/skilltree/collections.ts new file mode 100644 index 000000000..cc4af32d1 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/skilltree/collections.ts @@ -0,0 +1,113 @@ +/** + * SkillTree module — collection accessors and guest seed data. + */ + +import { db } from '$lib/data/database'; +import type { LocalSkill, LocalActivity, LocalAchievement } from './types'; + +// ─── Collection Accessors ────────────────────────────────── + +export const skillTable = db.table('skills'); +export const activityTable = db.table('activities'); +export const achievementTable = db.table('achievements'); + +// ─── Guest Seed ──────────────────────────────────────────── + +const DEMO_CODING_ID = 'demo-coding'; +const DEMO_FITNESS_ID = 'demo-fitness'; +const DEMO_CREATIVE_ID = 'demo-creative'; + +export const SKILLTREE_GUEST_SEED = { + skills: [ + { + id: DEMO_CODING_ID, + name: 'Programmieren', + description: 'Software-Entwicklung und Coding-Skills', + branch: 'intellect' as const, + icon: '💻', + currentXp: 250, + totalXp: 250, + level: 1, + }, + { + id: DEMO_FITNESS_ID, + name: 'Fitness', + description: 'Körperliche Fitness und Training', + branch: 'body' as const, + icon: '💪', + currentXp: 120, + totalXp: 120, + level: 1, + }, + { + id: DEMO_CREATIVE_ID, + name: 'Zeichnen', + description: 'Illustration, Skizzen und visuelles Denken', + branch: 'creativity' as const, + icon: '🎨', + currentXp: 60, + totalXp: 60, + level: 0, + }, + ], + activities: [ + { + id: 'activity-1', + skillId: DEMO_CODING_ID, + xpEarned: 100, + description: 'TypeScript-Projekt aufgesetzt', + duration: 60, + timestamp: new Date(Date.now() - 4 * 24 * 60 * 60 * 1000).toISOString(), + }, + { + id: 'activity-2', + skillId: DEMO_FITNESS_ID, + xpEarned: 50, + description: '5 km Joggen im Park', + duration: 35, + timestamp: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(), + }, + { + id: 'activity-3', + skillId: DEMO_CODING_ID, + xpEarned: 100, + description: 'REST API mit Hono gebaut', + duration: 90, + timestamp: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), + }, + { + id: 'activity-4', + skillId: DEMO_CREATIVE_ID, + xpEarned: 60, + description: 'Erste Skizzen mit Procreate', + duration: 45, + timestamp: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), + }, + { + id: 'activity-5', + skillId: DEMO_FITNESS_ID, + xpEarned: 70, + description: 'Krafttraining — Oberkörper', + duration: 50, + timestamp: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(), + }, + { + id: 'activity-6', + skillId: DEMO_CODING_ID, + xpEarned: 50, + description: 'Unit Tests geschrieben', + duration: 30, + timestamp: new Date(Date.now() - 12 * 60 * 60 * 1000).toISOString(), + }, + ], + achievements: [ + { + id: 'achievement-1', + key: 'first-skill', + name: 'Erste Schritte', + description: 'Deinen ersten Skill erstellt', + icon: '🌱', + unlockedAt: new Date(Date.now() - 4 * 24 * 60 * 60 * 1000).toISOString(), + }, + ], +}; diff --git a/apps/manacore/apps/web/src/lib/modules/skilltree/components/AchievementCard.svelte b/apps/manacore/apps/web/src/lib/modules/skilltree/components/AchievementCard.svelte new file mode 100644 index 000000000..0d84de771 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/skilltree/components/AchievementCard.svelte @@ -0,0 +1,93 @@ + + +
+ +
+ + {rarity.name} + +
+ +
+ +
+ {#if achievement.unlocked} + + {:else} + + {/if} +
+ +
+ +

+ {achievement.name} +

+ + +

+ {achievement.description} +

+ + + {#if !achievement.unlocked} +
+
+ {achievement.progress} / {achievement.condition.threshold} + {progressPercent}% +
+
+
+
+
+ {/if} + + +
+ + + +{achievement.xpReward} XP + + {#if achievement.unlocked && achievement.unlockedAt} + + {new Date(achievement.unlockedAt).toLocaleDateString('de-DE')} + + {/if} +
+
+
+
diff --git a/apps/manacore/apps/web/src/lib/modules/skilltree/components/AchievementCelebration.svelte b/apps/manacore/apps/web/src/lib/modules/skilltree/components/AchievementCelebration.svelte new file mode 100644 index 000000000..c0da912d3 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/skilltree/components/AchievementCelebration.svelte @@ -0,0 +1,151 @@ + + + + + diff --git a/apps/manacore/apps/web/src/lib/modules/skilltree/components/AddSkillModal.svelte b/apps/manacore/apps/web/src/lib/modules/skilltree/components/AddSkillModal.svelte new file mode 100644 index 000000000..b0c199578 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/skilltree/components/AddSkillModal.svelte @@ -0,0 +1,126 @@ + + + diff --git a/apps/manacore/apps/web/src/lib/modules/skilltree/components/AddXpModal.svelte b/apps/manacore/apps/web/src/lib/modules/skilltree/components/AddXpModal.svelte new file mode 100644 index 000000000..6e646f981 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/skilltree/components/AddXpModal.svelte @@ -0,0 +1,171 @@ + + + diff --git a/apps/manacore/apps/web/src/lib/modules/skilltree/components/EditSkillModal.svelte b/apps/manacore/apps/web/src/lib/modules/skilltree/components/EditSkillModal.svelte new file mode 100644 index 000000000..081bbcc6c --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/skilltree/components/EditSkillModal.svelte @@ -0,0 +1,192 @@ + + + diff --git a/apps/manacore/apps/web/src/lib/modules/skilltree/components/LevelUpCelebration.svelte b/apps/manacore/apps/web/src/lib/modules/skilltree/components/LevelUpCelebration.svelte new file mode 100644 index 000000000..f32b4a41b --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/skilltree/components/LevelUpCelebration.svelte @@ -0,0 +1,176 @@ + + + + + diff --git a/apps/manacore/apps/web/src/lib/modules/skilltree/components/SkillCard.svelte b/apps/manacore/apps/web/src/lib/modules/skilltree/components/SkillCard.svelte new file mode 100644 index 000000000..232e4debd --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/skilltree/components/SkillCard.svelte @@ -0,0 +1,115 @@ + + +
+ +
+ + +
+
+

{skill.name}

+

{branchInfo.name}

+
+
+ {#each Array(skill.level) as _, i} + + {/each} + {#each Array(5 - skill.level) as _, i} + + {/each} +
+
+ + +
+ + Lvl {skill.level} - {levelName} + +
+ + +
+
+ XP + + {skill.totalXp.toLocaleString()} + {#if !isMaxLevel} + / {nextLevelXp.toLocaleString()} + {/if} + +
+
+
+
+
+ + + {#if skill.description} +

{skill.description}

+ {/if} + + +
+ + + +
+
diff --git a/apps/manacore/apps/web/src/lib/modules/skilltree/components/SkillTemplates.svelte b/apps/manacore/apps/web/src/lib/modules/skilltree/components/SkillTemplates.svelte new file mode 100644 index 000000000..01a2fcee0 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/skilltree/components/SkillTemplates.svelte @@ -0,0 +1,210 @@ + + + diff --git a/apps/manacore/apps/web/src/lib/modules/skilltree/components/StatsOverview.svelte b/apps/manacore/apps/web/src/lib/modules/skilltree/components/StatsOverview.svelte new file mode 100644 index 000000000..1a46c7378 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/skilltree/components/StatsOverview.svelte @@ -0,0 +1,105 @@ + + +
+ +
+
+
+ +
+
+

Gesamt-XP

+

+ {userStats.totalXp.toLocaleString()} +

+
+
+
+ + +
+
+
+ +
+
+

Skills

+

+ {userStats.totalSkills} +

+
+
+
+ + +
+
+
+ +
+
+

Hochstes Level

+

+ {userStats.highestLevel} +

+
+
+
+ + +
+
+
+ +
+
+

Streak

+

+ {userStats.streakDays} Tage +

+
+
+
+ + + +
+
+ +
+
+

Achievements

+

+ {achievementStats.unlocked}/{achievementStats.total} +

+
+
+
+
diff --git a/apps/manacore/apps/web/src/lib/modules/skilltree/index.ts b/apps/manacore/apps/web/src/lib/modules/skilltree/index.ts new file mode 100644 index 000000000..a901e096f --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/skilltree/index.ts @@ -0,0 +1,59 @@ +/** + * SkillTree module — barrel exports. + */ + +export { skillStore } from './stores/skills.svelte'; +export { + achievementStore, + buildAchievementStatus, + getUnlockedAchievements, + getLockedAchievements, + getAchievementsByCategory, + getAchievementStats, + getCompletionPercentage, +} from './stores/achievements.svelte'; +export { + useAllSkills, + useAllActivities, + useAllAchievements, + toSkill, + toActivity, + groupByBranch, + getTopSkills, + getRecentActivities, + computeBranchStats, + calculateStreak, + computeUserStats, + filterByBranch, + getSkillById, + getSkillActivities, +} from './queries'; +export { skillTable, activityTable, achievementTable, SKILLTREE_GUEST_SEED } from './collections'; +export type { + LocalSkill, + LocalActivity, + LocalAchievement, + Skill, + Activity, + SkillBranch, + UserStats, + AchievementCategory, + AchievementRarity, + AchievementCondition, + Achievement, + AchievementWithStatus, + AchievementUnlockResult, +} from './types'; +export { + LEVEL_THRESHOLDS, + LEVEL_NAMES, + BRANCH_INFO, + RARITY_INFO, + ACHIEVEMENT_CATEGORY_INFO, + ACHIEVEMENT_DEFINITIONS, + calculateLevel, + xpForNextLevel, + xpProgress, + createDefaultSkill, + createActivity, +} from './types'; diff --git a/apps/manacore/apps/web/src/lib/modules/skilltree/queries.ts b/apps/manacore/apps/web/src/lib/modules/skilltree/queries.ts new file mode 100644 index 000000000..f417ddb6e --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/skilltree/queries.ts @@ -0,0 +1,181 @@ +/** + * Reactive Queries & Pure Helpers for SkillTree + * + * 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 { liveQuery } from 'dexie'; +import { db } from '$lib/data/database'; +import type { LocalSkill, LocalActivity, LocalAchievement } from './types'; +import type { Skill, Activity, SkillBranch, UserStats } from './types'; +import { BRANCH_INFO } from './types'; + +// ─── Type Converters ─────────────────────────────────────── + +export function toSkill(local: LocalSkill): Skill { + return { + id: local.id, + name: local.name, + description: local.description, + branch: local.branch, + parentId: local.parentId ?? null, + icon: local.icon, + color: local.color ?? null, + currentXp: local.currentXp, + totalXp: local.totalXp, + level: local.level, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +export function toActivity(local: LocalActivity): Activity { + return { + id: local.id, + skillId: local.skillId, + xpEarned: local.xpEarned, + description: local.description, + duration: local.duration ?? null, + timestamp: local.timestamp, + }; +} + +// ─── Live Queries (call during component init) ───────────── + +/** All skills, auto-updates on any change. */ +export function useAllSkills() { + return liveQuery(async () => { + const locals = await db.table('skills').toArray(); + return locals.filter((s) => !s.deletedAt).map(toSkill); + }); +} + +/** All activities, auto-updates on any change. */ +export function useAllActivities() { + return liveQuery(async () => { + const locals = await db.table('activities').toArray(); + return locals.filter((a) => !a.deletedAt).map(toActivity); + }); +} + +/** All achievements (raw local records), auto-updates on any change. */ +export function useAllAchievements() { + return liveQuery(async () => { + const locals = await db.table('achievements').toArray(); + return locals.filter((a) => !a.deletedAt); + }); +} + +// ─── Pure Filter/Helper Functions (for $derived) ────────── + +/** Group skills by branch. */ +export function groupByBranch(skills: Skill[]): Record { + const grouped: Record = { + intellect: [], + body: [], + creativity: [], + social: [], + practical: [], + mindset: [], + custom: [], + }; + for (const skill of skills) { + grouped[skill.branch].push(skill); + } + return grouped; +} + +/** Get top N skills by total XP. */ +export function getTopSkills(skills: Skill[], n = 5): Skill[] { + return [...skills].sort((a, b) => b.totalXp - a.totalXp).slice(0, n); +} + +/** Get recent N activities sorted by timestamp descending. */ +export function getRecentActivities(activities: Activity[], n = 10): Activity[] { + return [...activities] + .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) + .slice(0, n); +} + +/** Compute branch-level stats. */ +export function computeBranchStats( + skills: Skill[] +): Record { + const stats = {} as Record; + for (const branch of Object.keys(BRANCH_INFO) as SkillBranch[]) { + const branchSkills = skills.filter((s) => s.branch === branch); + stats[branch] = { + count: branchSkills.length, + totalXp: branchSkills.reduce((sum, s) => sum + s.totalXp, 0), + avgLevel: + branchSkills.length > 0 + ? branchSkills.reduce((sum, s) => sum + s.level, 0) / branchSkills.length + : 0, + }; + } + return stats; +} + +/** Calculate activity streak in days. */ +export function calculateStreak(activities: Activity[]): number { + if (activities.length === 0) return 0; + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const sortedDates = activities + .map((a) => { + const d = new Date(a.timestamp); + d.setHours(0, 0, 0, 0); + return d.getTime(); + }) + .filter((v, i, a) => a.indexOf(v) === i) + .sort((a, b) => b - a); + + let streak = 0; + let expectedDate = today.getTime(); + + for (const date of sortedDates) { + if (date === expectedDate || date === expectedDate - 86400000) { + streak++; + expectedDate = date - 86400000; + } else if (date < expectedDate - 86400000) { + break; + } + } + + return streak; +} + +/** Compute aggregate user stats from skills and activities. */ +export function computeUserStats(skills: Skill[], activities: Activity[]): UserStats { + const sortedActivities = [...activities].sort( + (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() + ); + + return { + totalXp: skills.reduce((sum, s) => sum + s.totalXp, 0), + totalSkills: skills.length, + highestLevel: skills.reduce((max, s) => Math.max(max, s.level), 0), + streakDays: calculateStreak(activities), + lastActivityDate: sortedActivities.length > 0 ? sortedActivities[0].timestamp : null, + }; +} + +/** Filter skills by branch (or return all if 'all'). */ +export function filterByBranch(skills: Skill[], branch: SkillBranch | 'all'): Skill[] { + if (branch === 'all') return skills; + return skills.filter((s) => s.branch === branch); +} + +/** Find a skill by ID. */ +export function getSkillById(skills: Skill[], id: string): Skill | undefined { + return skills.find((s) => s.id === id); +} + +/** Get all activities for a specific skill. */ +export function getSkillActivities(activities: Activity[], skillId: string): Activity[] { + return activities.filter((a) => a.skillId === skillId); +} diff --git a/apps/manacore/apps/web/src/lib/modules/skilltree/stores/achievements.svelte.ts b/apps/manacore/apps/web/src/lib/modules/skilltree/stores/achievements.svelte.ts new file mode 100644 index 000000000..21159e78a --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/skilltree/stores/achievements.svelte.ts @@ -0,0 +1,229 @@ +/** + * Achievements Store — Write Actions + Unlock Queue + * + * Reads are handled by liveQuery hooks in queries.ts. + * This store handles achievement checking logic and the unlock celebration queue. + */ + +import { db } from '$lib/data/database'; +import type { + AchievementWithStatus, + AchievementUnlockResult, + AchievementCategory, + Skill, + Activity, + UserStats, + LocalAchievement, +} from '../types'; +import { ACHIEVEMENT_DEFINITIONS } from '../types'; + +// Queue of recently unlocked achievements to show celebrations +let unlockQueue = $state([]); + +// ─── Derived helpers (pure functions for consumers) ────────── + +/** Build achievement status list from stored records and definitions. */ +export function buildAchievementStatus(stored: LocalAchievement[]): AchievementWithStatus[] { + if (stored.length === 0) { + return ACHIEVEMENT_DEFINITIONS.map((def) => ({ + ...def, + unlocked: false, + unlockedAt: null, + progress: 0, + })); + } + return ACHIEVEMENT_DEFINITIONS.map((def) => { + const found = stored.find((s) => s.key === def.id || s.id === def.id); + return { + ...def, + unlocked: found?.unlockedAt ? true : false, + unlockedAt: found?.unlockedAt || null, + progress: 0, + }; + }); +} + +export function getUnlockedAchievements( + achievements: AchievementWithStatus[] +): AchievementWithStatus[] { + return achievements.filter((a) => a.unlocked); +} + +export function getLockedAchievements( + achievements: AchievementWithStatus[] +): AchievementWithStatus[] { + return achievements.filter((a) => !a.unlocked); +} + +export function getAchievementsByCategory( + achievements: AchievementWithStatus[] +): Record { + const grouped: Record = { + xp: [], + skills: [], + levels: [], + activities: [], + streak: [], + branches: [], + special: [], + }; + for (const a of achievements) { + grouped[a.category].push(a); + } + return grouped; +} + +export function getAchievementStats(achievements: AchievementWithStatus[]): { + total: number; + unlocked: number; +} { + return { + total: achievements.length, + unlocked: achievements.filter((a) => a.unlocked).length, + }; +} + +export function getCompletionPercentage(achievements: AchievementWithStatus[]): number { + if (achievements.length === 0) return 0; + return Math.round((achievements.filter((a) => a.unlocked).length / achievements.length) * 100); +} + +// ─── Actions ───────────────────────────────────────────────── + +async function seedIfEmpty() { + const stored = await db.table('achievements').toArray(); + const active = stored.filter((a) => !a.deletedAt); + if (active.length === 0) { + for (const def of ACHIEVEMENT_DEFINITIONS) { + await db.table('achievements').add({ + id: def.id, + key: def.id, + name: def.name, + description: def.description, + icon: def.icon, + unlockedAt: '', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + } + } +} + +/** + * Check achievements locally (offline mode). + * Called after skill/activity changes. + */ +async function checkLocal(context: { + skills: Skill[]; + activities: Activity[]; + userStats: UserStats; + lastActivityXp?: number; +}): Promise { + const { skills, activities: allActivities, userStats: stats, lastActivityXp } = context; + + // Get current achievements from DB + const stored = await db.table('achievements').toArray(); + const active = stored.filter((a) => !a.deletedAt); + const achievements = buildAchievementStatus(active); + + const uniqueBranches = new Set(skills.map((s) => s.branch).filter((b) => b !== 'custom')); + + const mainBranches = ['intellect', 'body', 'creativity', 'social', 'practical', 'mindset']; + const branchMaxLevels = new Map(); + for (const branch of mainBranches) { + const branchSkills = skills.filter((s) => s.branch === branch); + if (branchSkills.length > 0) { + branchMaxLevels.set(branch, Math.max(...branchSkills.map((s) => s.level))); + } + } + const allBranchesMinLevel = + branchMaxLevels.size === 6 ? Math.min(...branchMaxLevels.values()) : 0; + + const userData = { + totalXp: stats.totalXp, + totalSkills: skills.length, + highestLevel: stats.highestLevel, + totalActivities: allActivities.length, + streakDays: stats.streakDays, + uniqueBranches: uniqueBranches.size, + allBranchesMinLevel, + lastActivityXp: lastActivityXp ?? 0, + }; + + const newlyUnlocked: AchievementUnlockResult[] = []; + + for (const a of achievements) { + if (a.unlocked) continue; + + const condition = a.condition; + let current = 0; + let met = false; + + switch (condition.type) { + case 'total_xp': + current = userData.totalXp; + met = current >= condition.threshold; + break; + case 'total_skills': + current = userData.totalSkills; + met = current >= condition.threshold; + break; + case 'highest_level': + current = userData.highestLevel; + met = current >= condition.threshold; + break; + case 'total_activities': + current = userData.totalActivities; + met = current >= condition.threshold; + break; + case 'streak_days': + current = userData.streakDays; + met = current >= condition.threshold; + break; + case 'unique_branches': + current = userData.uniqueBranches; + met = current >= condition.threshold; + break; + case 'single_activity_xp': + current = userData.lastActivityXp; + met = current >= condition.threshold; + break; + case 'all_branches_min_level': + current = userData.allBranchesMinLevel; + met = current >= condition.threshold; + break; + } + + if (met) { + const now = new Date().toISOString(); + await db.table('achievements').update(a.id, { + unlockedAt: now, + updatedAt: now, + }); + newlyUnlocked.push({ achievement: a, xpReward: a.xpReward }); + } + } + + if (newlyUnlocked.length > 0) { + unlockQueue = [...unlockQueue, ...newlyUnlocked]; + } + + return newlyUnlocked; +} + +function popUnlockQueue(): AchievementUnlockResult | null { + if (unlockQueue.length === 0) return null; + const [first, ...rest] = unlockQueue; + unlockQueue = rest; + return first; +} + +export const achievementStore = { + get unlockQueue() { + return unlockQueue; + }, + + seedIfEmpty, + checkLocal, + popUnlockQueue, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/skilltree/stores/skills.svelte.ts b/apps/manacore/apps/web/src/lib/modules/skilltree/stores/skills.svelte.ts new file mode 100644 index 000000000..f88774929 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/skilltree/stores/skills.svelte.ts @@ -0,0 +1,112 @@ +/** + * Skills Store — Write Actions Only + * + * Reads are handled by liveQuery hooks in queries.ts. + * This store only exposes mutation actions that write to IndexedDB. + */ + +import { db } from '$lib/data/database'; +import type { Skill } from '../types'; +import { calculateLevel, createDefaultSkill, createActivity } from '../types'; +import type { LocalSkill, LocalActivity } from '../types'; +import { SkillTreeEvents } from '@manacore/shared-utils/analytics'; + +// ─── Actions ───────────────────────────────────────────────── + +async function addSkill(data: Partial): Promise { + const skill = createDefaultSkill(data); + const localSkill: LocalSkill = { + id: skill.id, + name: skill.name, + description: skill.description, + branch: skill.branch, + parentId: skill.parentId, + icon: skill.icon, + color: skill.color, + currentXp: skill.currentXp, + totalXp: skill.totalXp, + level: skill.level, + }; + await db.table('skills').add({ + ...localSkill, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + SkillTreeEvents.skillCreated(data.branch || 'custom'); + return skill; +} + +async function updateSkill(id: string, updates: Partial): Promise { + const localUpdates: Partial & { updatedAt: string } = { + updatedAt: new Date().toISOString(), + }; + if (updates.name !== undefined) localUpdates.name = updates.name; + if (updates.description !== undefined) localUpdates.description = updates.description; + if (updates.branch !== undefined) localUpdates.branch = updates.branch; + if (updates.parentId !== undefined) localUpdates.parentId = updates.parentId; + if (updates.icon !== undefined) localUpdates.icon = updates.icon; + if (updates.color !== undefined) localUpdates.color = updates.color; + + await db.table('skills').update(id, localUpdates); +} + +async function deleteSkill(id: string): Promise { + const now = new Date().toISOString(); + // Soft-delete all activities for this skill + const skillActivities = await db + .table('activities') + .where('skillId') + .equals(id) + .toArray(); + for (const a of skillActivities) { + await db.table('activities').update(a.id, { deletedAt: now, updatedAt: now }); + } + await db.table('skills').update(id, { deletedAt: now, updatedAt: now }); + SkillTreeEvents.skillDeleted(); +} + +async function addXp( + skillId: string, + xp: number, + description: string, + duration?: number +): Promise<{ leveledUp: boolean; newLevel: number }> { + const skill = await db.table('skills').get(skillId); + if (!skill) return { leveledUp: false, newLevel: 0 }; + + const newTotalXp = skill.totalXp + xp; + const newCurrentXp = skill.currentXp + xp; + const newLevel = calculateLevel(newTotalXp); + const leveledUp = newLevel > skill.level; + + await db.table('skills').update(skillId, { + totalXp: newTotalXp, + currentXp: newCurrentXp, + level: newLevel, + updatedAt: new Date().toISOString(), + }); + + const activity = createActivity(skillId, xp, description, duration); + await db.table('activities').add({ + id: activity.id, + skillId: activity.skillId, + xpEarned: activity.xpEarned, + description: activity.description, + duration: activity.duration, + timestamp: activity.timestamp, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + SkillTreeEvents.xpAdded(xp, leveledUp); + + return { leveledUp, newLevel }; +} + +// Export store (write-only actions) +export const skillStore = { + addSkill, + updateSkill, + deleteSkill, + addXp, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/skilltree/types.ts b/apps/manacore/apps/web/src/lib/modules/skilltree/types.ts new file mode 100644 index 000000000..4bc04a790 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/skilltree/types.ts @@ -0,0 +1,588 @@ +/** + * SkillTree module types for the unified ManaCore app. + */ + +import type { BaseRecord } from '@manacore/local-store'; + +// ─── Local Record Types (IndexedDB) ────────────────────── + +export interface LocalSkill extends BaseRecord { + name: string; + description: string; + branch: 'intellect' | 'body' | 'creativity' | 'social' | 'practical' | 'mindset' | 'custom'; + parentId?: string | null; + icon: string; + color?: string | null; + currentXp: number; + totalXp: number; + level: number; +} + +export interface LocalActivity extends BaseRecord { + skillId: string; + xpEarned: number; + description: string; + duration?: number | null; + timestamp: string; +} + +export interface LocalAchievement extends BaseRecord { + key: string; + name: string; + description: string; + icon: string; + unlockedAt: string; +} + +// ─── Domain Types ───────────────────────────────────────── + +export type SkillBranch = + | 'intellect' + | 'body' + | 'creativity' + | 'social' + | 'practical' + | 'mindset' + | 'custom'; + +export interface Skill { + id: string; + name: string; + description: string; + branch: SkillBranch; + parentId: string | null; + icon: string; + color: string | null; + currentXp: number; + totalXp: number; + level: number; + createdAt: string; + updatedAt: string; +} + +export interface Activity { + id: string; + skillId: string; + xpEarned: number; + description: string; + duration: number | null; // minutes + timestamp: string; +} + +export interface UserStats { + totalXp: number; + totalSkills: number; + highestLevel: number; + streakDays: number; + lastActivityDate: string | null; +} + +// ─── Level System ───────────────────────────────────────── + +export const LEVEL_THRESHOLDS = [0, 100, 500, 1500, 4000, 10000] as const; + +export const LEVEL_NAMES = [ + 'Unbekannt', + 'Anfänger', + 'Fortgeschritten', + 'Kompetent', + 'Experte', + 'Meister', +] as const; + +export const BRANCH_INFO: Record< + SkillBranch, + { name: string; icon: string; color: string; description: string } +> = { + intellect: { + name: 'Intellekt', + icon: 'brain', + color: 'var(--color-branch-intellect)', + description: 'Wissen, Sprachen, Wissenschaft', + }, + body: { + name: 'Körper', + icon: 'dumbbell', + color: 'var(--color-branch-body)', + description: 'Fitness, Sport, Gesundheit', + }, + creativity: { + name: 'Kreativität', + icon: 'palette', + color: 'var(--color-branch-creativity)', + description: 'Kunst, Musik, Schreiben', + }, + social: { + name: 'Sozial', + icon: 'users', + color: 'var(--color-branch-social)', + description: 'Kommunikation, Leadership, Empathie', + }, + practical: { + name: 'Praktisch', + icon: 'wrench', + color: 'var(--color-branch-practical)', + description: 'Handwerk, Kochen, Technologie', + }, + mindset: { + name: 'Mindset', + icon: 'heart', + color: 'var(--color-branch-mindset)', + description: 'Meditation, Fokus, Resilienz', + }, + custom: { + name: 'Eigene', + icon: 'star', + color: 'var(--color-primary)', + description: 'Eigene Kategorien', + }, +}; + +// ─── Helper Functions ───────────────────────────────────── + +export function calculateLevel(xp: number): number { + for (let i = LEVEL_THRESHOLDS.length - 1; i >= 0; i--) { + if (xp >= LEVEL_THRESHOLDS[i]) { + return i; + } + } + return 0; +} + +export function xpForNextLevel(currentLevel: number): number { + if (currentLevel >= LEVEL_THRESHOLDS.length - 1) { + return Infinity; + } + return LEVEL_THRESHOLDS[currentLevel + 1]; +} + +export function xpProgress(xp: number, level: number): number { + if (level >= LEVEL_THRESHOLDS.length - 1) { + return 100; + } + const currentThreshold = LEVEL_THRESHOLDS[level]; + const nextThreshold = LEVEL_THRESHOLDS[level + 1]; + const progress = ((xp - currentThreshold) / (nextThreshold - currentThreshold)) * 100; + return Math.min(100, Math.max(0, progress)); +} + +export function createDefaultSkill(partial: Partial = {}): Skill { + const now = new Date().toISOString(); + return { + id: crypto.randomUUID(), + name: '', + description: '', + branch: 'custom', + parentId: null, + icon: 'star', + color: null, + currentXp: 0, + totalXp: 0, + level: 0, + createdAt: now, + updatedAt: now, + ...partial, + }; +} + +export function createActivity( + skillId: string, + xpEarned: number, + description: string, + duration?: number +): Activity { + return { + id: crypto.randomUUID(), + skillId, + xpEarned, + description, + duration: duration ?? null, + timestamp: new Date().toISOString(), + }; +} + +// ─── Achievement Types ──────────────────────────────────── + +export type AchievementCategory = + | 'xp' + | 'skills' + | 'levels' + | 'activities' + | 'streak' + | 'branches' + | 'special'; + +export type AchievementRarity = 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'; + +export interface AchievementCondition { + type: string; + threshold: number; +} + +export interface Achievement { + id: string; + name: string; + description: string; + icon: string; + category: AchievementCategory; + rarity: AchievementRarity; + xpReward: number; + sortOrder: number; + condition: AchievementCondition; +} + +export interface AchievementWithStatus extends Achievement { + unlocked: boolean; + unlockedAt: string | null; + progress: number; +} + +export interface AchievementUnlockResult { + achievement: Achievement; + xpReward: number; +} + +export const RARITY_INFO: Record< + AchievementRarity, + { name: string; color: string; bgColor: string; borderColor: string } +> = { + common: { + name: 'Gewöhnlich', + color: 'text-gray-300', + bgColor: 'bg-gray-700/50', + borderColor: 'border-gray-600', + }, + uncommon: { + name: 'Ungewöhnlich', + color: 'text-green-400', + bgColor: 'bg-green-900/30', + borderColor: 'border-green-700', + }, + rare: { + name: 'Selten', + color: 'text-blue-400', + bgColor: 'bg-blue-900/30', + borderColor: 'border-blue-700', + }, + epic: { + name: 'Episch', + color: 'text-purple-400', + bgColor: 'bg-purple-900/30', + borderColor: 'border-purple-700', + }, + legendary: { + name: 'Legendär', + color: 'text-yellow-400', + bgColor: 'bg-yellow-900/30', + borderColor: 'border-yellow-600', + }, +}; + +export const ACHIEVEMENT_CATEGORY_INFO: Record< + AchievementCategory, + { name: string; icon: string } +> = { + xp: { name: 'Erfahrung', icon: 'star' }, + skills: { name: 'Skills', icon: 'grid' }, + levels: { name: 'Level', icon: 'arrow-up' }, + activities: { name: 'Aktivitäten', icon: 'lightning' }, + streak: { name: 'Streak', icon: 'flame' }, + branches: { name: 'Branches', icon: 'compass' }, + special: { name: 'Speziell', icon: 'trophy' }, +}; + +export const ACHIEVEMENT_DEFINITIONS: Achievement[] = [ + // XP + { + id: 'xp_100', + name: 'Erste Schritte', + description: 'Sammle 100 XP insgesamt', + icon: 'star', + category: 'xp', + rarity: 'common', + xpReward: 10, + sortOrder: 1, + condition: { type: 'total_xp', threshold: 100 }, + }, + { + id: 'xp_1000', + name: 'Tausender-Club', + description: 'Sammle 1.000 XP insgesamt', + icon: 'star', + category: 'xp', + rarity: 'uncommon', + xpReward: 25, + sortOrder: 2, + condition: { type: 'total_xp', threshold: 1000 }, + }, + { + id: 'xp_5000', + name: 'XP-Sammler', + description: 'Sammle 5.000 XP insgesamt', + icon: 'star', + category: 'xp', + rarity: 'rare', + xpReward: 50, + sortOrder: 3, + condition: { type: 'total_xp', threshold: 5000 }, + }, + { + id: 'xp_10000', + name: 'XP-Legende', + description: 'Sammle 10.000 XP insgesamt', + icon: 'crown', + category: 'xp', + rarity: 'epic', + xpReward: 100, + sortOrder: 4, + condition: { type: 'total_xp', threshold: 10000 }, + }, + { + id: 'xp_50000', + name: 'Grenzenlos', + description: 'Sammle 50.000 XP insgesamt', + icon: 'crown', + category: 'xp', + rarity: 'legendary', + xpReward: 250, + sortOrder: 5, + condition: { type: 'total_xp', threshold: 50000 }, + }, + // Skills + { + id: 'skills_1', + name: 'Der Anfang', + description: 'Erstelle deinen ersten Skill', + icon: 'plus', + category: 'skills', + rarity: 'common', + xpReward: 10, + sortOrder: 10, + condition: { type: 'total_skills', threshold: 1 }, + }, + { + id: 'skills_5', + name: 'Vielseitig', + description: 'Erstelle 5 Skills', + icon: 'grid', + category: 'skills', + rarity: 'uncommon', + xpReward: 25, + sortOrder: 11, + condition: { type: 'total_skills', threshold: 5 }, + }, + { + id: 'skills_10', + name: 'Skill-Sammler', + description: 'Erstelle 10 Skills', + icon: 'grid', + category: 'skills', + rarity: 'rare', + xpReward: 50, + sortOrder: 12, + condition: { type: 'total_skills', threshold: 10 }, + }, + { + id: 'skills_20', + name: 'Meister aller Klassen', + description: 'Erstelle 20 Skills', + icon: 'grid', + category: 'skills', + rarity: 'epic', + xpReward: 100, + sortOrder: 13, + condition: { type: 'total_skills', threshold: 20 }, + }, + // Levels + { + id: 'level_1', + name: 'Anfänger', + description: 'Erreiche Level 1 mit einem Skill', + icon: 'arrow-up', + category: 'levels', + rarity: 'common', + xpReward: 15, + sortOrder: 20, + condition: { type: 'highest_level', threshold: 1 }, + }, + { + id: 'level_3', + name: 'Kompetent', + description: 'Erreiche Level 3 mit einem Skill', + icon: 'arrow-up', + category: 'levels', + rarity: 'rare', + xpReward: 50, + sortOrder: 21, + condition: { type: 'highest_level', threshold: 3 }, + }, + { + id: 'level_5', + name: 'Meister', + description: 'Erreiche Level 5 mit einem Skill', + icon: 'crown', + category: 'levels', + rarity: 'legendary', + xpReward: 200, + sortOrder: 22, + condition: { type: 'highest_level', threshold: 5 }, + }, + // Activities + { + id: 'activities_1', + name: 'Erste Aktion', + description: 'Logge deine erste Aktivität', + icon: 'lightning', + category: 'activities', + rarity: 'common', + xpReward: 5, + sortOrder: 30, + condition: { type: 'total_activities', threshold: 1 }, + }, + { + id: 'activities_10', + name: 'Dranbleiber', + description: 'Logge 10 Aktivitäten', + icon: 'lightning', + category: 'activities', + rarity: 'uncommon', + xpReward: 20, + sortOrder: 31, + condition: { type: 'total_activities', threshold: 10 }, + }, + { + id: 'activities_50', + name: 'Fleißig', + description: 'Logge 50 Aktivitäten', + icon: 'lightning', + category: 'activities', + rarity: 'rare', + xpReward: 50, + sortOrder: 32, + condition: { type: 'total_activities', threshold: 50 }, + }, + { + id: 'activities_100', + name: 'Unaufhaltsam', + description: 'Logge 100 Aktivitäten', + icon: 'fire', + category: 'activities', + rarity: 'epic', + xpReward: 100, + sortOrder: 33, + condition: { type: 'total_activities', threshold: 100 }, + }, + { + id: 'activities_500', + name: 'Maschine', + description: 'Logge 500 Aktivitäten', + icon: 'fire', + category: 'activities', + rarity: 'legendary', + xpReward: 250, + sortOrder: 34, + condition: { type: 'total_activities', threshold: 500 }, + }, + // Streak + { + id: 'streak_3', + name: '3-Tage-Streak', + description: 'Halte einen 3-Tage-Streak', + icon: 'flame', + category: 'streak', + rarity: 'common', + xpReward: 15, + sortOrder: 40, + condition: { type: 'streak_days', threshold: 3 }, + }, + { + id: 'streak_7', + name: 'Wochenkrieger', + description: 'Halte einen 7-Tage-Streak', + icon: 'flame', + category: 'streak', + rarity: 'uncommon', + xpReward: 30, + sortOrder: 41, + condition: { type: 'streak_days', threshold: 7 }, + }, + { + id: 'streak_14', + name: 'Zwei-Wochen-Held', + description: 'Halte einen 14-Tage-Streak', + icon: 'flame', + category: 'streak', + rarity: 'rare', + xpReward: 75, + sortOrder: 42, + condition: { type: 'streak_days', threshold: 14 }, + }, + { + id: 'streak_30', + name: 'Monatsmeister', + description: 'Halte einen 30-Tage-Streak', + icon: 'flame', + category: 'streak', + rarity: 'epic', + xpReward: 150, + sortOrder: 43, + condition: { type: 'streak_days', threshold: 30 }, + }, + { + id: 'streak_100', + name: 'Hundert Tage', + description: 'Halte einen 100-Tage-Streak', + icon: 'flame', + category: 'streak', + rarity: 'legendary', + xpReward: 500, + sortOrder: 44, + condition: { type: 'streak_days', threshold: 100 }, + }, + // Branches + { + id: 'branches_3', + name: 'Entdecker', + description: 'Habe Skills in 3 verschiedenen Branches', + icon: 'compass', + category: 'branches', + rarity: 'uncommon', + xpReward: 25, + sortOrder: 50, + condition: { type: 'unique_branches', threshold: 3 }, + }, + { + id: 'branches_all', + name: 'Universalgelehrter', + description: 'Habe Skills in allen 6 Branches', + icon: 'compass', + category: 'branches', + rarity: 'epic', + xpReward: 100, + sortOrder: 51, + condition: { type: 'unique_branches', threshold: 6 }, + }, + // Special + { + id: 'single_xp_100', + name: 'Mammut-Session', + description: 'Verdiene 100+ XP in einer einzelnen Aktivität', + icon: 'zap', + category: 'special', + rarity: 'rare', + xpReward: 25, + sortOrder: 60, + condition: { type: 'single_activity_xp', threshold: 100 }, + }, + { + id: 'all_branches_level_1', + name: 'Allrounder', + description: 'Erreiche Level 1 in allen 6 Branches', + icon: 'shield', + category: 'special', + rarity: 'epic', + xpReward: 150, + sortOrder: 61, + condition: { type: 'all_branches_min_level', threshold: 1 }, + }, +]; diff --git a/apps/manacore/apps/web/src/lib/modules/zitare/collections.ts b/apps/manacore/apps/web/src/lib/modules/zitare/collections.ts new file mode 100644 index 000000000..8b88e7952 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/zitare/collections.ts @@ -0,0 +1,37 @@ +/** + * Zitare module — collection accessors and guest seed data. + */ + +import { db } from '$lib/data/database'; +import type { LocalFavorite, LocalQuoteList } from './types'; + +// ─── Collection Accessors ────────────────────────────────── + +export const favoriteTable = db.table('zitareFavorites'); +export const listTable = db.table('zitareLists'); + +// ─── Guest Seed ──────────────────────────────────────────── + +export const ZITARE_GUEST_SEED = { + zitareFavorites: [ + { id: 'fav-1', quoteId: 'mot-1' }, + { id: 'fav-2', quoteId: 'weis-3' }, + { id: 'fav-3', quoteId: 'mot-7' }, + { id: 'fav-4', quoteId: 'weis-1' }, + { id: 'fav-5', quoteId: 'liebe-1' }, + ], + zitareLists: [ + { + id: 'list-motivation', + name: 'Motivation & Antrieb', + description: 'Zitate die dich voranbringen', + quoteIds: ['mot-1', 'mot-7', 'mot-3'], + }, + { + id: 'list-weisheit', + name: 'Zeitlose Weisheiten', + description: 'Die großen Denker und Dichter', + quoteIds: ['weis-1', 'weis-3', 'weis-5'], + }, + ], +}; diff --git a/apps/manacore/apps/web/src/lib/modules/zitare/components/QuoteCard.svelte b/apps/manacore/apps/web/src/lib/modules/zitare/components/QuoteCard.svelte new file mode 100644 index 000000000..d2bc3faeb --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/zitare/components/QuoteCard.svelte @@ -0,0 +1,177 @@ + + +
+ {#if showCategory} +
+ + {$_(categoryLabels[quote.category])} + +
+ {/if} + +
+ "{quoteText}" +
+ +
+
+

+ — {quote.author} + {#if authorBioText} + + {/if} +

+ {#if showBio && authorBioText} +

{authorBioText}

+ {/if} + {#if showSource && (quote.source || quote.year)} +

+ {#if quote.source} + {quote.source} + {/if} + {#if quote.source && quote.year} + · + {/if} + {#if quote.year} + {quote.year} + {/if} +

+ {/if} +
+ +
+ + + {#if authStore.isAuthenticated} + + {/if} +
+
+
diff --git a/apps/manacore/apps/web/src/lib/modules/zitare/components/SpiralCanvas.svelte b/apps/manacore/apps/web/src/lib/modules/zitare/components/SpiralCanvas.svelte new file mode 100644 index 000000000..74dc513dc --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/zitare/components/SpiralCanvas.svelte @@ -0,0 +1,165 @@ + + +
+ + + {#if hoveredIndex !== null} +
+ Pixel #{hoveredIndex} +
+ {/if} +
+ + diff --git a/apps/manacore/apps/web/src/lib/modules/zitare/index.ts b/apps/manacore/apps/web/src/lib/modules/zitare/index.ts new file mode 100644 index 000000000..0451c7667 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/zitare/index.ts @@ -0,0 +1,21 @@ +/** + * Zitare module — barrel exports. + */ + +export { favoritesStore } from './stores/favorites.svelte'; +export { listsStore } from './stores/lists.svelte'; +export { quotesStore } from './stores/quotes.svelte'; +export { zitareSettings } from './stores/settings.svelte'; +export { spiralStore } from './stores/spiral.svelte'; +export { + useAllFavorites, + useAllLists, + toFavorite, + toQuoteList, + isFavorite, + findFavoriteByQuoteId, + findListById, +} from './queries'; +export type { Favorite, QuoteList } from './queries'; +export { favoriteTable, listTable, ZITARE_GUEST_SEED } from './collections'; +export type { LocalFavorite, LocalQuoteList } from './types'; diff --git a/apps/manacore/apps/web/src/lib/modules/zitare/queries.ts b/apps/manacore/apps/web/src/lib/modules/zitare/queries.ts new file mode 100644 index 000000000..4624c83ce --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/zitare/queries.ts @@ -0,0 +1,83 @@ +/** + * Reactive queries for Zitare — uses Dexie liveQuery on the unified DB. + */ + +import { liveQuery } from 'dexie'; +import { db } from '$lib/data/database'; +import type { LocalFavorite, LocalQuoteList } from './types'; + +// ─── Domain Types ───────────────────────────────────────── + +export interface Favorite { + id: string; + quoteId: string; + createdAt: string; +} + +export interface QuoteList { + id: string; + name: string; + description?: string; + quoteIds: string[]; + createdAt: string; + updatedAt: string; +} + +// ─── Type Converters ────────────────────────────────────── + +export function toFavorite(local: LocalFavorite): Favorite { + return { + id: local.id, + quoteId: local.quoteId, + createdAt: local.createdAt ?? new Date().toISOString(), + }; +} + +export function toQuoteList(local: LocalQuoteList): QuoteList { + return { + id: local.id, + name: local.name, + description: local.description ?? undefined, + quoteIds: local.quoteIds, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +// ─── Live Queries ───────────────────────────────────────── + +/** All favorites. Auto-updates on any change. */ +export function useAllFavorites() { + return liveQuery(async () => { + const locals = await db.table('zitareFavorites').toArray(); + return locals.filter((f) => !f.deletedAt).map(toFavorite); + }); +} + +/** All lists. Auto-updates on any change. */ +export function useAllLists() { + return liveQuery(async () => { + const locals = await db.table('zitareLists').toArray(); + return locals.filter((l) => !l.deletedAt).map(toQuoteList); + }); +} + +// ─── Pure Helper Functions (for $derived) ───────────────── + +/** Check if a quote is in the favorites list. */ +export function isFavorite(favorites: Favorite[], quoteId: string): boolean { + return favorites.some((f) => f.quoteId === quoteId); +} + +/** Find a favorite by quote ID. */ +export function findFavoriteByQuoteId( + favorites: Favorite[], + quoteId: string +): Favorite | undefined { + return favorites.find((f) => f.quoteId === quoteId); +} + +/** Find a list by ID. */ +export function findListById(lists: QuoteList[], listId: string): QuoteList | undefined { + return lists.find((l) => l.id === listId); +} diff --git a/apps/manacore/apps/web/src/lib/modules/zitare/stores/favorites.svelte.ts b/apps/manacore/apps/web/src/lib/modules/zitare/stores/favorites.svelte.ts new file mode 100644 index 000000000..5e2981ebb --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/zitare/stores/favorites.svelte.ts @@ -0,0 +1,40 @@ +/** + * Favorites Store — Mutation-only + * Reads come from liveQuery via queries.ts (reactive, auto-updating). + * This store only handles write operations. + */ + +import { db } from '$lib/data/database'; +import type { LocalFavorite } from '../types'; +import type { Favorite } from '../queries'; + +export const favoritesStore = { + async add(quoteId: string) { + const now = new Date().toISOString(); + await db.table('zitareFavorites').add({ + id: crypto.randomUUID(), + quoteId, + createdAt: now, + updatedAt: now, + }); + }, + + async remove(quoteId: string, favorites: Favorite[]) { + const fav = favorites.find((f) => f.quoteId === quoteId); + if (fav) { + await db.table('zitareFavorites').update(fav.id, { + deletedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + } + }, + + async toggle(quoteId: string, favorites: Favorite[]) { + const exists = favorites.some((f) => f.quoteId === quoteId); + if (exists) { + await this.remove(quoteId, favorites); + } else { + await this.add(quoteId); + } + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/zitare/stores/lists.svelte.ts b/apps/manacore/apps/web/src/lib/modules/zitare/stores/lists.svelte.ts new file mode 100644 index 000000000..41b26ba84 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/zitare/stores/lists.svelte.ts @@ -0,0 +1,101 @@ +/** + * Lists Store — Mutation-only + * Reads come from liveQuery via queries.ts (reactive, auto-updating). + * This store only handles write operations. + */ + +import { db } from '$lib/data/database'; +import type { LocalQuoteList } from '../types'; +import { toQuoteList, type QuoteList } from '../queries'; + +export type { QuoteList } from '../queries'; + +export const listsStore = { + async getList(id: string): Promise { + const local = await db.table('zitareLists').get(id); + return local ? toQuoteList(local) : null; + }, + + async createList(name: string, description?: string): Promise { + try { + const now = new Date().toISOString(); + const newLocal: LocalQuoteList = { + id: crypto.randomUUID(), + name, + description: description ?? null, + quoteIds: [], + createdAt: now, + updatedAt: now, + }; + await db.table('zitareLists').add(newLocal); + return toQuoteList(newLocal); + } catch { + return null; + } + }, + + async updateList( + id: string, + updates: { name?: string; description?: string } + ): Promise { + try { + await db.table('zitareLists').update(id, { + ...updates, + updatedAt: new Date().toISOString(), + }); + const updated = await db.table('zitareLists').get(id); + return updated ? toQuoteList(updated) : null; + } catch { + return null; + } + }, + + async deleteList(id: string): Promise { + try { + await db.table('zitareLists').update(id, { + deletedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + return true; + } catch { + return false; + } + }, + + async addQuoteToList(listId: string, quoteId: string): Promise { + try { + const existing = await db.table('zitareLists').get(listId); + if (!existing) return false; + + const quoteIds = [...(existing.quoteIds || [])]; + if (!quoteIds.includes(quoteId)) { + quoteIds.push(quoteId); + } + + await db.table('zitareLists').update(listId, { + quoteIds, + updatedAt: new Date().toISOString(), + }); + return true; + } catch { + return false; + } + }, + + async removeQuoteFromList(listId: string, quoteId: string): Promise { + try { + const existing = await db.table('zitareLists').get(listId); + if (!existing) return false; + + const quoteIds = (existing.quoteIds || []).filter((qid) => qid !== quoteId); + + await db.table('zitareLists').update(listId, { + quoteIds, + updatedAt: new Date().toISOString(), + }); + return true; + } catch { + return false; + } + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/zitare/stores/quotes.svelte.ts b/apps/manacore/apps/web/src/lib/modules/zitare/stores/quotes.svelte.ts new file mode 100644 index 000000000..24ccc7110 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/zitare/stores/quotes.svelte.ts @@ -0,0 +1,117 @@ +/** + * Quotes Store - Manages quote display state + */ + +import { browser } from '$app/environment'; +import { + QUOTES, + getDailyQuote, + getRandomQuote, + getQuotesByCategory, + searchQuotes, + getQuoteText, + type Quote, + type Category, + type SupportedLanguage, +} from '@zitare/content'; + +// State +let currentQuote = $state(null); +let language = $state('de'); + +// Get stored language or detect from browser +function getInitialLanguage(): SupportedLanguage { + if (browser) { + const stored = localStorage.getItem('zitare_quote_language'); + if (stored && ['de', 'en', 'it', 'fr', 'es', 'original'].includes(stored)) { + return stored as SupportedLanguage; + } + + // Map browser language to supported language + const browserLang = navigator.language.split('-')[0]; + const langMap: Record = { + de: 'de', + en: 'en', + it: 'it', + fr: 'fr', + es: 'es', + }; + return langMap[browserLang] || 'de'; + } + return 'de'; +} + +export const quotesStore = { + get currentQuote() { + return currentQuote; + }, + get language() { + return language; + }, + get allQuotes() { + return QUOTES; + }, + get totalCount() { + return QUOTES.length; + }, + + /** + * Initialize the store + */ + initialize() { + language = getInitialLanguage(); + currentQuote = getDailyQuote(); + }, + + /** + * Set the display language + */ + setLanguage(lang: SupportedLanguage) { + language = lang; + if (browser) { + localStorage.setItem('zitare_quote_language', lang); + } + }, + + /** + * Get quote text in current language + */ + getText(quote: Quote): string { + return getQuoteText(quote, language); + }, + + /** + * Load the daily quote + */ + loadDailyQuote() { + currentQuote = getDailyQuote(); + }, + + /** + * Load a random quote + */ + loadRandomQuote() { + currentQuote = getRandomQuote(); + }, + + /** + * Get quotes by category + */ + getByCategory(category: Category): Quote[] { + return getQuotesByCategory(category); + }, + + /** + * Search quotes + */ + search(query: string): Quote[] { + return searchQuotes(query, language); + }, + + /** + * Set current quote + */ + setCurrentQuote(quote: Quote) { + currentQuote = quote; + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/zitare/stores/settings.svelte.ts b/apps/manacore/apps/web/src/lib/modules/zitare/stores/settings.svelte.ts new file mode 100644 index 000000000..ebf009701 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/zitare/stores/settings.svelte.ts @@ -0,0 +1,96 @@ +/** + * Settings Store - Manages user preferences for the Zitare module + * Uses @manacore/shared-stores createAppSettingsStore factory + */ + +import { createAppSettingsStore } from '@manacore/shared-stores'; + +export interface ZitareAppSettings extends Record { + // View & Display + showQuoteOfTheDay: boolean; + autoRefreshDaily: boolean; + compactMode: boolean; + + // Quote Display + showCategory: boolean; + showSource: boolean; + fontSizeMultiplier: number; + + // Immersive Mode + immersiveModeEnabled: boolean; + + // Navigation UI + pillNavCollapsed: boolean; +} + +const DEFAULT_SETTINGS: ZitareAppSettings = { + // View & Display + showQuoteOfTheDay: true, + autoRefreshDaily: true, + compactMode: false, + + // Quote Display + showCategory: true, + showSource: true, + fontSizeMultiplier: 1, + + // Immersive Mode + immersiveModeEnabled: false, + + // Navigation UI + pillNavCollapsed: true, +}; + +// Create base store using factory +const baseStore = createAppSettingsStore('zitare-settings', DEFAULT_SETTINGS); + +// Export with convenience getters +export const zitareSettings = { + // Base store methods + get settings() { + return baseStore.settings; + }, + initialize: baseStore.initialize, + set: baseStore.set, + update: baseStore.update, + reset: baseStore.reset, + getDefaults: baseStore.getDefaults, + toggleImmersiveMode: baseStore.toggleImmersiveMode, + + // Convenience getters + get showQuoteOfTheDay() { + return baseStore.settings.showQuoteOfTheDay; + }, + get autoRefreshDaily() { + return baseStore.settings.autoRefreshDaily; + }, + get compactMode() { + return baseStore.settings.compactMode; + }, + get showCategory() { + return baseStore.settings.showCategory; + }, + get showSource() { + return baseStore.settings.showSource; + }, + get fontSizeMultiplier() { + return baseStore.settings.fontSizeMultiplier; + }, + get immersiveModeEnabled() { + return baseStore.settings.immersiveModeEnabled; + }, + get pillNavCollapsed() { + return baseStore.settings.pillNavCollapsed; + }, + + // Toggle methods + togglePillNav() { + baseStore.update({ pillNavCollapsed: !baseStore.settings.pillNavCollapsed }); + }, + showPillNav() { + baseStore.update({ pillNavCollapsed: false }); + }, + hidePillNav() { + baseStore.update({ pillNavCollapsed: true }); + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/zitare/stores/spiral.svelte.ts b/apps/manacore/apps/web/src/lib/modules/zitare/stores/spiral.svelte.ts new file mode 100644 index 000000000..19422a79c --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/zitare/stores/spiral.svelte.ts @@ -0,0 +1,233 @@ +/** + * Spiral DB Store for Zitare + * Manages SpiralDB state for visual quote storage + */ + +import { + SpiralDB, + createQuoteSchema, + type SpiralImage, + type SpiralRecord, + exportToPngBytes, + importFromPngBytes, + downloadPng, +} from '@manacore/spiral-db'; + +interface QuoteData extends Record { + id: number; + status: number; + category: number; + language: number; + createdAt: Date; + quoteId: string; + author: string; + text: string; +} + +interface SpiralStats { + imageSize: number; + totalPixels: number; + usedPixels: number; + totalRecords: number; + activeRecords: number; + deletedRecords: number; + currentRing: number; + compressionRatio: number; +} + +const CATEGORY_MAP: Record = { + motivation: 0, + weisheit: 1, + liebe: 2, + leben: 3, + erfolg: 4, + glueck: 5, + freundschaft: 6, + mut: 7, + hoffnung: 8, + natur: 9, +}; + +const CATEGORY_NAMES: Record = Object.fromEntries( + Object.entries(CATEGORY_MAP).map(([k, v]) => [v, k]) +); + +const LANGUAGE_MAP: Record = { + original: 0, + de: 1, + en: 2, + it: 3, + fr: 4, + es: 5, +}; + +class SpiralStore { + private db: SpiralDB; + + image = $state(null); + stats = $state(null); + records = $state[]>([]); + isLoading = $state(false); + error = $state(null); + + constructor() { + this.db = new SpiralDB({ + schema: createQuoteSchema(), + compression: true, + }); + this.updateState(); + } + + private updateState() { + this.image = this.db.getImage(); + this.records = this.db.getAll(); + + const dbStats = this.db.getStats(); + const jsonSize = JSON.stringify(this.records.map((r) => r.data)).length || 1; + const pixelBytes = Math.ceil((dbStats.usedPixels * 3) / 8); + + this.stats = { + ...dbStats, + compressionRatio: Math.round((1 - pixelBytes / jsonSize) * 100), + }; + } + + /** + * Import favorites from the favorites store, merged with quote data + */ + importFavorites( + favorites: Array<{ + quoteId: string; + createdAt?: string | Date; + }>, + getQuote: (quoteId: string) => { + author: string; + text: string; + category: string; + language?: string; + } | null + ) { + this.db = new SpiralDB({ + schema: createQuoteSchema(), + compression: true, + }); + + for (const fav of favorites) { + const quote = getQuote(fav.quoteId); + if (!quote) continue; + + const result = this.db.insert({ + id: 0, + status: 2, // favorited + category: CATEGORY_MAP[quote.category] ?? 0, + language: LANGUAGE_MAP[quote.language ?? 'de'] ?? 1, + createdAt: fav.createdAt ? new Date(fav.createdAt) : new Date(), + quoteId: fav.quoteId.slice(0, 100), + author: quote.author.slice(0, 100), + text: quote.text.slice(0, 255), + }); + + if (result.success) { + this.db.complete(result.recordId!); + } + } + + this.updateState(); + } + + /** + * Add a single quote to the spiral + */ + addQuote(quote: { + quoteId: string; + author: string; + text: string; + category: string; + language?: string; + }) { + const result = this.db.insert({ + id: 0, + status: 0, + category: CATEGORY_MAP[quote.category] ?? 0, + language: LANGUAGE_MAP[quote.language ?? 'de'] ?? 1, + createdAt: new Date(), + quoteId: quote.quoteId.slice(0, 100), + author: quote.author.slice(0, 100), + text: quote.text.slice(0, 255), + }); + + if (result.success) { + this.updateState(); + } + return result; + } + + /** + * Remove a quote (soft delete) + */ + removeQuote(id: number) { + const result = this.db.delete(id); + if (result.success) { + this.updateState(); + } + return result; + } + + /** + * Mark a quote as favorited + */ + favoriteQuote(id: number) { + const result = this.db.complete(id); + if (result.success) { + this.updateState(); + } + return result; + } + + downloadPng(filename = 'spiral-quotes.png') { + if (this.image) { + downloadPng(this.image, filename); + } + } + + getPngBytes(): Uint8Array | null { + if (!this.image) return null; + return exportToPngBytes(this.image); + } + + clear() { + this.db = new SpiralDB({ + schema: createQuoteSchema(), + compression: true, + }); + this.updateState(); + } + + async importFromPng(file: File): Promise<{ success: boolean; error?: string }> { + try { + this.isLoading = true; + this.error = null; + + const buffer = await file.arrayBuffer(); + const bytes = new Uint8Array(buffer); + const image = await importFromPngBytes(bytes); + + this.db = SpiralDB.fromImage(image, createQuoteSchema()); + this.updateState(); + + return { success: true }; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Unknown error'; + this.error = errorMessage; + return { success: false, error: errorMessage }; + } finally { + this.isLoading = false; + } + } + + getCategoryName(index: number): string { + return CATEGORY_NAMES[index] ?? 'unknown'; + } +} + +export const spiralStore = new SpiralStore(); diff --git a/apps/manacore/apps/web/src/lib/modules/zitare/types.ts b/apps/manacore/apps/web/src/lib/modules/zitare/types.ts new file mode 100644 index 000000000..5d1c3c21c --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/zitare/types.ts @@ -0,0 +1,15 @@ +/** + * Zitare module types for the unified app. + */ + +import type { BaseRecord } from '@manacore/local-store'; + +export interface LocalFavorite extends BaseRecord { + quoteId: string; +} + +export interface LocalQuoteList extends BaseRecord { + name: string; + description?: string | null; + quoteIds: string[]; +} diff --git a/apps/manacore/apps/web/src/routes/(app)/clock/+layout.svelte b/apps/manacore/apps/web/src/routes/(app)/clock/+layout.svelte new file mode 100644 index 000000000..066c973c6 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/clock/+layout.svelte @@ -0,0 +1,19 @@ + + +{@render children()} diff --git a/apps/manacore/apps/web/src/routes/(app)/clock/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/clock/+page.svelte new file mode 100644 index 000000000..6e05b178c --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/clock/+page.svelte @@ -0,0 +1,93 @@ + + + + Clock - ManaCore + + +
+
+

Clock

+

Dein Zeit-Management Hub

+
+ + +
+
+
+ +
+
+
+ {new Date().toLocaleTimeString('de-DE', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + })} +
+
+ {new Date().toLocaleDateString('de-DE', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + })} +
+
+
+
+ + +
+ {#each quickLinks as link} + +
+
+ +
+
+
{link.label}
+
{link.description}
+
+
+
+ {/each} +
+
diff --git a/apps/manacore/apps/web/src/routes/(app)/clock/alarms/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/clock/alarms/+page.svelte new file mode 100644 index 000000000..73e06cd18 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/clock/alarms/+page.svelte @@ -0,0 +1,327 @@ + + + + Wecker - Clock - ManaCore + + + + +
+ +
+ + + + +
+ + {#if showOptions} +
+ {#each dayNames as day, i} + + {/each} +
+ {/if} + + +
+ {#each DEFAULT_ALARM_PRESETS as preset} + {@const existingAlarm = findAlarmForPreset(preset.time)} + {@const isActive = existingAlarm?.enabled ?? false} +
togglePreset(preset.time, preset.label)} + onkeydown={(e) => e.key === 'Enter' && togglePreset(preset.time, preset.label)} + > +
+ {preset.time} +
+
+ {existingAlarm?.label || preset.label} +
+
+ {/each} +
+ + + {#if allAlarms.value.filter((a) => !DEFAULT_ALARM_PRESETS.some((p) => p.time === a.time.slice(0, 5))).length > 0} + {@const customAlarms = allAlarms.value.filter( + (a) => !DEFAULT_ALARM_PRESETS.some((p) => p.time === a.time.slice(0, 5)) + )} +
+

+ {$_('alarm.custom')} +

+
+ {#each customAlarms as alarm (alarm.id)} +
handleToggle(alarm.id)} + onkeydown={(e) => e.key === 'Enter' && handleToggle(alarm.id)} + > +
+ {alarm.time.slice(0, 5)} +
+
+ {alarm.label || getRepeatText(alarm.repeatDays)} +
+
+ {/each} +
+
+ {/if} + + + {#if showEditModal} +
+
+

{$_('alarm.edit')}

+ +
{ + e.preventDefault(); + handleEditSubmit(); + }} + > + +
+ + +
+ + +
+ + +
+ + +
+ +
+ {#each dayNames as day, i} + + {/each} +
+
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+
+
+ {/if} +
diff --git a/apps/manacore/apps/web/src/routes/(app)/moodlit/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/moodlit/+page.svelte new file mode 100644 index 000000000..c6dad4b17 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/moodlit/+page.svelte @@ -0,0 +1,67 @@ + + + + Moodlit - ManaCore + + +
+
+

Moodlit

+

Ambient Lighting & Mood App

+
+ + +
+
+
+ +
+
+
Stimmungslicht
+
Wahle ein Mood oder erstelle dein eigenes
+
+
+
+ + +
+ {#each quickLinks as link} + +
+
+ +
+
+
{link.label}
+
{link.description}
+
+
+
+ {/each} +
+
diff --git a/apps/manacore/apps/web/src/routes/(app)/moodlit/moods/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/moodlit/moods/+page.svelte new file mode 100644 index 000000000..632ab3fe5 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/moodlit/moods/+page.svelte @@ -0,0 +1,176 @@ + + + + Moods - Moodlit - ManaCore + + +
+
+

Moods

+ +
+ + + {#if activeMood} +
+

{activeMood.name}

+

{activeMood.animation}

+ +
+ {/if} + + {#if showCreate} +
+
+
+ + +
+
+ + +
+
+ +
+ {#each newColors as color, i} + + {/each} + +
+
+
+
+ +
+ {/if} + + {#if moods.loading} +
+ {#each Array(6) as _} +
+ {/each} +
+ {:else} +
+ {#each moods.value ?? [] as mood (mood.id)} + + {/if} + + {/each} +
+ {/if} +
diff --git a/apps/manacore/apps/web/src/routes/(app)/moodlit/sequences/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/moodlit/sequences/+page.svelte new file mode 100644 index 000000000..0a09da7b9 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/moodlit/sequences/+page.svelte @@ -0,0 +1,128 @@ + + + + Sequences - Moodlit - ManaCore + + +
+
+

Sequences

+ +
+ + {#if showCreate} +
+
+ + + Sek. + +
+
+ {/if} + + {#if !sequences.value?.length} +
+

Keine Sequences

+

+ Verkette mehrere Moods zu einer automatischen Sequenz. +

+
+ {:else} +
+ {#each sequences.value as seq (seq.id)} +
+
+
+

{seq.name}

+
+ {#each seq.moodIds as moodId} + {getMoodName(moodId)} + {/each} + · {seq.duration}s pro Mood +
+
+ +
+
+ {/each} +
+ {/if} +
diff --git a/apps/manacore/apps/web/src/routes/(app)/zitare/+layout.svelte b/apps/manacore/apps/web/src/routes/(app)/zitare/+layout.svelte new file mode 100644 index 000000000..501d96900 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/zitare/+layout.svelte @@ -0,0 +1,35 @@ + + +{@render children()} diff --git a/apps/manacore/apps/web/src/routes/(app)/zitare/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/zitare/+page.svelte new file mode 100644 index 000000000..f9e0afc79 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/zitare/+page.svelte @@ -0,0 +1,62 @@ + + + + Zitare - {$_('home.dailyQuote')} + + +
+ +
+

{$_('home.dailyQuote')}

+

{$_('app.tagline')}

+
+ + + {#if quotesStore.currentQuote} +
+ +
+ {/if} + + +
+ +
+ + +
+

+ {quotesStore.totalCount} Zitate in 10 Kategorien +

+
+
diff --git a/apps/manacore/apps/web/src/routes/(app)/zitare/categories/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/zitare/categories/+page.svelte new file mode 100644 index 000000000..e410b6e8e --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/zitare/categories/+page.svelte @@ -0,0 +1,100 @@ + + + + Zitare - {$_('categories.title')} + + +
+

{$_('categories.title')}

+ +
+ {#each CATEGORIES as category} + {@const data = categoryData[category]} + + {/each} +
+
diff --git a/apps/manacore/apps/web/src/routes/(app)/zitare/category/[category]/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/zitare/category/[category]/+page.svelte new file mode 100644 index 000000000..e5bf65bdf --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/zitare/category/[category]/+page.svelte @@ -0,0 +1,123 @@ + + + + Zitare - {isValidCategory ? $_(categoryLabels[category]) : $_('categories.notFound')} + + +
+ + + + {#if isValidCategory} +

{$_(categoryLabels[category])}

+

+ {$_('categories.quotes', { values: { count: quotes.length } })} +

+ + +
+
+
+ +
+ +
+ +
+ + {#if displayedQuotes.length === 0 && searchTerm.length >= 2} +
+

{$_('search.noResults')}

+
+ {:else} +
+ {#each displayedQuotes as quote (quote.id)} + + {/each} +
+ {/if} + {:else} +
+

{$_('categories.notFound')}

+ +
+ {/if} +
diff --git a/apps/manacore/apps/web/src/routes/(app)/zitare/favorites/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/zitare/favorites/+page.svelte new file mode 100644 index 000000000..ed4a16da2 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/zitare/favorites/+page.svelte @@ -0,0 +1,137 @@ + + + + Zitare - {$_('favorites.title')} + + +
+
+

{$_('favorites.title')}

+ {#if authStore.isAuthenticated && favoriteQuotes.length > 0} + + {favoriteQuotes.length} + + {/if} +
+ + {#if !authStore.isAuthenticated} + +
+ +

{$_('favorites.loginPrompt')}

+ +
+ {:else if favoriteQuotes.length === 0} + +
+ +

{$_('favorites.empty')}

+

{$_('favorites.emptyDescription')}

+
+ {:else} + +
+ {#each favoriteQuotes as quote (quote.id)} + +
handleContextMenu(e, quote)}> + +
+ {/each} +
+ {/if} +
+ + { + contextMenuVisible = false; + contextMenuQuote = null; + }} +/> diff --git a/apps/manacore/apps/web/src/routes/(app)/zitare/lists/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/zitare/lists/+page.svelte new file mode 100644 index 000000000..4ae28b4ed --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/zitare/lists/+page.svelte @@ -0,0 +1,210 @@ + + + + Zitare - {$_('lists.title')} + + +
+
+

{$_('lists.title')}

+ {#if authStore.isAuthenticated} + + {/if} +
+ + {#if !authStore.isAuthenticated} +
+
+ +
+

{$_('lists.loginPrompt')}

+ +
+ {:else if allLists.value.length === 0} +
+
+ +
+

{$_('lists.empty')}

+

{$_('lists.emptyDescription')}

+
+ {:else} + + {/if} +
+ + +{#if showCreateModal} +
+
+
+

{$_('lists.createModal.title')}

+ +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+{/if} diff --git a/apps/manacore/apps/web/src/routes/(app)/zitare/lists/[id]/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/zitare/lists/[id]/+page.svelte new file mode 100644 index 000000000..5360a1f1e --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/zitare/lists/[id]/+page.svelte @@ -0,0 +1,958 @@ + + + + {list?.name || $_('common.list')} - Zitare + + +{#if !list} +
+

{$_('lists.detail.notFound')}

+

{$_('lists.detail.notFoundDescription')}

+ {$_('lists.detail.backToLists')} +
+{:else} +
+ +
+ + +
+
+

{list.name}

+ {#if list.description} +

{list.description}

+ {/if} +
+ {$_('lists.quoteCount', { values: { count: listQuotes.length } })} + + {$_('lists.detail.lastEdited', { + values: { date: formatDate(list.updatedAt) }, + })} +
+
+ +
+ {#if listQuotes.length > 0} + + {/if} + + + + +
+
+ + {#if isSearchOpen} + + {/if} +
+ + + {#if listQuotes.length === 0} +
+
+ +
+

{$_('lists.detail.emptyTitle')}

+

{$_('lists.detail.emptyDescription')}

+ +
+ {:else if filteredQuotes.length === 0} +
+
+ +
+

{$_('lists.detail.noSearchResults')}

+

{$_('lists.detail.noSearchResultsDescription')}

+
+ {:else} +
+ {#each filteredQuotes as quote (quote.id)} +
+ + +
+ {/each} +
+ {/if} + + {#if isSearchOpen && filteredQuotes.length > 0} +
+ {$_('lists.detail.floatingResults', { + values: { filtered: filteredQuotes.length, total: listQuotes.length }, + })} +
+ {/if} +
+{/if} + + +{#if showEditModal} + +{/if} + + +{#if showAddQuotesModal} + +{/if} + +