mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:41:09 +02:00
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:
parent
a2423b4932
commit
2df9ecdcaa
8 changed files with 813 additions and 188 deletions
|
|
@ -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') },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
288
apps/mana/apps/web/src/lib/modules/rituals/ListView.svelte
Normal file
288
apps/mana/apps/web/src/lib/modules/rituals/ListView.svelte
Normal 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>
|
||||||
|
|
@ -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 |
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue