feat(rituals): rename ai-rituals → rituals, add ceremony step types

The module was named "ai-rituals" because every step was a tool call
(log drink, show tasks, create task from text input). That framing
excluded a whole class of rituals that *don't* capture data —
personal ceremonies that just want to hold the user's attention for a
minute: the morning coffee, the Sunday reset, the before-bed shutdown.

Changes:

  - Renamed the module: apps/web/src/lib/modules/ai-rituals → rituals
  - App id 'ai-rituals' → 'rituals' in app-registry/apps.ts
  - Moved the category from 'ai' to 'life' in app-registry/categories.ts
    (personal practice, not an AI subsystem)
  - Added RitualCategory = 'utility' | 'ceremony' | 'mixed' on both
    LocalRitual and RitualTemplate. Defaults to 'utility' on read so
    existing data from before this change stays accessible.
  - 3 new step types in the RitualStepConfig union:
      - presence : markdown body + optional countdown, no tool call.
                   Use case: "Fünf Minuten still trinken."
      - breath   : guided breathing with a circle that expands/contracts
                   on inhale/exhale. Presets: box (4-4-4-4), 4-7-8,
                   coherent (5-0-5-0), plus custom timings.
      - media    : image + caption (mantra / photo / quote) with
                   optional linger timer.
  - RitualRunner extended: timer teardown on step change, breath state
    machine with phase-driven scaling animation, stop/early-exit for
    both.
  - 3 ceremony templates seeded:
      - Morgenkaffee    : Wasser → Aufbrühen → 3 tiefe Atemzüge →
                          5 Min still trinken
      - Sonntag-Reset   : Ankommen → Streaks → Was nehme ich mit? →
                          Nächste Woche → Handy weg (mixed)
      - Vor dem Schlaf  : Bildschirme aus → 4-7-8 Atmung → Journal-
                          Eintrag → Loslassen
  - ListView: category filter chips (Alle / Utility / Zeremoniell),
    templates grouped by category in the picker, category pill on each
    ritual row (hidden for the default 'utility').
  - docs/MODULE_REGISTRY.md: moved from AI-System (now 8) to Gesundheit
    & Wellness (now 11).

No schema migration — the new `category` field is optional on
LocalRitual and falls back to 'utility' when undefined, so Dexie
doesn't need a version bump. Existing rituals (none in production)
keep working.

Heads-up for scenes: anyone who had 'ai-rituals' pinned to a workbench
scene will need to re-add it as 'rituals'. Acceptable given
pre-launch state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-17 14:41:26 +02:00
parent a2423b4932
commit 2df9ecdcaa
8 changed files with 813 additions and 188 deletions

View file

@ -1041,12 +1041,12 @@ registerApp({
}); });
registerApp({ registerApp({
id: 'ai-rituals', id: 'rituals',
name: 'AI Rituale', name: 'Rituale',
color: '#EC4899', color: '#EC4899',
icon: Lightning, icon: Lightning,
views: { views: {
list: { load: () => import('$lib/modules/ai-rituals/ListView.svelte') }, list: { load: () => import('$lib/modules/rituals/ListView.svelte') },
}, },
}); });

View file

@ -47,7 +47,6 @@ export const APP_CATEGORY_MAP: Record<string, AppCategory> = {
'ai-missions': 'ai', 'ai-missions': 'ai',
'ai-agents': 'ai', 'ai-agents': 'ai',
'ai-workbench': 'ai', 'ai-workbench': 'ai',
'ai-rituals': 'ai',
'ai-policy': 'ai', 'ai-policy': 'ai',
'ai-insights': 'ai', 'ai-insights': 'ai',
'ai-health': 'ai', 'ai-health': 'ai',
@ -67,6 +66,7 @@ export const APP_CATEGORY_MAP: Record<string, AppCategory> = {
dreams: 'life', dreams: 'life',
drink: 'life', drink: 'life',
meditate: 'life', meditate: 'life',
rituals: 'life',
journal: 'life', journal: 'life',
food: 'life', food: 'life',
recipes: 'life', recipes: 'life',

View file

@ -17,6 +17,7 @@ export const ritualStore = {
title: template.title, title: template.title,
description: template.description, description: template.description,
trigger: template.trigger, trigger: template.trigger,
category: template.category ?? 'utility',
status: 'active', status: 'active',
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@ -44,6 +45,7 @@ export const ritualStore = {
title: string; title: string;
description?: string; description?: string;
trigger: LocalRitual['trigger']; trigger: LocalRitual['trigger'];
category?: LocalRitual['category'];
}): Promise<LocalRitual> { }): Promise<LocalRitual> {
const now = new Date().toISOString(); const now = new Date().toISOString();
const ritual: LocalRitual = { const ritual: LocalRitual = {
@ -51,6 +53,7 @@ export const ritualStore = {
title: input.title, title: input.title,
description: input.description, description: input.description,
trigger: input.trigger, trigger: input.trigger,
category: input.category ?? 'utility',
status: 'active', status: 'active',
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,

View file

@ -1,17 +1,35 @@
/** /**
* Ritual types Guided routines that write data into modules. * Ritual types Guided sequences, either utility (data capture) or
* ceremony (presence/meaning) or a mix of both.
* *
* A ritual is a sequence of steps. Each step either executes a tool * A ritual is an ordered list of steps. Steps come in two flavours:
* (e.g. log_drink), collects user input (free text, mood picker, * - Utility: tool_call / number_input / text_input / mood_picker /
* number), or displays information (DaySnapshot data). * info_display / checklist each writes data into other
* modules (drink, food, tasks, ) via the AI tool layer.
* - Ceremony: presence / breath / media no tool calls, no data
* written. They exist to hold the user's attention on
* something for a moment (a timer, a breath pattern, an
* image). Useful for morning coffee, Sunday reset,
* before-bed shutdown.
*
* `category` on the ritual itself is a hint for filtering and UI intent,
* not a hard constraint a ritual can mix utility + ceremony steps.
*/ */
export type RitualCategory = 'utility' | 'ceremony' | 'mixed';
export interface LocalRitual { export interface LocalRitual {
id: string; id: string;
title: string; title: string;
description?: string; description?: string;
/** When this ritual should trigger ('morning', 'evening', 'manual') */ /** When this ritual should trigger ('morning', 'evening', 'manual') */
trigger: 'morning' | 'evening' | 'manual'; trigger: 'morning' | 'evening' | 'manual';
/**
* UI hint: is this more "log my state" (utility) or "hold my attention"
* (ceremony)? Defaults to 'utility' for backward compatibility with the
* original ai-rituals design where every ritual was tool-driven.
*/
category?: RitualCategory;
status: 'active' | 'paused' | 'archived'; status: 'active' | 'paused' | 'archived';
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@ -37,7 +55,10 @@ export type RitualStepType =
| 'text_input' // User enters text → tool call | 'text_input' // User enters text → tool call
| 'mood_picker' // User picks mood (1-5) → tool call | 'mood_picker' // User picks mood (1-5) → tool call
| 'info_display' // Show data from projections (read-only) | 'info_display' // Show data from projections (read-only)
| 'checklist'; // Multiple items to check off | 'checklist' // Multiple items to check off
| 'presence' // Markdown text + optional timer — hold attention
| 'breath' // Guided breathing pattern (box / 4-7-8 / custom)
| 'media'; // Image + caption — e.g. mantra, photo, quote
export type RitualStepConfig = export type RitualStepConfig =
| ToolCallStepConfig | ToolCallStepConfig
@ -45,7 +66,10 @@ export type RitualStepConfig =
| TextInputStepConfig | TextInputStepConfig
| MoodPickerStepConfig | MoodPickerStepConfig
| InfoDisplayStepConfig | InfoDisplayStepConfig
| ChecklistStepConfig; | ChecklistStepConfig
| PresenceStepConfig
| BreathStepConfig
| MediaStepConfig;
export interface ToolCallStepConfig { export interface ToolCallStepConfig {
type: 'tool_call'; type: 'tool_call';
@ -91,6 +115,41 @@ export interface ChecklistStepConfig {
items: { label: string; toolName?: string; toolParams?: Record<string, unknown> }[]; items: { label: string; toolName?: string; toolParams?: Record<string, unknown> }[];
} }
// ── Ceremony steps (no tool calls) ───────────────────
export interface PresenceStepConfig {
type: 'presence';
/** Markdown-ish body text shown while the timer runs. */
body?: string;
/** Optional countdown timer in seconds. Step can be advanced manually at any time. */
durationSec?: number;
}
export type BreathPattern =
| 'box' // 4-4-4-4
| '4-7-8' // inhale 4, hold 7, exhale 8
| 'coherent' // 5-0-5-0 (slow resonant breathing)
| 'custom';
export interface BreathStepConfig {
type: 'breath';
pattern: BreathPattern;
/** Number of full cycles. */
cycles: number;
/** Required when pattern='custom'; otherwise derived from the preset. */
timings?: { inhaleSec: number; hold1Sec: number; exhaleSec: number; hold2Sec: number };
}
export interface MediaStepConfig {
type: 'media';
/** URL or data-URL of the image. */
imageUrl?: string;
/** Caption / mantra / quote shown below the image. */
caption?: string;
/** Optional linger timer before the user can advance. */
durationSec?: number;
}
export interface LocalRitualLog { export interface LocalRitualLog {
id?: number; id?: number;
ritualId: string; ritualId: string;
@ -108,6 +167,7 @@ export interface RitualTemplate {
title: string; title: string;
description: string; description: string;
trigger: LocalRitual['trigger']; trigger: LocalRitual['trigger'];
category?: RitualCategory;
steps: Omit<LocalRitualStep, 'id' | 'ritualId' | 'createdAt'>[]; steps: Omit<LocalRitualStep, 'id' | 'ritualId' | 'createdAt'>[];
} }
@ -117,6 +177,7 @@ export const RITUAL_TEMPLATES: RitualTemplate[] = [
title: 'Morgenroutine', title: 'Morgenroutine',
description: 'Starte den Tag mit Wasser, Tagesueberblick und Prioritaeten', description: 'Starte den Tag mit Wasser, Tagesueberblick und Prioritaeten',
trigger: 'morning', trigger: 'morning',
category: 'utility',
steps: [ steps: [
{ {
order: 0, order: 0,
@ -153,6 +214,7 @@ export const RITUAL_TEMPLATES: RitualTemplate[] = [
title: 'Abendroutine', title: 'Abendroutine',
description: 'Reflektiere den Tag und plane morgen', description: 'Reflektiere den Tag und plane morgen',
trigger: 'evening', trigger: 'evening',
category: 'utility',
steps: [ steps: [
{ {
order: 0, order: 0,
@ -185,6 +247,7 @@ export const RITUAL_TEMPLATES: RitualTemplate[] = [
title: 'Trink-Check', title: 'Trink-Check',
description: 'Schneller Wasser-Check und Nachloggen', description: 'Schneller Wasser-Check und Nachloggen',
trigger: 'manual', trigger: 'manual',
category: 'utility',
steps: [ steps: [
{ {
order: 0, order: 0,
@ -209,4 +272,153 @@ export const RITUAL_TEMPLATES: RitualTemplate[] = [
}, },
], ],
}, },
// ── Ceremony templates ─────────────────────────────
{
id: 'tpl-morning-coffee',
title: 'Morgenkaffee',
description: 'Einen Moment für den ersten Kaffee des Tages — ohne Handy, ohne Eile.',
trigger: 'morning',
category: 'ceremony',
steps: [
{
order: 0,
type: 'presence',
label: 'Wasser aufsetzen',
config: {
type: 'presence',
body: 'Setz das Wasser auf. Atme dabei bewusst. Das Geräusch des Kochers ist dein erster Anker im Tag.',
durationSec: 180,
},
},
{
order: 1,
type: 'presence',
label: 'Aufbrühen',
config: {
type: 'presence',
body: 'Gieß langsam. Riech den Kaffee, bevor du ihn trinkst. Nichts tun außer brühen.',
durationSec: 240,
},
},
{
order: 2,
type: 'breath',
label: 'Drei tiefe Atemzüge',
config: { type: 'breath', pattern: 'coherent', cycles: 3 },
},
{
order: 3,
type: 'presence',
label: 'Fünf Minuten still trinken',
config: {
type: 'presence',
body: 'Kein Handy. Kein Podcast. Nur der Kaffee und der Morgen. Wie fühlst du dich heute?',
durationSec: 300,
},
},
],
},
{
id: 'tpl-sunday-reset',
title: 'Sonntag-Reset',
description: 'Die Woche abschließen, die nächste vorbereiten — ohne Hetze.',
trigger: 'manual',
category: 'mixed',
steps: [
{
order: 0,
type: 'presence',
label: 'Ankommen',
config: {
type: 'presence',
body: 'Mach dir einen Tee. Setz dich hin. Die Woche ist vorbei.',
durationSec: 300,
},
},
{
order: 1,
type: 'info_display',
label: 'Was diese Woche gelaufen ist',
config: { type: 'info_display', source: 'streaks' },
},
{
order: 2,
type: 'text_input',
label: 'Was nehme ich mit?',
config: {
type: 'text_input',
toolName: 'create_task',
paramName: 'title',
baseParams: {},
placeholder: 'Ein Moment, eine Lektion, ein Gefühl...',
},
},
{
order: 3,
type: 'info_display',
label: 'Nächste Woche',
config: { type: 'info_display', source: 'events_today' },
},
{
order: 4,
type: 'presence',
label: 'Handy weg',
config: {
type: 'presence',
body: 'Für die nächsten zwei Stunden: Handy in den anderen Raum. Buch, Kochen, Spaziergang — was auch immer dir gut tut.',
},
},
],
},
{
id: 'tpl-bedtime',
title: 'Vor dem Schlaf',
description: 'Herunterfahren. Gedanken loslassen. Bereit für die Nacht.',
trigger: 'evening',
category: 'ceremony',
steps: [
{
order: 0,
type: 'presence',
label: 'Bildschirme aus',
config: {
type: 'presence',
body: 'Schalte Fernseher, Laptop und Handy aus. Leg das Handy nicht neben das Bett.',
durationSec: 60,
},
},
{
order: 1,
type: 'breath',
label: '4-7-8 Atmung',
config: { type: 'breath', pattern: '4-7-8', cycles: 4 },
},
{
order: 2,
type: 'text_input',
label: 'Was hat mich heute bewegt?',
config: {
type: 'text_input',
toolName: 'create_journal_entry',
paramName: 'content',
baseParams: {},
placeholder: 'Ein Satz reicht.',
},
},
{
order: 3,
type: 'presence',
label: 'Loslassen',
config: {
type: 'presence',
body: 'Was heute offen geblieben ist, bleibt offen. Morgen ist ein neuer Tag.',
durationSec: 120,
},
},
],
},
]; ];

View file

@ -1,170 +0,0 @@
<!--
AI Rituals app — guided routines. Wraps the existing RitualRunner.
-->
<script lang="ts">
import { Plus, Play, Trash } from '@mana/shared-icons';
import RitualRunner from '$lib/modules/companion/components/RitualRunner.svelte';
import { ritualStore, 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;
}
</script>
<div class="r">
{#if activeRitual}
<button class="back" onclick={() => (activeRitual = null)}> Zurück</button>
<RitualRunner
ritual={activeRitual}
onComplete={() => (activeRitual = null)}
onClose={() => (activeRitual = null)}
/>
{:else}
<header class="bar">
<button type="button" class="primary" onclick={() => (showTemplates = !showTemplates)}>
<Plus size={14} /><span>Aus Template</span>
</button>
</header>
{#if showTemplates}
<div class="templates">
{#each RITUAL_TEMPLATES as t}
<button type="button" class="template" onclick={() => createFromTemplate(t.id)}>
<strong>{t.title}</strong>
<span>{t.description ?? ''}</span>
</button>
{/each}
</div>
{/if}
<ul class="list">
{#each rituals.value as r (r.id)}
<li class="item">
<button type="button" class="item-main" onclick={() => (activeRitual = r)}>
<Play size={12} />
<span>{r.title}</span>
</button>
<button
type="button"
class="item-del"
onclick={() => ritualStore.delete(r.id)}
title="Löschen"
>
<Trash size={11} />
</button>
</li>
{/each}
{#if rituals.value.length === 0 && !showTemplates}
<li class="empty">Noch keine Rituale — erstelle eines aus einer Vorlage oben.</li>
{/if}
</ul>
{/if}
</div>
<style>
.r {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.75rem 1rem 1.25rem;
}
.back {
align-self: flex-start;
border: none;
background: none;
padding: 0.25rem 0;
cursor: pointer;
color: hsl(var(--color-muted-foreground));
font-size: 0.8125rem;
}
.bar {
display: flex;
justify-content: flex-end;
}
.primary {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.375rem 0.625rem;
border: 1px solid color-mix(in oklab, hsl(var(--color-primary)) 45%, transparent);
border-radius: 0.375rem;
background: color-mix(in oklab, hsl(var(--color-primary)) 12%, hsl(var(--color-surface)));
color: hsl(var(--color-primary));
cursor: pointer;
font: inherit;
font-size: 0.8125rem;
}
.templates {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.5rem;
border: 1px dashed hsl(var(--color-border));
border-radius: 0.375rem;
}
.template {
text-align: left;
padding: 0.5rem 0.625rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
background: hsl(var(--color-surface));
cursor: pointer;
font: inherit;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.template span {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.item {
display: flex;
align-items: center;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
}
.item-main {
flex: 1;
display: inline-flex;
gap: 0.375rem;
align-items: center;
padding: 0.5rem 0.625rem;
border: none;
background: none;
cursor: pointer;
font: inherit;
font-size: 0.875rem;
color: hsl(var(--color-foreground));
text-align: left;
}
.item-del {
border: none;
background: none;
padding: 0.375rem 0.5rem;
cursor: pointer;
color: hsl(var(--color-muted-foreground));
}
.empty {
list-style: none;
color: hsl(var(--color-muted-foreground));
padding: 1rem 0;
font-size: 0.875rem;
}
</style>

View file

@ -5,13 +5,13 @@
displays projection data. Tracks progress and logs completion. displays projection data. Tracks progress and logs completion.
--> -->
<script lang="ts"> <script lang="ts">
import { Check, ArrowRight, Drop, Lightning, ListChecks } from '@mana/shared-icons'; import { Check, ArrowRight, Lightning } from '@mana/shared-icons';
import { executeTool } from '$lib/data/tools'; import { executeTool } from '$lib/data/tools';
import { useDaySnapshot } from '$lib/data/projections/day-snapshot'; import { useDaySnapshot } from '$lib/data/projections/day-snapshot';
import { useStreaks } from '$lib/data/projections/streaks'; import { useStreaks } from '$lib/data/projections/streaks';
import { ritualStore } from '$lib/companion/rituals/store'; import { ritualStore } from '$lib/companion/rituals/store';
import type { LocalRitual, LocalRitualStep } from '$lib/companion/rituals/types'; import type { LocalRitual, LocalRitualStep, BreathPattern } from '$lib/companion/rituals/types';
import { onMount } from 'svelte'; import { onMount, onDestroy } from 'svelte';
interface Props { interface Props {
ritual: LocalRitual; ritual: LocalRitual;
@ -31,14 +31,104 @@
let inputValue = $state<string | number>(''); let inputValue = $state<string | number>('');
let executing = $state(false); let executing = $state(false);
// Ceremony step runtime state
let timerRemaining = $state<number | null>(null);
let timerHandle: ReturnType<typeof setInterval> | null = null;
let breathPhase = $state<'inhale' | 'hold1' | 'exhale' | 'hold2' | null>(null);
let breathCycle = $state(0);
let breathHandle: ReturnType<typeof setTimeout> | null = null;
let currentStep = $derived(steps[currentStepIdx]); let currentStep = $derived(steps[currentStepIdx]);
let isLastStep = $derived(currentStepIdx >= steps.length - 1); let isLastStep = $derived(currentStepIdx >= steps.length - 1);
let progress = $derived(steps.length > 0 ? (completedSteps.size / steps.length) * 100 : 0); let progress = $derived(steps.length > 0 ? (completedSteps.size / steps.length) * 100 : 0);
function stopTimer() {
if (timerHandle) {
clearInterval(timerHandle);
timerHandle = null;
}
timerRemaining = null;
}
function stopBreath() {
if (breathHandle) {
clearTimeout(breathHandle);
breathHandle = null;
}
breathPhase = null;
breathCycle = 0;
}
function startTimer(durationSec: number) {
stopTimer();
timerRemaining = durationSec;
timerHandle = setInterval(() => {
if (timerRemaining == null) return;
timerRemaining -= 1;
if (timerRemaining <= 0) {
stopTimer();
completeStep();
}
}, 1000);
}
const BREATH_PRESETS: Record<
Exclude<BreathPattern, 'custom'>,
{ inhaleSec: number; hold1Sec: number; exhaleSec: number; hold2Sec: number }
> = {
box: { inhaleSec: 4, hold1Sec: 4, exhaleSec: 4, hold2Sec: 4 },
'4-7-8': { inhaleSec: 4, hold1Sec: 7, exhaleSec: 8, hold2Sec: 0 },
coherent: { inhaleSec: 5, hold1Sec: 0, exhaleSec: 5, hold2Sec: 0 },
};
function startBreath() {
if (currentStep?.config.type !== 'breath') return;
const cfg = currentStep.config;
const timings = cfg.pattern === 'custom' ? cfg.timings : BREATH_PRESETS[cfg.pattern];
if (!timings) return;
stopBreath();
breathCycle = 0;
const runPhase = (
phase: 'inhale' | 'hold1' | 'exhale' | 'hold2',
nextPhase: 'inhale' | 'hold1' | 'exhale' | 'hold2' | 'cycle-end',
durationSec: number
) => {
breathPhase = phase;
if (durationSec <= 0) {
dispatchNext(nextPhase);
return;
}
breathHandle = setTimeout(() => dispatchNext(nextPhase), durationSec * 1000);
};
const dispatchNext = (step: 'inhale' | 'hold1' | 'exhale' | 'hold2' | 'cycle-end') => {
if (step === 'hold1') runPhase('hold1', 'exhale', timings.hold1Sec);
else if (step === 'exhale') runPhase('exhale', 'hold2', timings.exhaleSec);
else if (step === 'hold2') runPhase('hold2', 'cycle-end', timings.hold2Sec);
else if (step === 'cycle-end') {
breathCycle += 1;
if (breathCycle >= cfg.cycles) {
stopBreath();
completeStep();
} else {
runPhase('inhale', 'hold1', timings.inhaleSec);
}
} else if (step === 'inhale') runPhase('inhale', 'hold1', timings.inhaleSec);
};
runPhase('inhale', 'hold1', timings.inhaleSec);
}
onMount(async () => { onMount(async () => {
steps = await ritualStore.getSteps(ritual.id); steps = await ritualStore.getSteps(ritual.id);
}); });
onDestroy(() => {
stopTimer();
stopBreath();
});
async function executeCurrentStep() { async function executeCurrentStep() {
if (!currentStep || executing) return; if (!currentStep || executing) return;
executing = true; executing = true;
@ -87,6 +177,10 @@
} }
function nextStep() { function nextStep() {
// Leaving the current step — tear down any running ceremony runtime
stopTimer();
stopBreath();
if (isLastStep) { if (isLastStep) {
ritualStore.logCompletion(ritual.id, completedSteps.size, steps.length); ritualStore.logCompletion(ritual.id, completedSteps.size, steps.length);
onComplete(); onComplete();
@ -94,8 +188,9 @@
} }
stepResult = null; stepResult = null;
currentStepIdx++; currentStepIdx++;
// Auto-complete info_display steps // Auto-complete steps that don't require an action
if (currentStep?.config.type === 'info_display') { const t = currentStep?.config.type;
if (t === 'info_display' || t === 'presence' || t === 'media') {
completeStep(); completeStep();
} }
} }
@ -207,6 +302,118 @@
{/each} {/each}
{/if} {/if}
</div> </div>
{:else if currentStep.config.type === 'presence'}
{#if currentStep.config.body}
<p class="presence-body">{currentStep.config.body}</p>
{/if}
{#if currentStep.config.durationSec}
{#if timerRemaining == null && !completedSteps.has(currentStepIdx)}
<button
class="step-action"
onclick={() =>
currentStep?.config.type === 'presence' &&
currentStep.config.durationSec &&
startTimer(currentStep.config.durationSec)}
>
Starten ({currentStep.config.durationSec}s)
</button>
{:else if timerRemaining != null}
<div class="timer-display">
<span class="timer-num">{timerRemaining}s</span>
<button
class="nav-skip"
onclick={() => {
stopTimer();
completeStep();
}}
>
Weiter
</button>
</div>
{:else}
<div class="step-done"><Check size={20} weight="bold" /> Pause gehalten</div>
{/if}
{:else}
<button class="step-action" onclick={completeStep}>Bereit</button>
{/if}
{:else if currentStep.config.type === 'breath'}
{#if breathPhase == null && !completedSteps.has(currentStepIdx)}
<p class="presence-body">
{#if currentStep.config.pattern === 'box'}
Box-Atmung (4-4-4-4): einatmen, halten, ausatmen, halten.
{:else if currentStep.config.pattern === '4-7-8'}
4-7-8: 4 Sek. einatmen, 7 Sek. halten, 8 Sek. ausatmen.
{:else if currentStep.config.pattern === 'coherent'}
Kohärent: langsam 5 Sek. rein, 5 Sek. raus.
{:else}
Eigenes Muster.
{/if}
</p>
<button class="step-action" onclick={startBreath}>
Starten — {currentStep.config.cycles} Zyklen
</button>
{:else if breathPhase != null}
<div class="breath-display">
<div class="breath-circle" data-phase={breathPhase}></div>
<div class="breath-label">
{#if breathPhase === 'inhale'}Einatmen{/if}
{#if breathPhase === 'hold1'}Halten{/if}
{#if breathPhase === 'exhale'}Ausatmen{/if}
{#if breathPhase === 'hold2'}Halten{/if}
</div>
<div class="breath-cycle">
Zyklus {breathCycle + 1} / {currentStep.config.cycles}
</div>
<button
class="nav-skip"
onclick={() => {
stopBreath();
completeStep();
}}
>
Früher beenden
</button>
</div>
{:else}
<div class="step-done"><Check size={20} weight="bold" /> Atmung abgeschlossen</div>
{/if}
{:else if currentStep.config.type === 'media'}
{#if currentStep.config.imageUrl}
<img
src={currentStep.config.imageUrl}
alt={currentStep.config.caption ?? ''}
class="media-image"
/>
{/if}
{#if currentStep.config.caption}
<p class="media-caption">{currentStep.config.caption}</p>
{/if}
{#if currentStep.config.durationSec}
{#if timerRemaining == null && !completedSteps.has(currentStepIdx)}
<button
class="step-action"
onclick={() =>
currentStep?.config.type === 'media' &&
currentStep.config.durationSec &&
startTimer(currentStep.config.durationSec)}
>
Starten ({currentStep.config.durationSec}s)
</button>
{:else if timerRemaining != null}
<div class="timer-display">
<span class="timer-num">{timerRemaining}s</span>
<button
class="nav-skip"
onclick={() => {
stopTimer();
completeStep();
}}
>
Weiter
</button>
</div>
{/if}
{/if}
{/if} {/if}
</div> </div>
@ -420,4 +627,89 @@
.close-btn:hover { .close-btn:hover {
color: hsl(var(--color-foreground)); color: hsl(var(--color-foreground));
} }
/* ── Ceremony step types ─────────────────────── */
.presence-body {
margin: 0;
font-size: 0.95rem;
line-height: 1.5;
color: hsl(var(--color-foreground));
white-space: pre-wrap;
}
.timer-display {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.timer-num {
font-size: 2rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: hsl(var(--color-primary));
}
.breath-display {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.6rem;
padding: 1rem 0;
}
.breath-circle {
width: 120px;
height: 120px;
border-radius: 50%;
background: radial-gradient(
circle,
hsl(var(--color-primary) / 0.3),
hsl(var(--color-primary) / 0.1)
);
border: 2px solid hsl(var(--color-primary) / 0.5);
transition: transform 4s ease-in-out;
transform: scale(0.7);
}
.breath-circle[data-phase='inhale'] {
transform: scale(1.15);
transition: transform 4s ease-in-out;
}
.breath-circle[data-phase='hold1'] {
transform: scale(1.15);
}
.breath-circle[data-phase='exhale'] {
transform: scale(0.7);
transition: transform 5s ease-in-out;
}
.breath-circle[data-phase='hold2'] {
transform: scale(0.7);
}
.breath-label {
font-size: 1.1rem;
font-weight: 500;
color: hsl(var(--color-foreground));
}
.breath-cycle {
font-size: 0.8rem;
color: hsl(var(--color-muted-foreground));
}
.media-image {
width: 100%;
max-height: 240px;
object-fit: cover;
border-radius: 0.5rem;
}
.media-caption {
margin: 0;
font-style: italic;
text-align: center;
color: hsl(var(--color-foreground));
}
</style> </style>

View file

@ -0,0 +1,288 @@
<!--
Rituals — guided sequences. Two flavours live here:
- utility : data-capture rituals (log drink, log mood, show tasks)
- ceremony : presence/meaning rituals (morning coffee, bedtime, reset)
Templates are grouped by category. Running a ritual hands off to
RitualRunner (companion), which renders each step type.
-->
<script lang="ts">
import { Plus, Play, Trash } from '@mana/shared-icons';
import RitualRunner from '$lib/modules/companion/components/RitualRunner.svelte';
import { ritualStore, useAllRituals, RITUAL_TEMPLATES } from '$lib/companion/rituals';
import type { LocalRitual, RitualCategory } from '$lib/companion/rituals/types';
const rituals = useAllRituals();
let activeRitual = $state<LocalRitual | null>(null);
let showTemplates = $state(false);
let filter = $state<RitualCategory | 'all'>('all');
const CATEGORY_LABELS: Record<RitualCategory, string> = {
utility: 'Utility',
ceremony: 'Zeremoniell',
mixed: 'Mixed',
};
async function createFromTemplate(templateId: string) {
const template = RITUAL_TEMPLATES.find((t) => t.id === templateId);
if (!template) return;
await ritualStore.createFromTemplate(template);
showTemplates = false;
}
const templatesByCategory = $derived.by(() => {
const buckets: Record<RitualCategory, typeof RITUAL_TEMPLATES> = {
utility: [],
ceremony: [],
mixed: [],
};
for (const t of RITUAL_TEMPLATES) {
buckets[t.category ?? 'utility'].push(t);
}
return buckets;
});
const filteredRituals = $derived.by(() => {
if (filter === 'all') return rituals.value;
return rituals.value.filter((r) => (r.category ?? 'utility') === filter);
});
</script>
<div class="r">
{#if activeRitual}
<button class="back" onclick={() => (activeRitual = null)}> Zurück</button>
<RitualRunner
ritual={activeRitual}
onComplete={() => (activeRitual = null)}
onClose={() => (activeRitual = null)}
/>
{:else}
<header class="bar">
<div class="filter-chips">
<button
type="button"
class="chip"
class:active={filter === 'all'}
onclick={() => (filter = 'all')}
>
Alle
</button>
<button
type="button"
class="chip"
class:active={filter === 'utility'}
onclick={() => (filter = 'utility')}
>
Utility
</button>
<button
type="button"
class="chip"
class:active={filter === 'ceremony'}
onclick={() => (filter = 'ceremony')}
>
Zeremoniell
</button>
</div>
<button type="button" class="primary" onclick={() => (showTemplates = !showTemplates)}>
<Plus size={14} /><span>{showTemplates ? 'Schließen' : 'Aus Template'}</span>
</button>
</header>
{#if showTemplates}
<div class="templates">
{#each ['ceremony', 'utility', 'mixed'] as const as cat (cat)}
{#if templatesByCategory[cat].length > 0}
<div class="template-group">
<div class="template-group-label">{CATEGORY_LABELS[cat]}</div>
{#each templatesByCategory[cat] as t (t.id)}
<button type="button" class="template" onclick={() => createFromTemplate(t.id)}>
<strong>{t.title}</strong>
<span>{t.description ?? ''}</span>
</button>
{/each}
</div>
{/if}
{/each}
</div>
{/if}
<ul class="list">
{#each filteredRituals as r (r.id)}
<li class="item">
<button type="button" class="item-main" onclick={() => (activeRitual = r)}>
<Play size={12} />
<span>{r.title}</span>
{#if r.category && r.category !== 'utility'}
<span class="cat-pill cat-{r.category}">{CATEGORY_LABELS[r.category]}</span>
{/if}
</button>
<button
type="button"
class="item-del"
onclick={() => ritualStore.delete(r.id)}
title="Löschen"
>
<Trash size={11} />
</button>
</li>
{/each}
{#if filteredRituals.length === 0 && !showTemplates}
<li class="empty">
{filter === 'all'
? 'Noch keine Rituale — erstelle eines aus einer Vorlage oben.'
: 'Keine Rituale in dieser Kategorie.'}
</li>
{/if}
</ul>
{/if}
</div>
<style>
.r {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.75rem 1rem 1.25rem;
}
.back {
align-self: flex-start;
border: none;
background: none;
padding: 0.25rem 0;
cursor: pointer;
color: hsl(var(--color-muted-foreground));
font-size: 0.8125rem;
}
.bar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.filter-chips {
display: inline-flex;
gap: 0.25rem;
}
.chip {
padding: 0.25rem 0.65rem;
border-radius: 999px;
border: 1px solid transparent;
background: hsl(var(--color-surface));
cursor: pointer;
font: inherit;
font-size: 0.8rem;
color: hsl(var(--color-muted-foreground));
}
.chip.active {
background: color-mix(in oklab, hsl(var(--color-primary)) 14%, transparent);
color: hsl(var(--color-primary));
border-color: hsl(var(--color-primary) / 0.4);
}
.primary {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.375rem 0.625rem;
border: 1px solid color-mix(in oklab, hsl(var(--color-primary)) 45%, transparent);
border-radius: 0.375rem;
background: color-mix(in oklab, hsl(var(--color-primary)) 12%, hsl(var(--color-surface)));
color: hsl(var(--color-primary));
cursor: pointer;
font: inherit;
font-size: 0.8125rem;
}
.templates {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.5rem;
border: 1px dashed hsl(var(--color-border));
border-radius: 0.375rem;
}
.template-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.template-group-label {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: hsl(var(--color-muted-foreground));
padding: 0.25rem 0.375rem 0.125rem;
}
.template {
text-align: left;
padding: 0.5rem 0.625rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
background: hsl(var(--color-surface));
cursor: pointer;
font: inherit;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.template span {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.item {
display: flex;
align-items: center;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
}
.item-main {
flex: 1;
display: inline-flex;
gap: 0.375rem;
align-items: center;
padding: 0.5rem 0.625rem;
border: none;
background: none;
cursor: pointer;
font: inherit;
font-size: 0.875rem;
color: hsl(var(--color-foreground));
text-align: left;
}
.cat-pill {
margin-left: auto;
font-size: 0.7rem;
padding: 0.1rem 0.5rem;
border-radius: 999px;
background: color-mix(in oklab, hsl(var(--color-primary)) 12%, transparent);
color: hsl(var(--color-primary));
}
.cat-pill.cat-ceremony {
background: color-mix(in oklab, #ec4899 18%, transparent);
color: #ec4899;
}
.cat-pill.cat-mixed {
background: color-mix(in oklab, #f59e0b 18%, transparent);
color: #f59e0b;
}
.item-del {
border: none;
background: none;
padding: 0.375rem 0.5rem;
cursor: pointer;
color: hsl(var(--color-muted-foreground));
}
.empty {
list-style: none;
color: hsl(var(--color-muted-foreground));
padding: 1rem 0;
font-size: 0.875rem;
}
</style>

View file

@ -18,7 +18,7 @@ Alle 73 Module der Mana-App (`apps/mana/apps/web/src/lib/modules/`).
| `inventory` | Inventory | Besitz verwalten mit Fotos, Quittungen, Garantien | | `inventory` | Inventory | Besitz verwalten mit Fotos, Quittungen, Garantien |
| `calc` | Calc | Taschenrechner (Standard, Scientific, Programmer, Unit-Conversion) | | `calc` | Calc | Taschenrechner (Standard, Scientific, Programmer, Unit-Conversion) |
## Gesundheit & Wellness (10) ## Gesundheit & Wellness (11)
| Modul | Name | Beschreibung | | Modul | Name | Beschreibung |
|---|---|---| |---|---|---|
@ -30,6 +30,7 @@ Alle 73 Module der Mana-App (`apps/mana/apps/web/src/lib/modules/`).
| `period` | Periode | Menstruations-Tracking mit Vorhersagen und Phasen-Erkennung | | `period` | Periode | Menstruations-Tracking mit Vorhersagen und Phasen-Erkennung |
| `stretch` | Stretch | Mobility-Assessments und geführte Dehn-Routinen | | `stretch` | Stretch | Mobility-Assessments und geführte Dehn-Routinen |
| `meditate` | Meditate | Meditations-Timer, Atemübungen, Body-Scans | | `meditate` | Meditate | Meditations-Timer, Atemübungen, Body-Scans |
| `rituals` | Rituale | Geführte Sequenzen — utility (Daten-Erfassung) + ceremony (Präsenz) |
| `mood` | Mood | Stimmungs-Tracking mehrmals täglich mit Kontext und Mustern | | `mood` | Mood | Stimmungs-Tracking mehrmals täglich mit Kontext und Mustern |
| `moodlit` | Moodlit | Beruhigende Ambient-Beleuchtung mit animierten Farbverläufen | | `moodlit` | Moodlit | Beruhigende Ambient-Beleuchtung mit animierten Farbverläufen |
@ -100,7 +101,7 @@ Alle 73 Module der Mana-App (`apps/mana/apps/web/src/lib/modules/`).
| `myday` | Mein Tag | Tagesübersicht: Tasks, Events, Wasser, Ernährung, Streaks | | `myday` | Mein Tag | Tagesübersicht: Tasks, Events, Wasser, Ernährung, Streaks |
| `activity` | Aktivität | Live-Activity-Stream über alle Module | | `activity` | Aktivität | Live-Activity-Stream über alle Module |
## AI-System (9) ## AI-System (8)
| Modul | Name | Beschreibung | | Modul | Name | Beschreibung |
|---|---|---| |---|---|---|
@ -109,7 +110,6 @@ Alle 73 Module der Mana-App (`apps/mana/apps/web/src/lib/modules/`).
| `ai-missions` | AI Missions | Langlebige autonome AI-Aufträge | | `ai-missions` | AI Missions | Langlebige autonome AI-Aufträge |
| `ai-agents` | AI Agents | AI-Agenten erstellen und verwalten | | `ai-agents` | AI Agents | AI-Agenten erstellen und verwalten |
| `ai-policy` | AI Policy | Tool-Execution-Policies konfigurieren | | `ai-policy` | AI Policy | Tool-Execution-Policies konfigurieren |
| `ai-rituals` | AI Rituals | AI-gesteuerte Routinen und Rituale |
| `ai-health` | AI Health | AI-System-Monitoring und Metriken | | `ai-health` | AI Health | AI-System-Monitoring und Metriken |
| `ai-insights` | AI Insights | AI-generierte Insights aus User-Daten | | `ai-insights` | AI Insights | AI-generierte Insights aus User-Daten |
| `news-research` | News Research | RSS-Feed-Discovery und Keyword-Suche | | `news-research` | News Research | RSS-Feed-Discovery und Keyword-Suche |