mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
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:
parent
d11f6aebf7
commit
73e3fdbbed
16 changed files with 2161 additions and 0 deletions
205
apps/mana/apps/web/src/lib/modules/meditate/ListView.svelte
Normal file
205
apps/mana/apps/web/src/lib/modules/meditate/ListView.svelte
Normal 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>
|
||||
23
apps/mana/apps/web/src/lib/modules/meditate/collections.ts
Normal file
23
apps/mana/apps/web/src/lib/modules/meditate/collections.ts
Normal 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>[],
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
58
apps/mana/apps/web/src/lib/modules/meditate/index.ts
Normal file
58
apps/mana/apps/web/src/lib/modules/meditate/index.ts
Normal 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';
|
||||
|
|
@ -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' }],
|
||||
};
|
||||
184
apps/mana/apps/web/src/lib/modules/meditate/queries.ts
Normal file
184
apps/mana/apps/web/src/lib/modules/meditate/queries.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
143
apps/mana/apps/web/src/lib/modules/meditate/types.ts
Normal file
143
apps/mana/apps/web/src/lib/modules/meditate/types.ts
Normal 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',
|
||||
};
|
||||
13
apps/mana/apps/web/src/routes/(app)/meditate/+layout.svelte
Normal file
13
apps/mana/apps/web/src/routes/(app)/meditate/+layout.svelte
Normal 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()}
|
||||
149
apps/mana/apps/web/src/routes/(app)/meditate/+page.svelte
Normal file
149
apps/mana/apps/web/src/routes/(app)/meditate/+page.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue