feat(meditate): add meditation module with presets, sessions, breathing UI

Local-first module with meditatePresets/Sessions/Settings tables, hub
ListView with stats + recent sessions, and SessionPlayer with
BreathingCircle + MoodPicker. Route at /meditate.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-14 19:50:13 +02:00
parent d11f6aebf7
commit 73e3fdbbed
16 changed files with 2161 additions and 0 deletions

View file

@ -0,0 +1,205 @@
<!--
Meditate — ListView (workbench-embedded view).
Quick-start presets, today's stats, recent sessions.
-->
<script lang="ts">
import {
useAllPresets,
useAllSessions,
getTodayMinutes,
getTodaySessions,
getCurrentStreak,
formatDuration,
formatDurationLong,
} from './queries';
import { CATEGORY_LABELS } from './types';
import type { MeditatePreset, MeditateSession } from './types';
import SessionPlayer from './components/SessionPlayer.svelte';
let presets$ = useAllPresets();
let sessions$ = useAllSessions();
let presets = $derived(presets$.value);
let sessions = $derived(sessions$.value);
let todayMinutes = $derived(getTodayMinutes(sessions));
let todaySessions = $derived(getTodaySessions(sessions));
let streak = $derived(getCurrentStreak(sessions));
let activePreset = $state<MeditatePreset | null>(null);
function startSession(preset: MeditatePreset) {
activePreset = preset;
}
function handleComplete() {
activePreset = null;
}
function handleCancel() {
activePreset = null;
}
const categoryIcon: Record<string, string> = {
silence: '🧘',
breathing: '🌬️',
bodyscan: '✨',
};
</script>
{#if activePreset}
<SessionPlayer preset={activePreset} onComplete={handleComplete} onCancel={handleCancel} />
{/if}
<div class="meditate-list">
<!-- Today stats -->
{#if sessions.length > 0}
<div class="today-stats">
<div class="stat">
<span class="stat-value">{todayMinutes}</span>
<span class="stat-unit">min heute</span>
</div>
{#if streak > 0}
<div class="stat">
<span class="stat-value">{streak}</span>
<span class="stat-unit">{streak === 1 ? 'Tag' : 'Tage'} Streak</span>
</div>
{/if}
</div>
{/if}
<!-- Preset buttons -->
<div class="preset-grid">
{#each presets as preset (preset.id)}
<button type="button" class="preset-btn" onclick={() => startSession(preset)}>
<span class="preset-icon">{categoryIcon[preset.category] ?? '🧘'}</span>
<span class="preset-name">{preset.name}</span>
<span class="preset-duration">{formatDurationLong(preset.defaultDurationSec)}</span>
</button>
{/each}
</div>
<!-- Recent sessions -->
{#if todaySessions.length > 0}
<div class="recent-header">Heute</div>
<div class="recent-list">
{#each todaySessions as session (session.id)}
{@const preset = presets.find((p) => p.id === session.presetId)}
<div class="recent-item">
<span class="recent-name">{preset?.name ?? CATEGORY_LABELS[session.category].de}</span>
<span class="recent-duration">{formatDuration(session.durationSec)}</span>
</div>
{/each}
</div>
{/if}
</div>
<style>
.meditate-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.75rem;
}
/* Today stats */
.today-stats {
display: flex;
gap: 1rem;
justify-content: center;
}
.stat {
display: flex;
align-items: baseline;
gap: 0.3rem;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
color: hsl(var(--color-foreground));
font-variant-numeric: tabular-nums;
}
.stat-unit {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
/* Preset grid */
.preset-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.5rem;
}
.preset-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
padding: 0.75rem 0.5rem;
border-radius: 0.75rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-card));
cursor: pointer;
transition: all 0.15s ease;
}
.preset-btn:hover {
border-color: hsl(var(--color-primary) / 0.5);
box-shadow: 0 2px 8px hsl(var(--color-primary) / 0.1);
}
.preset-icon {
font-size: 1.25rem;
}
.preset-name {
font-size: 0.8rem;
font-weight: 500;
color: hsl(var(--color-foreground));
text-align: center;
line-height: 1.2;
}
.preset-duration {
font-size: 0.7rem;
color: hsl(var(--color-muted-foreground));
}
/* Recent sessions */
.recent-header {
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--color-muted-foreground));
text-transform: uppercase;
letter-spacing: 0.05em;
}
.recent-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.recent-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.4rem 0.5rem;
border-radius: 0.5rem;
background: hsl(var(--color-muted) / 0.3);
}
.recent-name {
font-size: 0.8rem;
color: hsl(var(--color-foreground));
}
.recent-duration {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
font-variant-numeric: tabular-nums;
}
</style>

View file

@ -0,0 +1,23 @@
/**
* Meditate module collection accessors and guest seed data.
*
* Tables: meditatePresets, meditateSessions, meditateSettings.
*/
import { db } from '$lib/data/database';
import type { LocalMeditatePreset, LocalMeditateSession, LocalMeditateSettings } from './types';
import { DEFAULT_PRESETS } from './default-presets';
// ─── Collection Accessors ───────────────────────────────────
export const meditatePresetTable = db.table<LocalMeditatePreset>('meditatePresets');
export const meditateSessionTable = db.table<LocalMeditateSession>('meditateSessions');
export const meditateSettingsTable = db.table<LocalMeditateSettings>('meditateSettings');
// ─── Guest Seed ─────────────────────────────────────────────
export const MEDITATE_GUEST_SEED = {
meditatePresets: DEFAULT_PRESETS as unknown as Record<string, unknown>[],
meditateSessions: [] as Record<string, unknown>[],
meditateSettings: [] as Record<string, unknown>[],
};

View file

@ -0,0 +1,181 @@
<!--
BreathingCircle — animated breathing guide.
Expands on inhale, holds, contracts on exhale. Phase label in the center.
-->
<script lang="ts">
import type { BreathPattern, BreathPhase } from '../types';
import { BREATH_PHASE_LABELS } from '../types';
interface Props {
pattern: BreathPattern;
isActive: boolean;
}
let { pattern, isActive }: Props = $props();
let phase = $state<BreathPhase>('inhale');
let phaseElapsed = $state(0);
let animationFrame = $state<number | null>(null);
let lastTick = $state(0);
const cycleDuration = $derived(pattern.inhale + pattern.hold1 + pattern.exhale + pattern.hold2);
// Scale from 0.6 → 1.0 during inhale, stay at 1.0 during hold1,
// 1.0 → 0.6 during exhale, stay at 0.6 during hold2.
let scale = $derived.by(() => {
if (!isActive) return 0.6;
const minScale = 0.6;
const maxScale = 1.0;
const range = maxScale - minScale;
if (phase === 'inhale' && pattern.inhale > 0) {
return minScale + range * (phaseElapsed / pattern.inhale);
}
if (phase === 'hold1') return maxScale;
if (phase === 'exhale' && pattern.exhale > 0) {
return maxScale - range * (phaseElapsed / pattern.exhale);
}
return minScale; // hold2
});
let phaseDuration = $derived.by(() => {
if (phase === 'inhale') return pattern.inhale;
if (phase === 'hold1') return pattern.hold1;
if (phase === 'exhale') return pattern.exhale;
return pattern.hold2;
});
let phaseLabel = $derived(BREATH_PHASE_LABELS[phase].de);
let phaseCountdown = $derived(Math.max(0, Math.ceil(phaseDuration - phaseElapsed)));
function nextPhase(): BreathPhase {
const order: BreathPhase[] = ['inhale', 'hold1', 'exhale', 'hold2'];
let idx = order.indexOf(phase);
// Skip phases with 0 duration
do {
idx = (idx + 1) % 4;
} while (getPatternValue(order[idx]) === 0 && idx !== order.indexOf(phase));
return order[idx];
}
function getPatternValue(p: BreathPhase): number {
if (p === 'inhale') return pattern.inhale;
if (p === 'hold1') return pattern.hold1;
if (p === 'exhale') return pattern.exhale;
return pattern.hold2;
}
function tick() {
if (!isActive) return;
const now = performance.now();
const elapsed = (now - lastTick) / 1000;
lastTick = now;
phaseElapsed += elapsed;
if (phaseElapsed >= phaseDuration) {
phaseElapsed = 0;
phase = nextPhase();
}
animationFrame = requestAnimationFrame(tick);
}
function start() {
phase = 'inhale';
phaseElapsed = 0;
lastTick = performance.now();
tick();
}
function stop() {
if (animationFrame) {
cancelAnimationFrame(animationFrame);
animationFrame = null;
}
}
$effect(() => {
if (isActive) {
start();
} else {
stop();
}
return () => stop();
});
</script>
<div class="breathing-container">
<div class="breathing-circle" style:transform="scale({scale})">
<div class="breath-label">{phaseLabel}</div>
{#if phaseDuration > 0}
<div class="breath-countdown">{phaseCountdown}</div>
{/if}
</div>
<div class="breathing-ring ring-outer"></div>
<div class="breathing-ring ring-inner"></div>
</div>
<style>
.breathing-container {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 240px;
height: 240px;
}
.breathing-circle {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 180px;
height: 180px;
border-radius: 50%;
background: radial-gradient(
circle at 40% 40%,
hsl(var(--color-primary) / 0.6),
hsl(var(--color-primary) / 0.3)
);
backdrop-filter: blur(8px);
transition: transform 0.1s linear;
box-shadow:
0 0 40px hsl(var(--color-primary) / 0.2),
0 0 80px hsl(var(--color-primary) / 0.1);
}
.breath-label {
font-size: 1rem;
font-weight: 600;
color: hsl(var(--color-primary-foreground));
text-transform: uppercase;
letter-spacing: 0.1em;
opacity: 0.9;
}
.breath-countdown {
font-size: 2rem;
font-weight: 700;
color: hsl(var(--color-primary-foreground));
font-variant-numeric: tabular-nums;
}
.breathing-ring {
position: absolute;
border-radius: 50%;
border: 1px solid hsl(var(--color-primary) / 0.15);
}
.ring-outer {
width: 230px;
height: 230px;
}
.ring-inner {
width: 210px;
height: 210px;
}
</style>

View file

@ -0,0 +1,86 @@
<!--
MoodPicker — 1-5 mood selection for before/after meditation.
-->
<script lang="ts">
interface Props {
value: number | null;
onSelect: (mood: number) => void;
label?: string;
}
let { value, onSelect, label = 'Stimmung' }: Props = $props();
const moods = [
{ value: 1, emoji: '😔', label: 'Schlecht' },
{ value: 2, emoji: '😕', label: 'Mäßig' },
{ value: 3, emoji: '😐', label: 'Okay' },
{ value: 4, emoji: '🙂', label: 'Gut' },
{ value: 5, emoji: '😊', label: 'Sehr gut' },
];
</script>
<div class="mood-picker">
<span class="mood-label">{label}</span>
<div class="mood-options">
{#each moods as mood}
<button
type="button"
class="mood-btn"
class:selected={value === mood.value}
onclick={() => onSelect(mood.value)}
title={mood.label}
>
<span class="mood-emoji">{mood.emoji}</span>
</button>
{/each}
</div>
</div>
<style>
.mood-picker {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.mood-label {
font-size: 0.8rem;
color: hsl(var(--color-muted-foreground));
text-transform: uppercase;
letter-spacing: 0.05em;
}
.mood-options {
display: flex;
gap: 0.5rem;
}
.mood-btn {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 50%;
border: 2px solid hsl(var(--color-border));
background: hsl(var(--color-card));
cursor: pointer;
transition: all 0.15s ease;
}
.mood-btn:hover {
border-color: hsl(var(--color-primary) / 0.5);
transform: scale(1.1);
}
.mood-btn.selected {
border-color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.15);
transform: scale(1.15);
}
.mood-emoji {
font-size: 1.25rem;
}
</style>

View file

@ -0,0 +1,125 @@
<!--
PresetCard — clickable card for a meditation preset.
-->
<script lang="ts">
import type { MeditatePreset } from '../types';
import { CATEGORY_LABELS } from '../types';
import { formatDurationLong } from '../queries';
interface Props {
preset: MeditatePreset;
onStart: (preset: MeditatePreset) => void;
}
let { preset, onStart }: Props = $props();
const categoryLabel = $derived(CATEGORY_LABELS[preset.category].de);
const durationLabel = $derived(formatDurationLong(preset.defaultDurationSec));
const categoryIcon = $derived.by(() => {
if (preset.category === 'silence') return '🧘';
if (preset.category === 'breathing') return '🌬️';
return '✨'; // bodyscan
});
const patternLabel = $derived.by(() => {
if (!preset.breathPattern) return null;
const p = preset.breathPattern;
const parts = [p.inhale, p.hold1, p.exhale, p.hold2].filter((v) => v > 0);
return parts.join('-');
});
</script>
<button type="button" class="preset-card" onclick={() => onStart(preset)}>
<div class="preset-icon">{categoryIcon}</div>
<div class="preset-info">
<div class="preset-name">{preset.name}</div>
<div class="preset-meta">
<span class="preset-category">{categoryLabel}</span>
<span class="preset-dot">·</span>
<span class="preset-duration">{durationLabel}</span>
{#if patternLabel}
<span class="preset-dot">·</span>
<span class="preset-pattern">{patternLabel}</span>
{/if}
</div>
{#if preset.description}
<div class="preset-desc">{preset.description}</div>
{/if}
</div>
</button>
<style>
.preset-card {
display: flex;
align-items: flex-start;
gap: 0.75rem;
width: 100%;
padding: 1rem;
border-radius: 0.75rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-card));
cursor: pointer;
text-align: left;
transition: all 0.15s ease;
}
.preset-card:hover {
border-color: hsl(var(--color-primary) / 0.5);
box-shadow: 0 2px 12px hsl(var(--color-primary) / 0.1);
}
.preset-icon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 10px;
background: hsl(var(--color-primary) / 0.1);
font-size: 1.25rem;
}
.preset-info {
flex: 1;
min-width: 0;
}
.preset-name {
font-weight: 600;
color: hsl(var(--color-foreground));
font-size: 0.95rem;
}
.preset-meta {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.8rem;
color: hsl(var(--color-muted-foreground));
margin-top: 0.15rem;
}
.preset-dot {
opacity: 0.5;
}
.preset-pattern {
font-variant-numeric: tabular-nums;
font-family: monospace;
font-size: 0.75rem;
}
.preset-desc {
margin-top: 0.35rem;
font-size: 0.8rem;
color: hsl(var(--color-muted-foreground));
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View file

@ -0,0 +1,110 @@
<!--
SessionCard — displays a completed meditation session.
-->
<script lang="ts">
import type { MeditateSession, MeditatePreset } from '../types';
import { CATEGORY_LABELS } from '../types';
import { formatDuration } from '../queries';
interface Props {
session: MeditateSession;
presets: MeditatePreset[];
}
let { session, presets }: Props = $props();
const preset = $derived(presets.find((p) => p.id === session.presetId));
const name = $derived(preset?.name ?? CATEGORY_LABELS[session.category].de);
const duration = $derived(formatDuration(session.durationSec));
const date = $derived(
new Date(session.startedAt).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
);
const moodEmojis = ['', '😔', '😕', '😐', '🙂', '😊'];
</script>
<div class="session-card">
<div class="session-main">
<div class="session-name">{name}</div>
<div class="session-meta">
<span>{date}</span>
<span class="dot">·</span>
<span>{duration}</span>
{#if !session.completed}
<span class="dot">·</span>
<span class="incomplete">abgebrochen</span>
{/if}
</div>
</div>
{#if session.moodBefore || session.moodAfter}
<div class="session-mood">
{#if session.moodBefore}
<span title="Vorher">{moodEmojis[session.moodBefore]}</span>
{/if}
{#if session.moodBefore && session.moodAfter}
<span class="mood-arrow"></span>
{/if}
{#if session.moodAfter}
<span title="Nachher">{moodEmojis[session.moodAfter]}</span>
{/if}
</div>
{/if}
</div>
<style>
.session-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-radius: 0.75rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-card));
}
.session-main {
flex: 1;
min-width: 0;
}
.session-name {
font-weight: 500;
color: hsl(var(--color-foreground));
font-size: 0.9rem;
}
.session-meta {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
margin-top: 0.1rem;
}
.dot {
opacity: 0.5;
}
.incomplete {
color: hsl(var(--color-destructive));
}
.session-mood {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 1.1rem;
}
.mood-arrow {
font-size: 0.7rem;
color: hsl(var(--color-muted-foreground));
}
</style>

View file

@ -0,0 +1,560 @@
<!--
SessionPlayer — fullscreen meditation session overlay.
Timer, breathing animation, body scan steps, mood tracking, gong.
-->
<script lang="ts">
import type { MeditatePreset, MeditateCategory } from '../types';
import { meditateStore } from '../stores/meditate.svelte';
import { formatDuration } from '../queries';
import BreathingCircle from './BreathingCircle.svelte';
import MoodPicker from './MoodPicker.svelte';
interface Props {
preset: MeditatePreset;
onComplete: () => void;
onCancel: () => void;
}
let { preset, onComplete, onCancel }: Props = $props();
// ─── State ──────────────────────────────────────
type SessionPhase = 'mood_before' | 'countdown' | 'active' | 'finishing' | 'mood_after';
let phase = $state<SessionPhase>('mood_before');
let initialDuration = $derived(preset.defaultDurationSec);
let timeRemaining = $state(0);
let totalDuration = $state(0);
// Initialize from preset
$effect(() => {
timeRemaining = initialDuration;
totalDuration = initialDuration;
});
let isPaused = $state(false);
let moodBefore = $state<number | null>(null);
let moodAfter = $state<number | null>(null);
let notes = $state('');
let countdownValue = $state(3);
let sessionStartTime = $state('');
let currentBodyScanStep = $state(0);
// Timer internals
let timerRef = $state<number | null>(null);
let lastTick = $state(0);
let displayTime = $derived(formatDuration(Math.ceil(timeRemaining)));
let progress = $derived(totalDuration > 0 ? 1 - timeRemaining / totalDuration : 0);
let isBreathing = $derived(preset.category === 'breathing' && preset.breathPattern !== null);
let isBodyScan = $derived(preset.category === 'bodyscan' && preset.bodyScanSteps !== null);
let bodyScanStepCount = $derived(preset.bodyScanSteps?.length ?? 0);
let bodyScanStepText = $derived(preset.bodyScanSteps?.[currentBodyScanStep] ?? '');
let bodyScanTimePerStep = $derived(
bodyScanStepCount > 0 ? totalDuration / bodyScanStepCount : totalDuration
);
// ─── Timer Engine ───────────────────────────────
function startTimer() {
timeRemaining = totalDuration;
isPaused = false;
lastTick = performance.now();
sessionStartTime = new Date().toISOString();
tick();
}
function tick() {
if (isPaused) return;
const now = performance.now();
const elapsed = (now - lastTick) / 1000;
lastTick = now;
timeRemaining = Math.max(0, timeRemaining - elapsed);
// Update body scan step based on elapsed time
if (isBodyScan && bodyScanStepCount > 0) {
const elapsedTotal = totalDuration - timeRemaining;
currentBodyScanStep = Math.min(
Math.floor(elapsedTotal / bodyScanTimePerStep),
bodyScanStepCount - 1
);
}
if (timeRemaining <= 0) {
handleTimerEnd();
return;
}
timerRef = requestAnimationFrame(tick);
}
function togglePause() {
isPaused = !isPaused;
if (!isPaused) {
lastTick = performance.now();
tick();
}
}
function handleTimerEnd() {
if (timerRef) cancelAnimationFrame(timerRef);
playGong();
phase = 'mood_after';
}
function stopSession() {
if (timerRef) cancelAnimationFrame(timerRef);
phase = 'finishing';
}
// ─── Audio (Web Audio API — no external files needed) ───
function playGong() {
try {
const ctx = new AudioContext();
const now = ctx.currentTime;
// Sine tone at ~220 Hz with slow decay = bell/gong character
const osc = ctx.createOscillator();
osc.type = 'sine';
osc.frequency.setValueAtTime(220, now);
osc.frequency.exponentialRampToValueAtTime(180, now + 2);
// Second harmonic for richness
const osc2 = ctx.createOscillator();
osc2.type = 'sine';
osc2.frequency.setValueAtTime(440, now);
osc2.frequency.exponentialRampToValueAtTime(360, now + 1.5);
const gain = ctx.createGain();
gain.gain.setValueAtTime(0.4, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 3);
const gain2 = ctx.createGain();
gain2.gain.setValueAtTime(0.15, now);
gain2.gain.exponentialRampToValueAtTime(0.001, now + 2);
osc.connect(gain).connect(ctx.destination);
osc2.connect(gain2).connect(ctx.destination);
osc.start(now);
osc.stop(now + 3);
osc2.start(now);
osc2.stop(now + 2);
} catch {
// Audio not available — not critical
}
}
// ─── Phase Transitions ──────────────────────────
function startCountdown() {
phase = 'countdown';
countdownValue = 3;
const interval = setInterval(() => {
countdownValue--;
if (countdownValue <= 0) {
clearInterval(interval);
phase = 'active';
playGong();
startTimer();
}
}, 1000);
}
function skipMoodBefore() {
startCountdown();
}
function selectMoodBefore(mood: number) {
moodBefore = mood;
startCountdown();
}
function selectMoodAfter(mood: number) {
moodAfter = mood;
}
async function saveAndFinish() {
const actualDuration = Math.round(totalDuration - timeRemaining);
await meditateStore.logSession({
presetId: preset.id,
category: preset.category,
startedAt: sessionStartTime || new Date().toISOString(),
durationSec: actualDuration,
completed: timeRemaining <= 0,
moodBefore,
moodAfter,
notes: notes.trim() || null,
});
onComplete();
}
async function cancelEarly() {
if (phase === 'active' || phase === 'finishing') {
const actualDuration = Math.round(totalDuration - timeRemaining);
if (actualDuration > 10) {
// Save partial session
await meditateStore.logSession({
presetId: preset.id,
category: preset.category,
startedAt: sessionStartTime || new Date().toISOString(),
durationSec: actualDuration,
completed: false,
moodBefore,
moodAfter: null,
notes: null,
});
}
}
if (timerRef) cancelAnimationFrame(timerRef);
onCancel();
}
// Cleanup on unmount
$effect(() => {
return () => {
if (timerRef) cancelAnimationFrame(timerRef);
};
});
</script>
<div class="session-overlay">
<!-- Close button -->
<button type="button" class="close-btn" onclick={cancelEarly}> </button>
{#if phase === 'mood_before'}
<!-- Mood before meditation -->
<div class="center-content">
<h2 class="phase-title">{preset.name}</h2>
<p class="phase-subtitle">Wie fühlst du dich gerade?</p>
<div class="mood-section">
<MoodPicker value={moodBefore} onSelect={selectMoodBefore} label="Stimmung vorher" />
</div>
<button type="button" class="skip-btn" onclick={skipMoodBefore}> Überspringen </button>
</div>
{:else if phase === 'countdown'}
<!-- 3-2-1 countdown -->
<div class="center-content">
<div class="countdown-number">{countdownValue}</div>
<p class="phase-subtitle">Mach es dir bequem…</p>
</div>
{:else if phase === 'active'}
<!-- Active meditation -->
<div class="center-content">
{#if isBreathing && preset.breathPattern}
<BreathingCircle pattern={preset.breathPattern} isActive={!isPaused} />
{:else if isBodyScan}
<div class="bodyscan-container">
<div class="bodyscan-step-indicator">
{currentBodyScanStep + 1} / {bodyScanStepCount}
</div>
<p class="bodyscan-text">{bodyScanStepText}</p>
</div>
{:else}
<!-- Silence: minimal visual -->
<div class="silence-circle"></div>
{/if}
<div class="timer-display">{displayTime}</div>
<!-- Progress bar -->
<div class="progress-bar">
<div class="progress-fill" style:width="{progress * 100}%"></div>
</div>
<!-- Controls -->
<div class="controls">
<button type="button" class="control-btn" onclick={togglePause}>
{isPaused ? '▶' : '⏸'}
</button>
<button type="button" class="control-btn control-stop" onclick={stopSession}> </button>
</div>
</div>
{:else if phase === 'finishing'}
<!-- Confirm stop early -->
<div class="center-content">
<h2 class="phase-title">Session beenden?</h2>
<p class="phase-subtitle">
Du hast {formatDuration(Math.round(totalDuration - timeRemaining))} meditiert.
</p>
<div class="finish-actions">
<button
type="button"
class="btn btn-secondary"
onclick={() => {
phase = 'active';
lastTick = performance.now();
isPaused = false;
tick();
}}
>
Weitermachen
</button>
<button
type="button"
class="btn btn-primary"
onclick={() => {
phase = 'mood_after';
}}
>
Beenden
</button>
</div>
</div>
{:else if phase === 'mood_after'}
<!-- Post-session: mood + notes -->
<div class="center-content">
<h2 class="phase-title">Geschafft!</h2>
<p class="phase-subtitle">
{formatDuration(Math.round(totalDuration - timeRemaining))} meditiert
</p>
<div class="mood-section">
<MoodPicker value={moodAfter} onSelect={selectMoodAfter} label="Stimmung nachher" />
</div>
<div class="notes-section">
<textarea
class="notes-input"
bind:value={notes}
placeholder="Wie war's? (optional)"
rows="3"
></textarea>
</div>
<button type="button" class="btn btn-primary save-btn" onclick={saveAndFinish}>
Speichern
</button>
</div>
{/if}
</div>
<style>
.session-overlay {
position: fixed;
inset: 0;
z-index: 1000000;
display: flex;
align-items: center;
justify-content: center;
background: hsl(var(--color-background));
}
.close-btn {
position: absolute;
top: 1rem;
right: 1rem;
z-index: 10;
width: 40px;
height: 40px;
border-radius: 50%;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-card));
color: hsl(var(--color-muted-foreground));
font-size: 1.1rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.close-btn:hover {
background: hsl(var(--color-muted));
}
.center-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
max-width: 400px;
padding: 2rem;
width: 100%;
}
.phase-title {
font-size: 1.5rem;
font-weight: 700;
color: hsl(var(--color-foreground));
text-align: center;
}
.phase-subtitle {
font-size: 0.95rem;
color: hsl(var(--color-muted-foreground));
text-align: center;
}
.countdown-number {
font-size: 6rem;
font-weight: 800;
color: hsl(var(--color-primary));
font-variant-numeric: tabular-nums;
}
.mood-section {
margin: 0.5rem 0;
}
.skip-btn {
padding: 0.5rem 1.5rem;
border: none;
background: none;
color: hsl(var(--color-muted-foreground));
font-size: 0.85rem;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 3px;
}
.timer-display {
font-size: 3rem;
font-weight: 700;
color: hsl(var(--color-foreground));
font-variant-numeric: tabular-nums;
margin-top: 1.5rem;
}
.progress-bar {
width: 100%;
max-width: 300px;
height: 4px;
border-radius: 2px;
background: hsl(var(--color-muted));
overflow: hidden;
}
.progress-fill {
height: 100%;
background: hsl(var(--color-primary));
border-radius: 2px;
transition: width 0.3s linear;
}
.controls {
display: flex;
gap: 1rem;
margin-top: 0.5rem;
}
.control-btn {
width: 56px;
height: 56px;
border-radius: 50%;
border: 2px solid hsl(var(--color-border));
background: hsl(var(--color-card));
color: hsl(var(--color-foreground));
font-size: 1.25rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
}
.control-btn:hover {
border-color: hsl(var(--color-primary) / 0.5);
}
.control-stop:hover {
border-color: hsl(var(--color-destructive) / 0.5);
}
/* Body scan */
.bodyscan-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
max-width: 320px;
}
.bodyscan-step-indicator {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
font-variant-numeric: tabular-nums;
}
.bodyscan-text {
font-size: 1.1rem;
color: hsl(var(--color-foreground));
text-align: center;
line-height: 1.6;
}
/* Silence mode */
.silence-circle {
width: 120px;
height: 120px;
border-radius: 50%;
background: radial-gradient(
circle at 40% 40%,
hsl(var(--color-primary) / 0.3),
hsl(var(--color-primary) / 0.1)
);
animation: pulse-soft 4s ease-in-out infinite;
}
@keyframes pulse-soft {
0%,
100% {
transform: scale(1);
opacity: 0.7;
}
50% {
transform: scale(1.05);
opacity: 1;
}
}
/* Buttons */
.finish-actions {
display: flex;
gap: 0.75rem;
}
.btn {
padding: 0.65rem 1.5rem;
border-radius: 0.5rem;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
border: none;
}
.btn-primary {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
.btn-primary:hover {
opacity: 0.9;
}
.btn-secondary {
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
}
.btn-secondary:hover {
background: hsl(var(--color-muted) / 0.8);
}
/* Notes */
.notes-section {
width: 100%;
}
.notes-input {
width: 100%;
padding: 0.75rem;
border-radius: 0.5rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-card));
color: hsl(var(--color-foreground));
font-size: 0.9rem;
resize: vertical;
font-family: inherit;
}
.notes-input::placeholder {
color: hsl(var(--color-muted-foreground));
}
.save-btn {
min-width: 160px;
}
</style>

View file

@ -0,0 +1,80 @@
<!--
StatsOverview — streak, weekly minutes, total sessions.
-->
<script lang="ts">
import type { MeditateSession } from '../types';
import {
getCurrentStreak,
getWeekMinutes,
getWeekSessionCount,
getTotalMinutes,
} from '../queries';
interface Props {
sessions: MeditateSession[];
}
let { sessions }: Props = $props();
const streak = $derived(getCurrentStreak(sessions));
const weekMinutes = $derived(getWeekMinutes(sessions));
const weekSessions = $derived(getWeekSessionCount(sessions));
const totalMinutes = $derived(getTotalMinutes(sessions));
</script>
<div class="stats-grid">
<div class="stat-card">
<span class="stat-value">{streak}</span>
<span class="stat-label">{streak === 1 ? 'Tag' : 'Tage'} Streak</span>
</div>
<div class="stat-card">
<span class="stat-value">{weekMinutes}</span>
<span class="stat-label">Min. diese Woche</span>
</div>
<div class="stat-card">
<span class="stat-value">{weekSessions}</span>
<span class="stat-label">Sessions</span>
</div>
<div class="stat-card">
<span class="stat-value">{totalMinutes}</span>
<span class="stat-label">Min. gesamt</span>
</div>
</div>
<style>
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem;
}
.stat-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.75rem 0.5rem;
border-radius: 0.75rem;
background: hsl(var(--color-muted) / 0.5);
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
color: hsl(var(--color-foreground));
font-variant-numeric: tabular-nums;
}
.stat-label {
font-size: 0.7rem;
color: hsl(var(--color-muted-foreground));
text-align: center;
line-height: 1.2;
margin-top: 0.15rem;
}
@media (max-width: 480px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>

View file

@ -0,0 +1,83 @@
/**
* Meditate module seed presets for new users.
*
* Five presets covering the three categories: silence, breathing, bodyscan.
*/
import type { LocalMeditatePreset } from './types';
export const DEFAULT_PRESETS: LocalMeditatePreset[] = [
{
id: 'meditate-preset-silence',
name: 'Stille Meditation',
description:
'Setze dich hin, schließe die Augen und beobachte deinen Atem ohne ihn zu steuern.',
category: 'silence',
breathPattern: null,
bodyScanSteps: null,
defaultDurationSec: 600,
isPreset: true,
isArchived: false,
order: 0,
},
{
id: 'meditate-preset-box',
name: 'Box Breathing',
description:
'Gleichmäßiges Atmen im 4-4-4-4-Rhythmus. Beruhigt das Nervensystem und schärft den Fokus.',
category: 'breathing',
breathPattern: { inhale: 4, hold1: 4, exhale: 4, hold2: 4 },
bodyScanSteps: null,
defaultDurationSec: 300,
isPreset: true,
isArchived: false,
order: 1,
},
{
id: 'meditate-preset-478',
name: '4-7-8 Entspannung',
description:
'Tiefe Entspannung: 4s einatmen, 7s halten, 8s langsam ausatmen. Ideal zum Einschlafen.',
category: 'breathing',
breathPattern: { inhale: 4, hold1: 7, exhale: 8, hold2: 0 },
bodyScanSteps: null,
defaultDurationSec: 300,
isPreset: true,
isArchived: false,
order: 2,
},
{
id: 'meditate-preset-wimhof',
name: 'Wim Hof',
description:
'Kraftvolles Ein- und Ausatmen in schnellem Rhythmus. Energetisierend und aktivierend.',
category: 'breathing',
breathPattern: { inhale: 2, hold1: 0, exhale: 2, hold2: 0 },
bodyScanSteps: null,
defaultDurationSec: 300,
isPreset: true,
isArchived: false,
order: 3,
},
{
id: 'meditate-preset-bodyscan',
name: 'Body Scan',
description: 'Wandere mit deiner Aufmerksamkeit durch den Körper, von den Füßen bis zum Kopf.',
category: 'bodyscan',
breathPattern: null,
bodyScanSteps: [
'Füße — Spüre den Kontakt zum Boden. Lass die Spannung los.',
'Unterschenkel & Knie — Lass die Muskeln weich werden.',
'Oberschenkel & Hüfte — Spüre die Schwere. Lass los.',
'Bauch & unterer Rücken — Atme in den Bauch. Lass ihn weich werden.',
'Brust & oberer Rücken — Spüre den Atem. Lass die Schultern sinken.',
'Hände & Arme — Lass die Finger entspannen. Spüre die Wärme.',
'Nacken & Schultern — Löse alle Anspannung. Lass den Kopf schwer werden.',
'Gesicht & Kopf — Stirn entspannen, Kiefer lockern, Augen ruhen lassen.',
],
defaultDurationSec: 600,
isPreset: true,
isArchived: false,
order: 4,
},
];

View file

@ -0,0 +1,58 @@
/**
* Meditate module barrel exports.
*/
// ─── Stores ──────────────────────────────────────────────
export { meditateStore } from './stores/meditate.svelte';
// ─── Queries ─────────────────────────────────────────────
export {
useAllPresets,
useAllSessions,
useSettings,
toMeditatePreset,
toMeditateSession,
toMeditateSettings,
todayDateStr,
getSessionsForDate,
getTodaySessions,
getTodayMinutes,
getWeekSessionCount,
getWeekMinutes,
getCurrentStreak,
getTotalSessions,
getTotalMinutes,
formatDuration,
formatDurationLong,
getDefaultSettings,
} from './queries';
// ─── Collections ─────────────────────────────────────────
export {
meditatePresetTable,
meditateSessionTable,
meditateSettingsTable,
MEDITATE_GUEST_SEED,
} from './collections';
// ─── Types ───────────────────────────────────────────────
export {
MEDITATE_CATEGORIES,
CATEGORY_LABELS,
BELL_SOUND_LABELS,
BREATH_PHASE_LABELS,
DEFAULT_SETTINGS,
} from './types';
export type {
MeditateCategory,
BellSound,
BackgroundTheme,
BreathPattern,
BreathPhase,
LocalMeditatePreset,
LocalMeditateSession,
LocalMeditateSettings,
MeditatePreset,
MeditateSession,
MeditateSettings,
} from './types';

View file

@ -0,0 +1,6 @@
import type { ModuleConfig } from '$lib/data/module-registry';
export const meditateModuleConfig: ModuleConfig = {
appId: 'meditate',
tables: [{ name: 'meditatePresets' }, { name: 'meditateSessions' }, { name: 'meditateSettings' }],
};

View file

@ -0,0 +1,184 @@
/**
* Reactive Queries & Pure Helpers for the Meditate module.
*
* Read-side only mutations live in stores/meditate.svelte.ts.
*/
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { decryptRecords } from '$lib/data/crypto';
import { db } from '$lib/data/database';
import type {
LocalMeditatePreset,
LocalMeditateSession,
LocalMeditateSettings,
MeditatePreset,
MeditateSession,
MeditateSettings,
} from './types';
import { DEFAULT_SETTINGS } from './types';
// ─── Type Converters ────────────────────────────────────────
export function toMeditatePreset(local: LocalMeditatePreset): MeditatePreset {
const now = new Date().toISOString();
return {
id: local.id,
name: local.name,
description: local.description ?? '',
category: local.category,
breathPattern: local.breathPattern ?? null,
bodyScanSteps: local.bodyScanSteps ?? null,
defaultDurationSec: local.defaultDurationSec,
isPreset: local.isPreset,
isArchived: local.isArchived,
order: local.order,
createdAt: local.createdAt ?? now,
updatedAt: local.updatedAt ?? now,
};
}
export function toMeditateSession(local: LocalMeditateSession): MeditateSession {
return {
id: local.id,
presetId: local.presetId ?? null,
category: local.category,
startedAt: local.startedAt,
durationSec: local.durationSec,
completed: local.completed,
moodBefore: local.moodBefore ?? null,
moodAfter: local.moodAfter ?? null,
notes: local.notes ?? null,
createdAt: local.createdAt ?? new Date().toISOString(),
};
}
export function toMeditateSettings(local: LocalMeditateSettings): MeditateSettings {
return {
id: local.id,
bellSound: local.bellSound,
intervalBell: local.intervalBell,
intervalSeconds: local.intervalSeconds,
showBreathGuide: local.showBreathGuide,
backgroundTheme: local.backgroundTheme,
};
}
// ─── Live Queries ───────────────────────────────────────────
export function useAllPresets() {
return useLiveQueryWithDefault(async () => {
const locals = await db
.table<LocalMeditatePreset>('meditatePresets')
.orderBy('order')
.toArray();
const visible = locals.filter((p) => !p.deletedAt && !p.isArchived);
const decrypted = await decryptRecords('meditatePresets', visible);
return decrypted.map(toMeditatePreset);
}, [] as MeditatePreset[]);
}
export function useAllSessions() {
return useLiveQueryWithDefault(async () => {
const locals = await db
.table<LocalMeditateSession>('meditateSessions')
.orderBy('startedAt')
.reverse()
.toArray();
const visible = locals.filter((s) => !s.deletedAt);
const decrypted = await decryptRecords('meditateSessions', visible);
return decrypted.map(toMeditateSession);
}, [] as MeditateSession[]);
}
export function useSettings() {
return useLiveQueryWithDefault(
async () => {
const locals = await db.table<LocalMeditateSettings>('meditateSettings').toArray();
if (locals.length === 0) return null;
return toMeditateSettings(locals[0]);
},
null as MeditateSettings | null
);
}
// ─── Pure Helpers (for $derived) ────────────────────────────
export function todayDateStr(): string {
return new Date().toISOString().split('T')[0];
}
export function getSessionsForDate(sessions: MeditateSession[], date: string): MeditateSession[] {
return sessions.filter((s) => s.startedAt.split('T')[0] === date);
}
export function getTodaySessions(sessions: MeditateSession[]): MeditateSession[] {
return getSessionsForDate(sessions, todayDateStr());
}
export function getTodayMinutes(sessions: MeditateSession[]): number {
return Math.round(getTodaySessions(sessions).reduce((sum, s) => sum + s.durationSec, 0) / 60);
}
export function getWeekSessionCount(sessions: MeditateSession[]): number {
const now = new Date();
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString();
return sessions.filter((s) => s.startedAt >= weekAgo).length;
}
export function getWeekMinutes(sessions: MeditateSession[]): number {
const now = new Date();
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString();
return Math.round(
sessions.filter((s) => s.startedAt >= weekAgo).reduce((sum, s) => sum + s.durationSec, 0) / 60
);
}
export function getCurrentStreak(sessions: MeditateSession[]): number {
if (sessions.length === 0) return 0;
const uniqueDays = new Set(sessions.map((s) => s.startedAt.split('T')[0]));
const today = todayDateStr();
let streak = 0;
let d = new Date(today);
// If no session today, start checking from yesterday
if (!uniqueDays.has(today)) {
d.setDate(d.getDate() - 1);
}
while (uniqueDays.has(d.toISOString().split('T')[0])) {
streak++;
d.setDate(d.getDate() - 1);
}
return streak;
}
export function getTotalSessions(sessions: MeditateSession[]): number {
return sessions.filter((s) => s.completed).length;
}
export function getTotalMinutes(sessions: MeditateSession[]): number {
return Math.round(sessions.reduce((sum, s) => sum + s.durationSec, 0) / 60);
}
export function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
if (mins === 0) return `${secs}s`;
if (secs === 0) return `${mins} min`;
return `${mins}:${String(secs).padStart(2, '0')}`;
}
export function formatDurationLong(seconds: number): string {
const mins = Math.floor(seconds / 60);
if (mins === 1) return '1 Minute';
return `${mins} Minuten`;
}
export function getDefaultSettings(): MeditateSettings {
return {
id: 'settings',
...DEFAULT_SETTINGS,
};
}

View file

@ -0,0 +1,143 @@
/**
* Meditate module types meditation timer, breathing exercises, body scans.
*
* Tables:
* meditatePresets predefined & custom meditation/breathing templates
* meditateSessions completed meditation sessions
* meditateSettings per-user preferences (bell sound, theme, etc.)
*/
import type { BaseRecord } from '@mana/local-store';
// ─── Enums / unions ─────────────────────────────────────────
export type MeditateCategory = 'silence' | 'breathing' | 'bodyscan';
export type BellSound = 'gong' | 'bowl' | 'bell' | 'none';
export type BackgroundTheme = 'minimal' | 'gradient' | 'dark';
// ─── Embedded Types ─────────────────────────────────────────
/** Breathing pattern — all values in seconds. */
export interface BreathPattern {
inhale: number;
hold1: number;
exhale: number;
hold2: number;
}
/** Which phase of a breath cycle we're in. */
export type BreathPhase = 'inhale' | 'hold1' | 'exhale' | 'hold2';
// ─── Local Record Types (Dexie) ─────────────────────────────
export interface LocalMeditatePreset extends BaseRecord {
name: string;
description: string;
category: MeditateCategory;
/** null for silence and bodyscan presets. */
breathPattern: BreathPattern | null;
/** Text steps for body scan (e.g. "Feet", "Legs", …). null for other categories. */
bodyScanSteps: string[] | null;
defaultDurationSec: number;
/** Built-in seed vs. user-created. */
isPreset: boolean;
isArchived: boolean;
order: number;
}
export interface LocalMeditateSession extends BaseRecord {
presetId: string | null;
/** Denormalized for stats queries without join. */
category: MeditateCategory;
startedAt: string;
durationSec: number;
completed: boolean;
moodBefore: number | null;
moodAfter: number | null;
notes: string | null;
}
export interface LocalMeditateSettings extends BaseRecord {
bellSound: BellSound;
intervalBell: boolean;
intervalSeconds: number;
showBreathGuide: boolean;
backgroundTheme: BackgroundTheme;
}
// ─── Domain Types (UI-facing) ───────────────────────────────
export interface MeditatePreset {
id: string;
name: string;
description: string;
category: MeditateCategory;
breathPattern: BreathPattern | null;
bodyScanSteps: string[] | null;
defaultDurationSec: number;
isPreset: boolean;
isArchived: boolean;
order: number;
createdAt: string;
updatedAt: string;
}
export interface MeditateSession {
id: string;
presetId: string | null;
category: MeditateCategory;
startedAt: string;
durationSec: number;
completed: boolean;
moodBefore: number | null;
moodAfter: number | null;
notes: string | null;
createdAt: string;
}
export interface MeditateSettings {
id: string;
bellSound: BellSound;
intervalBell: boolean;
intervalSeconds: number;
showBreathGuide: boolean;
backgroundTheme: BackgroundTheme;
}
// ─── Constants ──────────────────────────────────────────────
export const MEDITATE_CATEGORIES: readonly MeditateCategory[] = [
'silence',
'breathing',
'bodyscan',
] as const;
export const CATEGORY_LABELS: Record<MeditateCategory, { de: string; en: string }> = {
silence: { de: 'Stille', en: 'Silence' },
breathing: { de: 'Atemübung', en: 'Breathing' },
bodyscan: { de: 'Body Scan', en: 'Body Scan' },
};
export const BELL_SOUND_LABELS: Record<BellSound, { de: string; en: string }> = {
gong: { de: 'Gong', en: 'Gong' },
bowl: { de: 'Klangschale', en: 'Singing Bowl' },
bell: { de: 'Glocke', en: 'Bell' },
none: { de: 'Aus', en: 'Off' },
};
export const BREATH_PHASE_LABELS: Record<BreathPhase, { de: string; en: string }> = {
inhale: { de: 'Einatmen', en: 'Inhale' },
hold1: { de: 'Halten', en: 'Hold' },
exhale: { de: 'Ausatmen', en: 'Exhale' },
hold2: { de: 'Halten', en: 'Hold' },
};
export const DEFAULT_SETTINGS: Omit<LocalMeditateSettings, keyof BaseRecord> = {
bellSound: 'gong',
intervalBell: false,
intervalSeconds: 300,
showBreathGuide: true,
backgroundTheme: 'minimal',
};

View file

@ -0,0 +1,13 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { setContext } from 'svelte';
import { useAllPresets, useAllSessions, useSettings } from '$lib/modules/meditate/queries';
let { children }: { children: Snippet } = $props();
setContext('meditatePresets', useAllPresets());
setContext('meditateSessions', useAllSessions());
setContext('meditateSettings', useSettings());
</script>
{@render children()}

View file

@ -0,0 +1,149 @@
<!--
Meditate Hub — quick start, stats, presets, recent sessions.
-->
<script lang="ts">
import { getContext } from 'svelte';
import type {
MeditatePreset,
MeditateSession,
MeditateSettings,
} from '$lib/modules/meditate/types';
import PresetCard from '$lib/modules/meditate/components/PresetCard.svelte';
import SessionCard from '$lib/modules/meditate/components/SessionCard.svelte';
import StatsOverview from '$lib/modules/meditate/components/StatsOverview.svelte';
import SessionPlayer from '$lib/modules/meditate/components/SessionPlayer.svelte';
const presetsQuery = getContext<{ value: MeditatePreset[] }>('meditatePresets');
const sessionsQuery = getContext<{ value: MeditateSession[] }>('meditateSessions');
let presets = $derived(presetsQuery.value);
let sessions = $derived(sessionsQuery.value);
let recentSessions = $derived(sessions.slice(0, 5));
// Session player state
let activePreset = $state<MeditatePreset | null>(null);
function startSession(preset: MeditatePreset) {
activePreset = preset;
}
function handleComplete() {
activePreset = null;
}
function handleCancel() {
activePreset = null;
}
</script>
<svelte:head>
<title>Meditate - Mana</title>
</svelte:head>
{#if activePreset}
<SessionPlayer preset={activePreset} onComplete={handleComplete} onCancel={handleCancel} />
{/if}
<div class="page">
<header class="page-header">
<h1 class="page-title">Meditate</h1>
<p class="page-subtitle">Finde deine Ruhe</p>
</header>
<!-- Stats -->
{#if sessions.length > 0}
<section class="section">
<StatsOverview {sessions} />
</section>
{/if}
<!-- Presets -->
<section class="section">
<div class="section-header">
<h2 class="section-title">Meditationen</h2>
</div>
<div class="preset-list">
{#each presets as preset (preset.id)}
<PresetCard {preset} onStart={startSession} />
{/each}
</div>
</section>
<!-- Recent sessions -->
{#if recentSessions.length > 0}
<section class="section">
<div class="section-header">
<h2 class="section-title">Letzte Sessions</h2>
<a href="/meditate/history" class="section-link">Alle →</a>
</div>
<div class="session-list">
{#each recentSessions as session (session.id)}
<SessionCard {session} {presets} />
{/each}
</div>
</section>
{/if}
</div>
<style>
.page {
max-width: 640px;
margin: 0 auto;
padding: 1.5rem 1rem 3rem;
}
.page-header {
margin-bottom: 1.5rem;
}
.page-title {
font-size: 1.75rem;
font-weight: 700;
color: hsl(var(--color-foreground));
}
.page-subtitle {
font-size: 0.9rem;
color: hsl(var(--color-muted-foreground));
margin-top: 0.2rem;
}
.section {
margin-bottom: 1.5rem;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.section-title {
font-size: 1rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.section-link {
font-size: 0.8rem;
color: hsl(var(--color-primary));
text-decoration: none;
}
.section-link:hover {
text-decoration: underline;
}
.preset-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.session-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
</style>

View file

@ -0,0 +1,155 @@
<!--
Meditate History — full session history with category filter.
-->
<script lang="ts">
import { getContext } from 'svelte';
import type {
MeditatePreset,
MeditateSession,
MeditateCategory,
} from '$lib/modules/meditate/types';
import { CATEGORY_LABELS } from '$lib/modules/meditate/types';
import SessionCard from '$lib/modules/meditate/components/SessionCard.svelte';
import { getTotalMinutes, getTotalSessions } from '$lib/modules/meditate/queries';
const presetsQuery = getContext<{ value: MeditatePreset[] }>('meditatePresets');
const sessionsQuery = getContext<{ value: MeditateSession[] }>('meditateSessions');
let presets = $derived(presetsQuery.value);
let sessions = $derived(sessionsQuery.value);
let categoryFilter = $state<MeditateCategory | 'all'>('all');
let filtered = $derived(
categoryFilter === 'all' ? sessions : sessions.filter((s) => s.category === categoryFilter)
);
let totalMin = $derived(getTotalMinutes(filtered));
let totalCount = $derived(getTotalSessions(filtered));
</script>
<svelte:head>
<title>Meditation History - Mana</title>
</svelte:head>
<div class="page">
<header class="page-header">
<a href="/meditate" class="back-link">← Meditate</a>
<h1 class="page-title">Verlauf</h1>
<p class="page-subtitle">{totalCount} Sessions · {totalMin} Minuten</p>
</header>
<!-- Filter -->
<div class="filter-bar">
<button
type="button"
class="filter-btn"
class:active={categoryFilter === 'all'}
onclick={() => (categoryFilter = 'all')}
>
Alle
</button>
{#each Object.entries(CATEGORY_LABELS) as [key, label]}
<button
type="button"
class="filter-btn"
class:active={categoryFilter === key}
onclick={() => (categoryFilter = key as MeditateCategory)}
>
{label.de}
</button>
{/each}
</div>
<!-- Session list -->
{#if filtered.length === 0}
<div class="empty">
<p class="empty-text">Noch keine Sessions.</p>
</div>
{:else}
<div class="session-list">
{#each filtered as session (session.id)}
<SessionCard {session} {presets} />
{/each}
</div>
{/if}
</div>
<style>
.page {
max-width: 640px;
margin: 0 auto;
padding: 1.5rem 1rem 3rem;
}
.page-header {
margin-bottom: 1rem;
}
.back-link {
font-size: 0.8rem;
color: hsl(var(--color-muted-foreground));
text-decoration: none;
}
.back-link:hover {
color: hsl(var(--color-primary));
}
.page-title {
font-size: 1.5rem;
font-weight: 700;
color: hsl(var(--color-foreground));
margin-top: 0.25rem;
}
.page-subtitle {
font-size: 0.85rem;
color: hsl(var(--color-muted-foreground));
margin-top: 0.15rem;
}
.filter-bar {
display: flex;
gap: 0.35rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.filter-btn {
padding: 0.4rem 0.85rem;
border-radius: 9999px;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-card));
color: hsl(var(--color-muted-foreground));
font-size: 0.8rem;
cursor: pointer;
transition: all 0.15s ease;
}
.filter-btn:hover {
border-color: hsl(var(--color-primary) / 0.5);
}
.filter-btn.active {
background: hsl(var(--color-primary) / 0.15);
border-color: hsl(var(--color-primary));
color: hsl(var(--color-primary));
}
.session-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.empty {
text-align: center;
padding: 3rem 1rem;
}
.empty-text {
color: hsl(var(--color-muted-foreground));
font-size: 0.9rem;
}
</style>