From b841a24e7316f81af0f9e8f4737b1b030576f70d Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 8 Apr 2026 16:01:50 +0200 Subject: [PATCH] feat(todo): voice quick-add in workbench ListView via shared MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Speak a task and it lands in the list as a placeholder while mana-stt transcribes it; the title swaps in once the transcript returns. No date/priority/label parsing yet — that's a follow-up that needs an LLM pass over the transcript. For now the whole transcript becomes the task title and the user can edit inline. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../web/src/lib/modules/todo/ListView.svelte | 12 +++++ .../lib/modules/todo/stores/tasks.svelte.ts | 48 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/apps/mana/apps/web/src/lib/modules/todo/ListView.svelte b/apps/mana/apps/web/src/lib/modules/todo/ListView.svelte index 8dc4a8804..42d7e0b3d 100644 --- a/apps/mana/apps/web/src/lib/modules/todo/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/todo/ListView.svelte @@ -20,6 +20,7 @@ import { dropTarget, dragSource } from '@mana/shared-ui/dnd'; import type { TagDragData } from '@mana/shared-ui/dnd'; import { useAllTags, getTagsByIds } from '@mana/shared-stores'; + import VoiceCaptureBar from '$lib/components/voice/VoiceCaptureBar.svelte'; let { navigate, goBack, params }: ViewProps = $props(); @@ -68,6 +69,10 @@ newTitle = ''; } + async function handleVoiceComplete(blob: Blob, durationMs: number) { + await tasksStore.createFromVoice(blob, durationMs, 'de'); + } + // Context menu let ctxMenu = $state<{ visible: boolean; @@ -160,6 +165,13 @@ + +
{#each filtered() as task (task.id)} {@const taskTagIds = getTaskTagIds(task)} diff --git a/apps/mana/apps/web/src/lib/modules/todo/stores/tasks.svelte.ts b/apps/mana/apps/web/src/lib/modules/todo/stores/tasks.svelte.ts index 778cd0c8d..c7dd7ff55 100644 --- a/apps/mana/apps/web/src/lib/modules/todo/stores/tasks.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/todo/stores/tasks.svelte.ts @@ -83,6 +83,54 @@ export const tasksStore = { return plaintextSnapshot; }, + /** + * Create a task from a voice recording. Inserts a placeholder task + * immediately so the user sees instant feedback in the list, then + * fills in the real title once mana-stt returns the transcript. + * + * No date/priority parsing yet — that needs an LLM pass and is its + * own follow-up. The user can edit the task inline like any other. + */ + async createFromVoice(blob: Blob, _durationMs: number, language = 'de') { + const placeholder = await this.createTask({ title: 'Sprachaufgabe wird transkribiert…' }); + void this.transcribeIntoTask(placeholder.id, blob, language); + return placeholder; + }, + + /** + * Upload an audio blob to /api/v1/voice/transcribe and write the + * transcript into an existing task as the new title. On failure, + * surfaces the error inline so the user isn't left with the + * "wird transkribiert…" placeholder forever. + */ + async transcribeIntoTask(taskId: string, blob: Blob, language?: string): Promise { + try { + const form = new FormData(); + const ext = blob.type.includes('webm') + ? '.webm' + : blob.type.includes('mp4') + ? '.m4a' + : '.audio'; + form.append('file', blob, `task${ext}`); + if (language) form.append('language', language); + + const response = await fetch('/api/v1/voice/transcribe', { + method: 'POST', + body: form, + }); + if (!response.ok) { + const text = await response.text(); + throw new Error(text || `HTTP ${response.status}`); + } + const result = (await response.json()) as { text: string }; + const transcript = (result.text ?? '').trim() || 'Sprachaufgabe'; + await this.updateTask(taskId, { title: transcript }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + await this.updateTask(taskId, { title: `Sprachaufgabe (Fehler: ${msg})` }); + } + }, + async updateTask(id: string, data: Record) { const raw = await taskTable.get(id); if (!raw) return;