From 41357b2541e8ddf73e694e694a6597f34cbc1463 Mon Sep 17 00:00:00 2001 From: Till JS Date: Mon, 13 Apr 2026 22:01:37 +0200 Subject: [PATCH] feat(brain): add Ritual system with guided routines and step execution Phase 6 of the Companion Brain. Introduces guided routines ("rituals") that walk users through multi-step sequences, executing tools and displaying projection data at each step. Data layer (companion/rituals/): - LocalRitual + LocalRitualStep + LocalRitualLog types - 6 step types: tool_call, number_input, text_input, mood_picker, info_display, checklist - 3 templates: Morning routine (water + events + tasks + streaks), Evening routine (progress + reflection), Hydration check - Store with createFromTemplate, CRUD, step management, completion logs - Reactive queries for active/all rituals UI: - RitualRunner.svelte: step-by-step card UI with progress bar, tool execution, number/text input, projection data display, skip/next navigation - /companion/rituals route: ritual list, template picker, play/pause Adds rituals + ritualSteps + ritualLogs tables (v10 schema). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../web/src/lib/companion/rituals/index.ts | 11 + .../web/src/lib/companion/rituals/queries.ts | 19 + .../web/src/lib/companion/rituals/store.ts | 122 +++++ .../web/src/lib/companion/rituals/types.ts | 212 +++++++++ apps/mana/apps/web/src/lib/data/database.ts | 4 + .../companion/components/RitualRunner.svelte | 423 ++++++++++++++++++ .../(app)/companion/rituals/+page.svelte | 283 ++++++++++++ 7 files changed, 1074 insertions(+) create mode 100644 apps/mana/apps/web/src/lib/companion/rituals/index.ts create mode 100644 apps/mana/apps/web/src/lib/companion/rituals/queries.ts create mode 100644 apps/mana/apps/web/src/lib/companion/rituals/store.ts create mode 100644 apps/mana/apps/web/src/lib/companion/rituals/types.ts create mode 100644 apps/mana/apps/web/src/lib/modules/companion/components/RitualRunner.svelte create mode 100644 apps/mana/apps/web/src/routes/(app)/companion/rituals/+page.svelte diff --git a/apps/mana/apps/web/src/lib/companion/rituals/index.ts b/apps/mana/apps/web/src/lib/companion/rituals/index.ts new file mode 100644 index 000000000..71c53c32d --- /dev/null +++ b/apps/mana/apps/web/src/lib/companion/rituals/index.ts @@ -0,0 +1,11 @@ +export { ritualStore } from './store'; +export { useActiveRituals, useAllRituals } from './queries'; +export { RITUAL_TEMPLATES } from './types'; +export type { + LocalRitual, + LocalRitualStep, + LocalRitualLog, + RitualTemplate, + RitualStepType, + RitualStepConfig, +} from './types'; diff --git a/apps/mana/apps/web/src/lib/companion/rituals/queries.ts b/apps/mana/apps/web/src/lib/companion/rituals/queries.ts new file mode 100644 index 000000000..c5276c8d8 --- /dev/null +++ b/apps/mana/apps/web/src/lib/companion/rituals/queries.ts @@ -0,0 +1,19 @@ +import { useLiveQueryWithDefault } from '@mana/local-store/svelte'; +import { db } from '$lib/data/database'; +import type { LocalRitual } from './types'; + +export function useActiveRituals() { + return useLiveQueryWithDefault(async () => { + const all = await db.table('rituals').toArray(); + return all + .filter((r) => r.status === 'active' && !r.deletedAt) + .sort((a, b) => a.createdAt.localeCompare(b.createdAt)); + }, []); +} + +export function useAllRituals() { + return useLiveQueryWithDefault(async () => { + const all = await db.table('rituals').toArray(); + return all.filter((r) => !r.deletedAt); + }, []); +} diff --git a/apps/mana/apps/web/src/lib/companion/rituals/store.ts b/apps/mana/apps/web/src/lib/companion/rituals/store.ts new file mode 100644 index 000000000..2c7661c73 --- /dev/null +++ b/apps/mana/apps/web/src/lib/companion/rituals/store.ts @@ -0,0 +1,122 @@ +/** + * Ritual Store — CRUD for rituals, steps, and execution logs. + */ + +import { db } from '$lib/data/database'; +import type { LocalRitual, LocalRitualStep, LocalRitualLog, RitualTemplate } from './types'; + +const RITUALS = 'rituals'; +const STEPS = 'ritualSteps'; +const LOGS = 'ritualLogs'; + +export const ritualStore = { + async createFromTemplate(template: RitualTemplate): Promise { + const now = new Date().toISOString(); + const ritual: LocalRitual = { + id: crypto.randomUUID(), + title: template.title, + description: template.description, + trigger: template.trigger, + status: 'active', + createdAt: now, + updatedAt: now, + }; + await db.table(RITUALS).add(ritual); + + // Create steps + for (const stepDef of template.steps) { + const step: LocalRitualStep = { + id: crypto.randomUUID(), + ritualId: ritual.id, + order: stepDef.order, + type: stepDef.type, + label: stepDef.label, + config: stepDef.config, + createdAt: now, + }; + await db.table(STEPS).add(step); + } + + return ritual; + }, + + async create(input: { + title: string; + description?: string; + trigger: LocalRitual['trigger']; + }): Promise { + const now = new Date().toISOString(); + const ritual: LocalRitual = { + id: crypto.randomUUID(), + title: input.title, + description: input.description, + trigger: input.trigger, + status: 'active', + createdAt: now, + updatedAt: now, + }; + await db.table(RITUALS).add(ritual); + return ritual; + }, + + async addStep( + ritualId: string, + step: Omit + ): Promise { + const newStep: LocalRitualStep = { + id: crypto.randomUUID(), + ritualId, + ...step, + createdAt: new Date().toISOString(), + }; + await db.table(STEPS).add(newStep); + return newStep; + }, + + async getSteps(ritualId: string): Promise { + return db.table(STEPS).where('ritualId').equals(ritualId).sortBy('order'); + }, + + async pause(id: string): Promise { + await db.table(RITUALS).update(id, { status: 'paused', updatedAt: new Date().toISOString() }); + }, + + async resume(id: string): Promise { + await db.table(RITUALS).update(id, { status: 'active', updatedAt: new Date().toISOString() }); + }, + + async archive(id: string): Promise { + await db.table(RITUALS).update(id, { status: 'archived', updatedAt: new Date().toISOString() }); + }, + + async delete(id: string): Promise { + await db + .table(RITUALS) + .update(id, { deletedAt: new Date().toISOString(), updatedAt: new Date().toISOString() }); + }, + + // ── Logs ────────────────────────────────────────── + + async logCompletion(ritualId: string, completedSteps: number, totalSteps: number): Promise { + const now = new Date().toISOString(); + const log: LocalRitualLog = { + ritualId, + date: now.split('T')[0], + completedSteps, + totalSteps, + completedAt: completedSteps >= totalSteps ? now : undefined, + createdAt: now, + }; + await db.table(LOGS).add(log); + }, + + async getTodayLog(ritualId: string): Promise { + const today = new Date().toISOString().split('T')[0]; + const logs = await db + .table(LOGS) + .where('[ritualId+date]') + .equals([ritualId, today]) + .toArray(); + return logs[0]; + }, +}; diff --git a/apps/mana/apps/web/src/lib/companion/rituals/types.ts b/apps/mana/apps/web/src/lib/companion/rituals/types.ts new file mode 100644 index 000000000..d7091ed85 --- /dev/null +++ b/apps/mana/apps/web/src/lib/companion/rituals/types.ts @@ -0,0 +1,212 @@ +/** + * Ritual types — Guided routines that write data into modules. + * + * A ritual is a sequence of steps. Each step either executes a tool + * (e.g. log_drink), collects user input (free text, mood picker, + * number), or displays information (DaySnapshot data). + */ + +export interface LocalRitual { + id: string; + title: string; + description?: string; + /** When this ritual should trigger ('morning', 'evening', 'manual') */ + trigger: 'morning' | 'evening' | 'manual'; + status: 'active' | 'paused' | 'archived'; + createdAt: string; + updatedAt: string; + deletedAt?: string; +} + +export interface LocalRitualStep { + id: string; + ritualId: string; + order: number; + /** Step type determines the UI and behavior */ + type: RitualStepType; + /** Human-readable label shown to the user */ + label: string; + /** Configuration depends on type */ + config: RitualStepConfig; + createdAt: string; +} + +export type RitualStepType = + | 'tool_call' // Execute a tool with preset params + | 'number_input' // User enters a number → tool call + | 'text_input' // User enters text → tool call + | 'mood_picker' // User picks mood (1-5) → tool call + | 'info_display' // Show data from projections (read-only) + | 'checklist'; // Multiple items to check off + +export type RitualStepConfig = + | ToolCallStepConfig + | NumberInputStepConfig + | TextInputStepConfig + | MoodPickerStepConfig + | InfoDisplayStepConfig + | ChecklistStepConfig; + +export interface ToolCallStepConfig { + type: 'tool_call'; + toolName: string; + params: Record; +} + +export interface NumberInputStepConfig { + type: 'number_input'; + toolName: string; + paramName: string; + /** Other params to pass along */ + baseParams: Record; + unit?: string; + min?: number; + max?: number; + defaultValue?: number; +} + +export interface TextInputStepConfig { + type: 'text_input'; + toolName: string; + paramName: string; + baseParams: Record; + placeholder?: string; +} + +export interface MoodPickerStepConfig { + type: 'mood_picker'; + toolName: string; + paramName: string; + baseParams: Record; +} + +export interface InfoDisplayStepConfig { + type: 'info_display'; + /** Which projection data to show */ + source: 'tasks_today' | 'events_today' | 'drink_progress' | 'nutrition_progress' | 'streaks'; +} + +export interface ChecklistStepConfig { + type: 'checklist'; + items: { label: string; toolName?: string; toolParams?: Record }[]; +} + +export interface LocalRitualLog { + id?: number; + ritualId: string; + date: string; // YYYY-MM-DD + completedSteps: number; + totalSteps: number; + completedAt?: string; + createdAt: string; +} + +// ── Templates ─────────────────────────────────────── + +export interface RitualTemplate { + id: string; + title: string; + description: string; + trigger: LocalRitual['trigger']; + steps: Omit[]; +} + +export const RITUAL_TEMPLATES: RitualTemplate[] = [ + { + id: 'tpl-morning', + title: 'Morgenroutine', + description: 'Starte den Tag mit Wasser, Tagesueberblick und Prioritaeten', + trigger: 'morning', + steps: [ + { + order: 0, + type: 'tool_call', + label: 'Glas Wasser trinken', + config: { + type: 'tool_call', + toolName: 'log_drink', + params: { drinkType: 'water', quantityMl: 250, name: 'Wasser' }, + }, + }, + { + order: 1, + type: 'info_display', + label: 'Dein Tag auf einen Blick', + config: { type: 'info_display', source: 'events_today' }, + }, + { + order: 2, + type: 'info_display', + label: 'Heutige Tasks', + config: { type: 'info_display', source: 'tasks_today' }, + }, + { + order: 3, + type: 'info_display', + label: 'Deine Streaks', + config: { type: 'info_display', source: 'streaks' }, + }, + ], + }, + { + id: 'tpl-evening', + title: 'Abendroutine', + description: 'Reflektiere den Tag und plane morgen', + trigger: 'evening', + steps: [ + { + order: 0, + type: 'info_display', + label: 'Tages-Zusammenfassung', + config: { type: 'info_display', source: 'drink_progress' }, + }, + { + order: 1, + type: 'info_display', + label: 'Ernaehrung heute', + config: { type: 'info_display', source: 'nutrition_progress' }, + }, + { + order: 2, + type: 'text_input', + label: 'Was war heute gut?', + config: { + type: 'text_input', + toolName: 'create_task', + paramName: 'title', + baseParams: {}, + placeholder: 'z.B. Gutes Gespraech mit Anna...', + }, + }, + ], + }, + { + id: 'tpl-hydration', + title: 'Trink-Check', + description: 'Schneller Wasser-Check und Nachloggen', + trigger: 'manual', + steps: [ + { + order: 0, + type: 'info_display', + label: 'Wasser-Fortschritt', + config: { type: 'info_display', source: 'drink_progress' }, + }, + { + order: 1, + type: 'number_input', + label: 'Wasser nachloggen', + config: { + type: 'number_input', + toolName: 'log_drink', + paramName: 'quantityMl', + baseParams: { drinkType: 'water', name: 'Wasser' }, + unit: 'ml', + min: 100, + max: 1000, + defaultValue: 250, + }, + }, + ], + }, +]; diff --git a/apps/mana/apps/web/src/lib/data/database.ts b/apps/mana/apps/web/src/lib/data/database.ts index 9ecf7e44f..c454f201a 100644 --- a/apps/mana/apps/web/src/lib/data/database.ts +++ b/apps/mana/apps/web/src/lib/data/database.ts @@ -432,6 +432,10 @@ db.version(10).stores({ _nudgeOutcomes: '++id, nudgeId, nudgeType, outcome, timestamp, [nudgeType+outcome]', companionConversations: 'id, createdAt', companionMessages: 'id, conversationId, role, createdAt, [conversationId+createdAt]', + // Rituals + rituals: 'id, status, createdAt', + ritualSteps: 'id, ritualId, order, [ritualId+order]', + ritualLogs: '++id, ritualId, date, [ritualId+date]', }); // Schema version 11 — adds the Mail module (local draft cache). diff --git a/apps/mana/apps/web/src/lib/modules/companion/components/RitualRunner.svelte b/apps/mana/apps/web/src/lib/modules/companion/components/RitualRunner.svelte new file mode 100644 index 000000000..89aca1512 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/companion/components/RitualRunner.svelte @@ -0,0 +1,423 @@ + + + +{#if steps.length === 0} +
Lade Ritual...
+{:else} +
+ +
+

{ritual.title}

+
+
+
+ {completedSteps.size} / {steps.length} +
+ + + {#if currentStep} +
+
{currentStep.label}
+ + {#if currentStep.config.type === 'tool_call'} + {#if completedSteps.has(currentStepIdx)} +
{stepResult}
+ {:else} + + {/if} + {:else if currentStep.config.type === 'number_input'} + {#if completedSteps.has(currentStepIdx)} +
{stepResult}
+ {:else} +
+ + {#if currentStep.config.unit} + {currentStep.config.unit} + {/if} + +
+ {/if} + {:else if currentStep.config.type === 'text_input'} + {#if completedSteps.has(currentStepIdx)} +
{stepResult}
+ {:else} +
+ + +
+ {/if} + {:else if currentStep.config.type === 'info_display'} +
+ {#if currentStep.config.source === 'tasks_today'} + {#if day.value.tasks.dueToday.length > 0} + {#each day.value.tasks.dueToday as t} +
• {t.title}
+ {/each} + {:else} +
Keine Tasks faellig heute
+ {/if} + {:else if currentStep.config.source === 'events_today'} + {#if day.value.events.upcoming.length > 0} + {#each day.value.events.upcoming as e} +
• {e.title}
+ {/each} + {:else} +
Keine Termine heute
+ {/if} + {:else if currentStep.config.source === 'drink_progress'} +
+ Wasser: {day.value.drinks.water.ml}ml / {day.value.drinks.water.goal}ml ({day.value + .drinks.water.percent}%) +
+
Gesamt: {day.value.drinks.total.count} Getraenke
+ {:else if currentStep.config.source === 'nutrition_progress'} +
+ Kalorien: {day.value.nutrition.calories.actual} / {day.value.nutrition.calories + .goal} kcal +
+
Mahlzeiten: {day.value.nutrition.meals}
+ {:else if currentStep.config.source === 'streaks'} + {#each streaks.value as s} +
+ {s.label}: {s.currentStreak} Tage + {#if s.status === 'at_risk'}(gefaehrdet){/if} +
+ {/each} + {/if} +
+ {/if} +
+ + +
+ + +
+ {/if} + + + +
+{/if} + + diff --git a/apps/mana/apps/web/src/routes/(app)/companion/rituals/+page.svelte b/apps/mana/apps/web/src/routes/(app)/companion/rituals/+page.svelte new file mode 100644 index 000000000..e6b7453a2 --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/companion/rituals/+page.svelte @@ -0,0 +1,283 @@ + + + + Rituale - Mana Companion + + +{#if activeRitual} + (activeRitual = null)} + /> +{:else} +
+ + + {#if showTemplates} +
+

Vorlage waehlen

+ {#each RITUAL_TEMPLATES as tpl} + + {/each} +
+ {/if} + +
+ {#each rituals.value as ritual (ritual.id)} +
+
+ {ritual.title} + {#if ritual.description} + {ritual.description} + {/if} + + {ritual.trigger === 'morning' + ? 'Morgens' + : ritual.trigger === 'evening' + ? 'Abends' + : 'Manuell'} + · {ritual.status === 'active' ? 'Aktiv' : 'Pausiert'} + +
+
+ {#if ritual.status === 'active'} + + + {:else} + + {/if} + +
+
+ {:else} +

Noch keine Rituale. Erstelle eins aus einer Vorlage.

+ {/each} +
+
+{/if} + +