diff --git a/apps/mana/apps/web/src/lib/components/workbench/scenes/ConfirmDialog.svelte b/apps/mana/apps/web/src/lib/components/workbench/scenes/ConfirmDialog.svelte new file mode 100644 index 000000000..bf2c2b776 --- /dev/null +++ b/apps/mana/apps/web/src/lib/components/workbench/scenes/ConfirmDialog.svelte @@ -0,0 +1,179 @@ + + + + + +{#if show} + + +
+ +
+{/if} + + diff --git a/apps/mana/apps/web/src/lib/components/workbench/scenes/SceneRenameDialog.svelte b/apps/mana/apps/web/src/lib/components/workbench/scenes/SceneRenameDialog.svelte new file mode 100644 index 000000000..1f17a5776 --- /dev/null +++ b/apps/mana/apps/web/src/lib/components/workbench/scenes/SceneRenameDialog.svelte @@ -0,0 +1,246 @@ + + + + + +{#if show} + + +
+ +
+{/if} + + diff --git a/apps/mana/apps/web/src/lib/components/workbench/scenes/SceneTabs.svelte b/apps/mana/apps/web/src/lib/components/workbench/scenes/SceneTabs.svelte new file mode 100644 index 000000000..185c7c3d7 --- /dev/null +++ b/apps/mana/apps/web/src/lib/components/workbench/scenes/SceneTabs.svelte @@ -0,0 +1,232 @@ + + + +
+
+ {#each scenes as scene (scene.id)} + + + {/each} + +
+
+ + (menuVisible = false)} +/> + + diff --git a/apps/mana/apps/web/src/lib/data/database.ts b/apps/mana/apps/web/src/lib/data/database.ts index 3b2b36ff6..3e3b70a47 100644 --- a/apps/mana/apps/web/src/lib/data/database.ts +++ b/apps/mana/apps/web/src/lib/data/database.ts @@ -59,6 +59,7 @@ db.version(1).stores({ // ─── Core / Mana (appId: 'mana') ─── userSettings: 'id, key', dashboardConfigs: 'id', + workbenchScenes: 'id, order', automations: 'id, sourceApp, targetApp, enabled, [sourceApp+sourceCollection]', // ─── Todo (appId: 'todo') ─── diff --git a/apps/mana/apps/web/src/lib/modules/core/module.config.ts b/apps/mana/apps/web/src/lib/modules/core/module.config.ts index bb07d089a..3877dc567 100644 --- a/apps/mana/apps/web/src/lib/modules/core/module.config.ts +++ b/apps/mana/apps/web/src/lib/modules/core/module.config.ts @@ -14,7 +14,12 @@ import type { ModuleConfig } from '$lib/data/module-registry'; export const manaCoreConfig: ModuleConfig = { appId: 'mana', - tables: [{ name: 'userSettings' }, { name: 'dashboardConfigs' }, { name: 'automations' }], + tables: [ + { name: 'userSettings' }, + { name: 'dashboardConfigs' }, + { name: 'workbenchScenes' }, + { name: 'automations' }, + ], }; export const tagsCoreConfig: ModuleConfig = { diff --git a/apps/mana/apps/web/src/lib/modules/habits/ListView.svelte b/apps/mana/apps/web/src/lib/modules/habits/ListView.svelte index 7a444ab57..92bf34dbe 100644 --- a/apps/mana/apps/web/src/lib/modules/habits/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/habits/ListView.svelte @@ -15,9 +15,11 @@ import type { Habit, HabitLog } from './types'; import type { ViewProps } from '$lib/app-registry'; import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui'; + import { toastStore } from '@mana/shared-ui/toast'; import { DynamicIcon } from '@mana/shared-ui/atoms'; import { IconPicker } from '@mana/shared-ui/molecules'; import { PencilSimple, Trash, Pause, Play } from '@mana/shared-icons'; + import VoiceCaptureBar from '$lib/components/voice/VoiceCaptureBar.svelte'; let { navigate, goBack, params }: ViewProps = $props(); @@ -64,6 +66,21 @@ setTimeout(() => (animatingId = null), 300); } + async function handleVoiceComplete(blob: Blob, durationMs: number) { + const result = await habitsStore.logFromVoice(blob, durationMs, 'de'); + if (!result) { + toastStore.error('Habit nicht erkannt. Versuche den Namen direkt zu sagen, z.B. "Kaffee".'); + return; + } + toastStore.success(`${result.habitTitle} geloggt`); + // Reuse the existing pulse animation by finding the matching habit id + const matched = habits.find((h) => h.title === result.habitTitle); + if (matched) { + animatingId = matched.id; + setTimeout(() => (animatingId = null), 300); + } + } + async function handleCreate(e: Event) { e.preventDefault(); if (!newTitle.trim()) return; @@ -140,6 +157,14 @@
+ + +
{#each activeHabits as habit (habit.id)} diff --git a/apps/mana/apps/web/src/lib/modules/habits/stores/habits.svelte.ts b/apps/mana/apps/web/src/lib/modules/habits/stores/habits.svelte.ts index a2529d7f3..cfe7e7bd2 100644 --- a/apps/mana/apps/web/src/lib/modules/habits/stores/habits.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/habits/stores/habits.svelte.ts @@ -21,6 +21,46 @@ import { } from '$lib/data/time-blocks/recurrence'; import type { LocalHabit, LocalHabitLog, HabitSchedule } from '../types'; +/** + * Normalize for fuzzy comparison: lowercase, strip diacritics, + * collapse whitespace. "Kaffee" / "kaffee" / "KaffΓ©e " all collapse + * to "kaffee". + */ +function normalize(s: string): string { + return s + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .trim() + .replace(/\s+/g, ' '); +} + +/** + * Cheap client-side substring matching from spoken transcript to + * habit title. Used as a fast path before falling back to the LLM + * parse-habit endpoint. Returns the first habit whose normalized + * title appears as a whole word inside the normalized transcript, + * or vice versa for very short titles ("Tee" inside "GrΓΌner Tee"). + * + * Word-boundary matching avoids false positives like "Bier" matching + * a transcript that contains "ausprobiert". + */ +function matchHabitToTranscript(transcript: string, habits: LocalHabit[]): LocalHabit | null { + const normTranscript = normalize(transcript); + if (!normTranscript) return null; + const words = new Set(normTranscript.split(/[^a-z0-9Àâüß]+/i).filter((w) => w.length >= 3)); + for (const habit of habits) { + const normTitle = normalize(habit.title); + if (normTitle.length < 3) continue; + // Whole-word title appears in transcript + if (words.has(normTitle)) return habit; + // Multi-word title: every token must be present as a word + const titleWords = normTitle.split(' ').filter((w) => w.length >= 3); + if (titleWords.length > 1 && titleWords.every((w) => words.has(w))) return habit; + } + return null; +} + export const habitsStore = { async createHabit(data: { title: string; @@ -78,6 +118,85 @@ export const habitsStore = { } }, + /** + * Voice quick-log. The user taps the mic, says e.g. "kaffee" or + * "30 minuten gelaufen", and we log the right habit. Two-step: + * + * 1. Substring pre-match against habit titles. Catches the easy + * cases ("kaffee" β†’ "Kaffee") without an LLM round-trip. + * 2. If nothing matches, send transcript + habit titles to + * /api/v1/voice/parse-habit which asks mana-llm to pick one + * from the list. Handles the harder cases ("gelaufen" β†’ + * "Laufen", "rauchen" β†’ "Zigarette"). + * + * If neither step finds a habit, returns null and the caller can + * surface a "habit nicht erkannt" hint instead of silently logging + * nothing. The transcribe step itself never throws β€” failures show + * up as null too. + */ + async logFromVoice( + blob: Blob, + _durationMs: number, + language = 'de' + ): Promise<{ logId: string; habitTitle: string } | null> { + // Step 1: speech to text + let transcript: string; + try { + const form = new FormData(); + const ext = blob.type.includes('webm') + ? '.webm' + : blob.type.includes('mp4') + ? '.m4a' + : '.audio'; + form.append('file', blob, `habit${ext}`); + if (language) form.append('language', language); + + const sttResponse = await fetch('/api/v1/voice/transcribe', { + method: 'POST', + body: form, + }); + if (!sttResponse.ok) return null; + const sttResult = (await sttResponse.json()) as { text: string }; + transcript = (sttResult.text ?? '').trim(); + } catch { + return null; + } + if (!transcript) return null; + + // Step 2: pick a habit. Substring fast path first, LLM fallback. + const habits = (await habitTable.toArray()).filter((h) => !h.deletedAt && !h.isArchived); + if (habits.length === 0) return null; + + const matched = matchHabitToTranscript(transcript, habits); + const note = transcript; + if (matched) { + const log = await this.logHabit(matched.id, note); + return { logId: log.id, habitTitle: matched.title }; + } + + // LLM fallback + try { + const response = await fetch('/api/v1/voice/parse-habit', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + transcript, + habits: habits.map((h) => h.title), + language, + }), + }); + if (!response.ok) return null; + const parsed = (await response.json()) as { match: string | null; note: string | null }; + if (!parsed.match) return null; + const target = habits.find((h) => h.title === parsed.match); + if (!target) return null; + const log = await this.logHabit(target.id, parsed.note ?? note); + return { logId: log.id, habitTitle: target.title }; + } catch { + return null; + } + }, + async logHabit(habitId: string, note?: string) { const habit = await habitTable.get(habitId); const now = new Date(); diff --git a/apps/mana/apps/web/src/lib/stores/workbench-scenes.svelte.ts b/apps/mana/apps/web/src/lib/stores/workbench-scenes.svelte.ts new file mode 100644 index 000000000..73e7a0cb7 --- /dev/null +++ b/apps/mana/apps/web/src/lib/stores/workbench-scenes.svelte.ts @@ -0,0 +1,322 @@ +/** + * Workbench Scenes Store β€” local-first, cross-device synced. + * + * Scenes (named workbench layouts) are persisted in the unified Mana Dexie + * database under the `workbenchScenes` table and reach other devices via + * the mana-sync engine (field-level LWW). The currently active scene id is + * a per-device preference and lives in localStorage so device A doesn't + * pull device B into a different scene. + * + * Reactive surface: `scenes`, `activeSceneId`, `activeScene`, `openApps`, + * `initialized`. The store subscribes to a Dexie liveQuery on module init, + * so writes from other tabs and remote sync pulls flow back into the UI + * without a manual refresh. + */ + +import { browser } from '$app/environment'; +import { liveQuery, type Subscription } from 'dexie'; +import { db } from '$lib/data/database'; +import type { + LocalWorkbenchScene, + WorkbenchScene, + WorkbenchSceneApp, +} from '$lib/types/workbench-scenes'; + +const TABLE = 'workbenchScenes'; +const ACTIVE_SCENE_LS_KEY = 'mana:workbench:activeSceneId'; + +const DEFAULT_HOME_APPS: WorkbenchSceneApp[] = [ + { appId: 'todo', minimized: false }, + { appId: 'calendar', minimized: false }, + { appId: 'notes', minimized: false }, +]; + +// ─── Reactive state ─────────────────────────────────────────── + +let scenesState = $state([]); +let activeSceneIdState = $state(null); +let initializedState = $state(false); + +let subscription: Subscription | null = null; + +function readActiveIdFromStorage(): string | null { + if (!browser) return null; + try { + return localStorage.getItem(ACTIVE_SCENE_LS_KEY); + } catch { + return null; + } +} + +function writeActiveIdToStorage(id: string | null) { + if (!browser) return; + try { + if (id) localStorage.setItem(ACTIVE_SCENE_LS_KEY, id); + else localStorage.removeItem(ACTIVE_SCENE_LS_KEY); + } catch { + /* storage quota / disabled β€” ignore */ + } +} + +function toScene(local: LocalWorkbenchScene): WorkbenchScene { + return { + id: local.id, + name: local.name, + icon: local.icon, + openApps: local.openApps ?? [], + order: local.order, + }; +} + +function nowIso() { + return new Date().toISOString(); +} + +async function ensureSeedScene(): Promise { + const id = crypto.randomUUID(); + const now = nowIso(); + const seed: LocalWorkbenchScene = { + id, + name: 'Home', + openApps: DEFAULT_HOME_APPS, + order: 0, + createdAt: now, + updatedAt: now, + }; + await db.table(TABLE).add(seed); + return id; +} + +function pickActiveId(scenes: WorkbenchScene[], current: string | null): string | null { + if (scenes.length === 0) return null; + if (current && scenes.some((s) => s.id === current)) return current; + return scenes[0].id; +} + +// ─── Mutations ──────────────────────────────────────────────── + +async function patchScene( + id: string, + patch: Partial> +) { + await db.table(TABLE).update(id, { + ...patch, + updatedAt: nowIso(), + }); +} + +async function patchActiveScene(fn: (apps: WorkbenchSceneApp[]) => WorkbenchSceneApp[]) { + const id = activeSceneIdState; + if (!id) return; + const current = scenesState.find((s) => s.id === id); + if (!current) return; + await patchScene(id, { openApps: fn(current.openApps) }); +} + +// ─── Public store ───────────────────────────────────────────── + +export const workbenchScenesStore = { + get scenes() { + return scenesState; + }, + get activeSceneId() { + return activeSceneIdState; + }, + get activeScene() { + return scenesState.find((s) => s.id === activeSceneIdState) ?? null; + }, + get openApps(): WorkbenchSceneApp[] { + return this.activeScene?.openApps ?? []; + }, + get initialized() { + return initializedState; + }, + + async initialize() { + if (!browser || initializedState) return; + + // Seed a Home scene on first run so the UI never has zero scenes. + const count = await db.table(TABLE).count(); + if (count === 0) { + await ensureSeedScene(); + } + + activeSceneIdState = readActiveIdFromStorage(); + + subscription = liveQuery(() => db.table(TABLE).toArray()).subscribe({ + next: (rows) => { + const visible = rows + .filter((r) => !r.deletedAt) + .sort((a, b) => a.order - b.order) + .map(toScene); + scenesState = visible; + + const next = pickActiveId(visible, activeSceneIdState); + if (next !== activeSceneIdState) { + activeSceneIdState = next; + writeActiveIdToStorage(next); + } + initializedState = true; + }, + error: (err) => { + console.error('[workbench-scenes] liveQuery failed:', err); + initializedState = true; + }, + }); + }, + + dispose() { + subscription?.unsubscribe(); + subscription = null; + }, + + // ── Scene CRUD ─────────────────────────────────────────── + + setActiveScene(id: string) { + if (!scenesState.some((s) => s.id === id)) return; + activeSceneIdState = id; + writeActiveIdToStorage(id); + }, + + async createScene(opts: { + name: string; + icon?: string; + seedApps?: WorkbenchSceneApp[]; + setActive?: boolean; + }): Promise { + const id = crypto.randomUUID(); + const now = nowIso(); + const maxOrder = scenesState.reduce((m, s) => Math.max(m, s.order), -1); + const row: LocalWorkbenchScene = { + id, + name: opts.name.trim() || 'Neue Szene', + icon: opts.icon, + openApps: opts.seedApps ? structuredClone(opts.seedApps) : [], + order: maxOrder + 1, + createdAt: now, + updatedAt: now, + }; + await db.table(TABLE).add(row); + if (opts.setActive !== false) { + activeSceneIdState = id; + writeActiveIdToStorage(id); + } + return id; + }, + + async renameScene(id: string, name: string, icon?: string) { + const trimmed = name.trim(); + if (!trimmed) return; + await patchScene(id, { name: trimmed, ...(icon !== undefined ? { icon } : {}) }); + }, + + async duplicateScene(id: string) { + const src = scenesState.find((s) => s.id === id); + if (!src) return; + await this.createScene({ + name: `${src.name} Kopie`, + icon: src.icon, + seedApps: src.openApps, + setActive: true, + }); + }, + + async deleteScene(id: string) { + // Refuse to delete the very last scene β€” the workbench always needs one. + if (scenesState.length <= 1) return; + await db.table(TABLE).update(id, { + deletedAt: nowIso(), + updatedAt: nowIso(), + }); + // liveQuery will recompute scenesState; pickActiveId then advances + // activeSceneId to the first remaining scene if needed. + }, + + async reorderScenes(fromId: string, toId: string) { + if (fromId === toId) return; + const ordered = [...scenesState]; + const fromIdx = ordered.findIndex((s) => s.id === fromId); + const toIdx = ordered.findIndex((s) => s.id === toId); + if (fromIdx === -1 || toIdx === -1) return; + const [moved] = ordered.splice(fromIdx, 1); + ordered.splice(toIdx, 0, moved); + // Renumber and persist only the rows whose order actually changed. + await Promise.all( + ordered.map((s, i) => (s.order === i ? null : patchScene(s.id, { order: i }))) + ); + }, + + // ── Per-scene app mutations (operate on the active scene) ─ + + async addApp(appId: string) { + await patchActiveScene((apps) => { + if (apps.some((a) => a.appId === appId)) { + return apps.map((a) => (a.appId === appId ? { ...a, minimized: false } : a)); + } + return [...apps, { appId, minimized: false }]; + }); + }, + + async removeApp(appId: string) { + await patchActiveScene((apps) => apps.filter((a) => a.appId !== appId)); + }, + + async minimizeApp(appId: string) { + await patchActiveScene((apps) => + apps.map((a) => (a.appId === appId ? { ...a, minimized: true } : a)) + ); + }, + + async restoreApp(appId: string) { + await patchActiveScene((apps) => + apps.map((a) => (a.appId === appId ? { ...a, minimized: false } : a)) + ); + }, + + async toggleMaximizeApp(appId: string) { + await patchActiveScene((apps) => + apps.map((a) => (a.appId === appId ? { ...a, maximized: !a.maximized, minimized: false } : a)) + ); + }, + + async resizeApp(appId: string, widthPx: number, heightPx?: number) { + await patchActiveScene((apps) => + apps.map((a) => + a.appId === appId ? { ...a, widthPx, ...(heightPx !== undefined ? { heightPx } : {}) } : a + ) + ); + }, + + async moveAppLeft(appId: string) { + await patchActiveScene((apps) => { + const idx = apps.findIndex((a) => a.appId === appId); + if (idx <= 0) return apps; + const next = [...apps]; + [next[idx - 1], next[idx]] = [next[idx], next[idx - 1]]; + return next; + }); + }, + + async moveAppRight(appId: string) { + await patchActiveScene((apps) => { + const idx = apps.findIndex((a) => a.appId === appId); + if (idx === -1 || idx >= apps.length - 1) return apps; + const next = [...apps]; + [next[idx], next[idx + 1]] = [next[idx + 1], next[idx]]; + return next; + }); + }, + + async reorderApps(fromId: string, toId: string) { + if (fromId === toId) return; + await patchActiveScene((apps) => { + const fromIdx = apps.findIndex((a) => a.appId === fromId); + const toIdx = apps.findIndex((a) => a.appId === toId); + if (fromIdx === -1 || toIdx === -1) return apps; + const next = [...apps]; + const [moved] = next.splice(fromIdx, 1); + next.splice(toIdx, 0, moved); + return next; + }); + }, +}; diff --git a/apps/mana/apps/web/src/lib/types/workbench-scenes.ts b/apps/mana/apps/web/src/lib/types/workbench-scenes.ts new file mode 100644 index 000000000..3e019686e --- /dev/null +++ b/apps/mana/apps/web/src/lib/types/workbench-scenes.ts @@ -0,0 +1,36 @@ +/** + * Workbench Scenes β€” user-defined named layouts of the workbench (homepage). + * + * Each scene is a named bundle of "open apps" with their window state + * (minimized / maximized / size). Users can switch between scenes to + * quickly change context (e.g. "Home", "Deep Work", "Travel"). + * + * Scenes are persisted in the unified Mana Dexie database under the + * `workbenchScenes` table and sync cross-device via mana-sync. The + * currently active scene id is stored per-device in localStorage so + * device A doesn't yank device B into a different scene. + */ + +import type { BaseRecord } from '@mana/local-store'; + +export interface WorkbenchSceneApp { + appId: string; + minimized: boolean; + maximized?: boolean; + widthPx?: number; + heightPx?: number; +} + +/** A user-defined named layout of the workbench. */ +export interface WorkbenchScene { + id: string; + name: string; + /** Optional emoji shown in the scene tab. */ + icon?: string; + openApps: WorkbenchSceneApp[]; + /** Sort order in the scene tab bar. */ + order: number; +} + +/** Dexie row shape (adds the BaseRecord audit fields stamped by hooks). */ +export interface LocalWorkbenchScene extends BaseRecord, WorkbenchScene {} diff --git a/apps/mana/apps/web/src/routes/(app)/+page.svelte b/apps/mana/apps/web/src/routes/(app)/+page.svelte index 6d8017a34..2a9ba27bc 100644 --- a/apps/mana/apps/web/src/routes/(app)/+page.svelte +++ b/apps/mana/apps/web/src/routes/(app)/+page.svelte @@ -1,10 +1,13 @@ @@ -225,6 +185,17 @@
+ workbenchScenesStore.setActiveScene(id)} + onCreate={handleCreateScene} + onRequestRename={handleRequestRename} + onDuplicate={handleDuplicateScene} + onRequestDelete={handleRequestDeleteScene} + onReorder={(fromId, toId) => workbenchScenesStore.reorderScenes(fromId, toId)} + /> + ctxMenu.close()} /> + + (sceneDialog = null)} + /> + + (sceneToDelete = null)} + />