mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
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) <noreply@anthropic.com>
This commit is contained in:
parent
46db527f8c
commit
41357b2541
7 changed files with 1074 additions and 0 deletions
11
apps/mana/apps/web/src/lib/companion/rituals/index.ts
Normal file
11
apps/mana/apps/web/src/lib/companion/rituals/index.ts
Normal file
|
|
@ -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';
|
||||
19
apps/mana/apps/web/src/lib/companion/rituals/queries.ts
Normal file
19
apps/mana/apps/web/src/lib/companion/rituals/queries.ts
Normal file
|
|
@ -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<LocalRitual[]>(async () => {
|
||||
const all = await db.table<LocalRitual>('rituals').toArray();
|
||||
return all
|
||||
.filter((r) => r.status === 'active' && !r.deletedAt)
|
||||
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function useAllRituals() {
|
||||
return useLiveQueryWithDefault<LocalRitual[]>(async () => {
|
||||
const all = await db.table<LocalRitual>('rituals').toArray();
|
||||
return all.filter((r) => !r.deletedAt);
|
||||
}, []);
|
||||
}
|
||||
122
apps/mana/apps/web/src/lib/companion/rituals/store.ts
Normal file
122
apps/mana/apps/web/src/lib/companion/rituals/store.ts
Normal file
|
|
@ -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<LocalRitual> {
|
||||
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<LocalRitual> {
|
||||
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<LocalRitualStep, 'id' | 'ritualId' | 'createdAt'>
|
||||
): Promise<LocalRitualStep> {
|
||||
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<LocalRitualStep[]> {
|
||||
return db.table<LocalRitualStep>(STEPS).where('ritualId').equals(ritualId).sortBy('order');
|
||||
},
|
||||
|
||||
async pause(id: string): Promise<void> {
|
||||
await db.table(RITUALS).update(id, { status: 'paused', updatedAt: new Date().toISOString() });
|
||||
},
|
||||
|
||||
async resume(id: string): Promise<void> {
|
||||
await db.table(RITUALS).update(id, { status: 'active', updatedAt: new Date().toISOString() });
|
||||
},
|
||||
|
||||
async archive(id: string): Promise<void> {
|
||||
await db.table(RITUALS).update(id, { status: 'archived', updatedAt: new Date().toISOString() });
|
||||
},
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await db
|
||||
.table(RITUALS)
|
||||
.update(id, { deletedAt: new Date().toISOString(), updatedAt: new Date().toISOString() });
|
||||
},
|
||||
|
||||
// ── Logs ──────────────────────────────────────────
|
||||
|
||||
async logCompletion(ritualId: string, completedSteps: number, totalSteps: number): Promise<void> {
|
||||
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<LocalRitualLog | undefined> {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const logs = await db
|
||||
.table<LocalRitualLog>(LOGS)
|
||||
.where('[ritualId+date]')
|
||||
.equals([ritualId, today])
|
||||
.toArray();
|
||||
return logs[0];
|
||||
},
|
||||
};
|
||||
212
apps/mana/apps/web/src/lib/companion/rituals/types.ts
Normal file
212
apps/mana/apps/web/src/lib/companion/rituals/types.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
export interface NumberInputStepConfig {
|
||||
type: 'number_input';
|
||||
toolName: string;
|
||||
paramName: string;
|
||||
/** Other params to pass along */
|
||||
baseParams: Record<string, unknown>;
|
||||
unit?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
defaultValue?: number;
|
||||
}
|
||||
|
||||
export interface TextInputStepConfig {
|
||||
type: 'text_input';
|
||||
toolName: string;
|
||||
paramName: string;
|
||||
baseParams: Record<string, unknown>;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export interface MoodPickerStepConfig {
|
||||
type: 'mood_picker';
|
||||
toolName: string;
|
||||
paramName: string;
|
||||
baseParams: Record<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown> }[];
|
||||
}
|
||||
|
||||
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<LocalRitualStep, 'id' | 'ritualId' | 'createdAt'>[];
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,423 @@
|
|||
<!--
|
||||
RitualRunner — Step-by-step guided routine execution.
|
||||
|
||||
Walks through ritual steps: executes tools, collects user input,
|
||||
displays projection data. Tracks progress and logs completion.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Check, ArrowRight, Drop, Lightning, ListChecks } from '@mana/shared-icons';
|
||||
import { executeTool } from '$lib/data/tools';
|
||||
import { useDaySnapshot } from '$lib/data/projections/day-snapshot';
|
||||
import { useStreaks } from '$lib/data/projections/streaks';
|
||||
import { ritualStore } from '$lib/companion/rituals/store';
|
||||
import type { LocalRitual, LocalRitualStep } from '$lib/companion/rituals/types';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
ritual: LocalRitual;
|
||||
onComplete: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { ritual, onComplete, onClose }: Props = $props();
|
||||
|
||||
const day = useDaySnapshot();
|
||||
const streaks = useStreaks();
|
||||
|
||||
let steps = $state<LocalRitualStep[]>([]);
|
||||
let currentStepIdx = $state(0);
|
||||
let completedSteps = $state<Set<number>>(new Set());
|
||||
let stepResult = $state<string | null>(null);
|
||||
let inputValue = $state<string | number>('');
|
||||
let executing = $state(false);
|
||||
|
||||
let currentStep = $derived(steps[currentStepIdx]);
|
||||
let isLastStep = $derived(currentStepIdx >= steps.length - 1);
|
||||
let progress = $derived(steps.length > 0 ? (completedSteps.size / steps.length) * 100 : 0);
|
||||
|
||||
onMount(async () => {
|
||||
steps = await ritualStore.getSteps(ritual.id);
|
||||
});
|
||||
|
||||
async function executeCurrentStep() {
|
||||
if (!currentStep || executing) return;
|
||||
executing = true;
|
||||
stepResult = null;
|
||||
|
||||
try {
|
||||
const config = currentStep.config;
|
||||
|
||||
if (config.type === 'tool_call') {
|
||||
const result = await executeTool(config.toolName, config.params);
|
||||
stepResult = result.message;
|
||||
} else if (config.type === 'number_input') {
|
||||
const val = typeof inputValue === 'number' ? inputValue : Number(inputValue);
|
||||
if (isNaN(val)) {
|
||||
executing = false;
|
||||
return;
|
||||
}
|
||||
const params = { ...config.baseParams, [config.paramName]: val };
|
||||
const result = await executeTool(config.toolName, params);
|
||||
stepResult = result.message;
|
||||
} else if (config.type === 'text_input') {
|
||||
const val = String(inputValue).trim();
|
||||
if (!val) {
|
||||
executing = false;
|
||||
return;
|
||||
}
|
||||
const params = { ...config.baseParams, [config.paramName]: val };
|
||||
const result = await executeTool(config.toolName, params);
|
||||
stepResult = result.message;
|
||||
} else if (config.type === 'info_display') {
|
||||
// Info steps are auto-completed
|
||||
stepResult = null;
|
||||
}
|
||||
|
||||
completeStep();
|
||||
} catch (err) {
|
||||
stepResult = `Fehler: ${err instanceof Error ? err.message : String(err)}`;
|
||||
} finally {
|
||||
executing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function completeStep() {
|
||||
completedSteps = new Set([...completedSteps, currentStepIdx]);
|
||||
inputValue = '';
|
||||
}
|
||||
|
||||
function nextStep() {
|
||||
if (isLastStep) {
|
||||
ritualStore.logCompletion(ritual.id, completedSteps.size, steps.length);
|
||||
onComplete();
|
||||
return;
|
||||
}
|
||||
stepResult = null;
|
||||
currentStepIdx++;
|
||||
// Auto-complete info_display steps
|
||||
if (currentStep?.config.type === 'info_display') {
|
||||
completeStep();
|
||||
}
|
||||
}
|
||||
|
||||
function skipStep() {
|
||||
nextStep();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if steps.length === 0}
|
||||
<div class="loading">Lade Ritual...</div>
|
||||
{:else}
|
||||
<div class="ritual-runner">
|
||||
<!-- Header -->
|
||||
<div class="ritual-header">
|
||||
<h3>{ritual.title}</h3>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style:width="{progress}%"></div>
|
||||
</div>
|
||||
<span class="step-counter">{completedSteps.size} / {steps.length}</span>
|
||||
</div>
|
||||
|
||||
<!-- Current Step -->
|
||||
{#if currentStep}
|
||||
<div class="step-card">
|
||||
<div class="step-label">{currentStep.label}</div>
|
||||
|
||||
{#if currentStep.config.type === 'tool_call'}
|
||||
{#if completedSteps.has(currentStepIdx)}
|
||||
<div class="step-done"><Check size={20} weight="bold" /> {stepResult}</div>
|
||||
{:else}
|
||||
<button class="step-action" onclick={executeCurrentStep} disabled={executing}>
|
||||
<Lightning size={16} weight="bold" /> Ausfuehren
|
||||
</button>
|
||||
{/if}
|
||||
{:else if currentStep.config.type === 'number_input'}
|
||||
{#if completedSteps.has(currentStepIdx)}
|
||||
<div class="step-done"><Check size={20} weight="bold" /> {stepResult}</div>
|
||||
{:else}
|
||||
<div class="input-row">
|
||||
<input
|
||||
type="number"
|
||||
class="step-input"
|
||||
bind:value={inputValue}
|
||||
min={currentStep.config.min}
|
||||
max={currentStep.config.max}
|
||||
placeholder={String(currentStep.config.defaultValue ?? '')}
|
||||
/>
|
||||
{#if currentStep.config.unit}
|
||||
<span class="input-unit">{currentStep.config.unit}</span>
|
||||
{/if}
|
||||
<button class="step-action" onclick={executeCurrentStep} disabled={executing}>
|
||||
Loggen
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if currentStep.config.type === 'text_input'}
|
||||
{#if completedSteps.has(currentStepIdx)}
|
||||
<div class="step-done"><Check size={20} weight="bold" /> {stepResult}</div>
|
||||
{:else}
|
||||
<div class="input-row">
|
||||
<input
|
||||
type="text"
|
||||
class="step-input text"
|
||||
bind:value={inputValue}
|
||||
placeholder={currentStep.config.placeholder ?? ''}
|
||||
/>
|
||||
<button class="step-action" onclick={executeCurrentStep} disabled={executing}>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if currentStep.config.type === 'info_display'}
|
||||
<div class="info-card">
|
||||
{#if currentStep.config.source === 'tasks_today'}
|
||||
{#if day.value.tasks.dueToday.length > 0}
|
||||
{#each day.value.tasks.dueToday as t}
|
||||
<div class="info-item">• {t.title}</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="info-empty">Keine Tasks faellig heute</div>
|
||||
{/if}
|
||||
{:else if currentStep.config.source === 'events_today'}
|
||||
{#if day.value.events.upcoming.length > 0}
|
||||
{#each day.value.events.upcoming as e}
|
||||
<div class="info-item">• {e.title}</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="info-empty">Keine Termine heute</div>
|
||||
{/if}
|
||||
{:else if currentStep.config.source === 'drink_progress'}
|
||||
<div class="info-item">
|
||||
Wasser: {day.value.drinks.water.ml}ml / {day.value.drinks.water.goal}ml ({day.value
|
||||
.drinks.water.percent}%)
|
||||
</div>
|
||||
<div class="info-item">Gesamt: {day.value.drinks.total.count} Getraenke</div>
|
||||
{:else if currentStep.config.source === 'nutrition_progress'}
|
||||
<div class="info-item">
|
||||
Kalorien: {day.value.nutrition.calories.actual} / {day.value.nutrition.calories
|
||||
.goal} kcal
|
||||
</div>
|
||||
<div class="info-item">Mahlzeiten: {day.value.nutrition.meals}</div>
|
||||
{:else if currentStep.config.source === 'streaks'}
|
||||
{#each streaks.value as s}
|
||||
<div class="info-item">
|
||||
{s.label}: {s.currentStreak} Tage
|
||||
{#if s.status === 'at_risk'}(gefaehrdet){/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="step-nav">
|
||||
<button class="nav-skip" onclick={skipStep}>
|
||||
{completedSteps.has(currentStepIdx) || currentStep.config.type === 'info_display'
|
||||
? ''
|
||||
: 'Ueberspringen'}
|
||||
</button>
|
||||
<button class="nav-next" onclick={nextStep}>
|
||||
{isLastStep ? 'Fertig' : 'Weiter'}
|
||||
<ArrowRight size={16} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Close -->
|
||||
<button class="close-btn" onclick={onClose}>Abbrechen</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.ritual-runner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
padding: 1.5rem;
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.ritual-header h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 4px;
|
||||
background: hsl(var(--color-muted) / 0.3);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: hsl(var(--color-primary));
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.step-counter {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.step-card {
|
||||
background: hsl(var(--color-card));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.step-label {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.step-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 9999px;
|
||||
border: none;
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.step-action:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.step-action:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.step-done {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: hsl(var(--color-success, 142 71% 45%));
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.step-input {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1.5px solid hsl(var(--color-border));
|
||||
background: hsl(var(--color-background));
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
.step-input.text {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.step-input:focus {
|
||||
border-color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.input-unit {
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.info-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.info-empty {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.step-nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-skip {
|
||||
border: none;
|
||||
background: none;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.nav-skip:hover {
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.nav-next {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 9999px;
|
||||
border: none;
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
color: hsl(var(--color-primary));
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.nav-next:hover {
|
||||
background: hsl(var(--color-primary) / 0.2);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
align-self: center;
|
||||
border: none;
|
||||
background: none;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
padding: 0.375rem 0.75rem;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,283 @@
|
|||
<script lang="ts">
|
||||
import { Plus, Play, Pause, Trash } from '@mana/shared-icons';
|
||||
import RitualRunner from '$lib/modules/companion/components/RitualRunner.svelte';
|
||||
import {
|
||||
ritualStore,
|
||||
useActiveRituals,
|
||||
useAllRituals,
|
||||
RITUAL_TEMPLATES,
|
||||
} from '$lib/companion/rituals';
|
||||
import type { LocalRitual } from '$lib/companion/rituals/types';
|
||||
|
||||
const rituals = useAllRituals();
|
||||
|
||||
let activeRitual = $state<LocalRitual | null>(null);
|
||||
let showTemplates = $state(false);
|
||||
|
||||
async function createFromTemplate(templateId: string) {
|
||||
const template = RITUAL_TEMPLATES.find((t) => t.id === templateId);
|
||||
if (!template) return;
|
||||
await ritualStore.createFromTemplate(template);
|
||||
showTemplates = false;
|
||||
}
|
||||
|
||||
function startRitual(ritual: LocalRitual) {
|
||||
activeRitual = ritual;
|
||||
}
|
||||
|
||||
function handleComplete() {
|
||||
activeRitual = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Rituale - Mana Companion</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if activeRitual}
|
||||
<RitualRunner
|
||||
ritual={activeRitual}
|
||||
onComplete={handleComplete}
|
||||
onClose={() => (activeRitual = null)}
|
||||
/>
|
||||
{:else}
|
||||
<div class="rituals-page">
|
||||
<div class="page-header">
|
||||
<h2>Rituale</h2>
|
||||
<button class="add-btn" onclick={() => (showTemplates = !showTemplates)}>
|
||||
<Plus size={16} weight="bold" /> Neu
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showTemplates}
|
||||
<div class="templates">
|
||||
<h3>Vorlage waehlen</h3>
|
||||
{#each RITUAL_TEMPLATES as tpl}
|
||||
<button class="template-card" onclick={() => createFromTemplate(tpl.id)}>
|
||||
<span class="tpl-title">{tpl.title}</span>
|
||||
<span class="tpl-desc">{tpl.description}</span>
|
||||
<span class="tpl-trigger"
|
||||
>{tpl.trigger === 'morning'
|
||||
? 'Morgens'
|
||||
: tpl.trigger === 'evening'
|
||||
? 'Abends'
|
||||
: 'Manuell'}</span
|
||||
>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="ritual-list">
|
||||
{#each rituals.value as ritual (ritual.id)}
|
||||
<div class="ritual-card">
|
||||
<div class="ritual-info">
|
||||
<span class="ritual-title">{ritual.title}</span>
|
||||
{#if ritual.description}
|
||||
<span class="ritual-desc">{ritual.description}</span>
|
||||
{/if}
|
||||
<span class="ritual-trigger">
|
||||
{ritual.trigger === 'morning'
|
||||
? 'Morgens'
|
||||
: ritual.trigger === 'evening'
|
||||
? 'Abends'
|
||||
: 'Manuell'}
|
||||
· {ritual.status === 'active' ? 'Aktiv' : 'Pausiert'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="ritual-actions">
|
||||
{#if ritual.status === 'active'}
|
||||
<button class="action-btn play" onclick={() => startRitual(ritual)} title="Starten">
|
||||
<Play size={16} weight="fill" />
|
||||
</button>
|
||||
<button
|
||||
class="action-btn"
|
||||
onclick={() => ritualStore.pause(ritual.id)}
|
||||
title="Pausieren"
|
||||
>
|
||||
<Pause size={14} weight="bold" />
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="action-btn"
|
||||
onclick={() => ritualStore.resume(ritual.id)}
|
||||
title="Fortsetzen"
|
||||
>
|
||||
<Play size={14} weight="bold" />
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="action-btn danger"
|
||||
onclick={() => ritualStore.delete(ritual.id)}
|
||||
title="Loeschen"
|
||||
>
|
||||
<Trash size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="empty">Noch keine Rituale. Erstelle eins aus einer Vorlage.</p>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.rituals-page {
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 9999px;
|
||||
border: none;
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.templates {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.templates h3 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.template-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1.5px solid hsl(var(--color-border));
|
||||
border-radius: 0.75rem;
|
||||
background: hsl(var(--color-card));
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
margin-bottom: 0.5rem;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.template-card:hover {
|
||||
border-color: hsl(var(--color-primary) / 0.5);
|
||||
background: hsl(var(--color-primary) / 0.03);
|
||||
}
|
||||
|
||||
.tpl-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.tpl-desc {
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.tpl-trigger {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-primary));
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ritual-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.ritual-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.75rem;
|
||||
background: hsl(var(--color-card));
|
||||
}
|
||||
|
||||
.ritual-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.ritual-title {
|
||||
font-weight: 500;
|
||||
font-size: 0.9375rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.ritual-desc {
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.ritual-trigger {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.ritual-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: hsl(var(--color-muted) / 0.2);
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: hsl(var(--color-surface-hover));
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.action-btn.play {
|
||||
background: hsl(var(--color-primary) / 0.15);
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
.action-btn.play:hover {
|
||||
background: hsl(var(--color-primary) / 0.25);
|
||||
}
|
||||
.action-btn.danger:hover {
|
||||
background: hsl(var(--color-error) / 0.15);
|
||||
color: hsl(var(--color-error));
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.875rem;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue