feat(stretch): add stretch module with guided routines, assessment, and reminders

New "Dehnen/Stretch" module for guided stretching with timer-based sessions,
mobility self-assessments, streak tracking, and configurable reminders.

Includes: 22 seed exercises, 5 preset routines (morning, desk break, evening,
upper body, lower body), fullscreen session player with Performance.now() timer
and Wake Lock, 6-step mobility assessment wizard with scoring, 30-day heatmap,
body region balance chart, custom routine builder, and reminder management.

Registered in module-registry, encryption registry (5 tables), database v9,
seed-registry, app-icons, mana-apps, and workbench app-registry.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-13 20:29:06 +02:00
parent e927c1f10f
commit aabf130480
23 changed files with 5341 additions and 1 deletions

View file

@ -49,6 +49,8 @@ import {
BookOpen,
Books,
CookingPot,
PersonSimpleTaiChi,
Envelope,
} from '@mana/shared-icons';
// ── Apps with entity capabilities ───────────────────────────
@ -866,3 +868,34 @@ registerApp({
},
],
});
registerApp({
id: 'stretch',
name: 'Stretch',
color: '#10b981',
icon: PersonSimpleTaiChi,
views: {
list: { load: () => import('$lib/modules/stretch/ListView.svelte') },
},
});
registerApp({
id: 'mail',
name: 'Mail',
color: '#6366f1',
icon: Envelope,
views: {
list: { load: () => import('$lib/modules/mail/ListView.svelte') },
},
contextMenuActions: [
{
id: 'new-mail',
label: 'Neue Mail',
icon: Plus,
action: () =>
window.dispatchEvent(
new CustomEvent('mana:quick-action', { detail: { app: 'mail', action: 'compose' } })
),
},
],
});

View file

@ -418,6 +418,36 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
// Plaintext (intentional): difficulty, tags, servings, times,
// isFavorite, photo refs — needed for indexing and filtering.
recipes: { enabled: true, fields: ['title', 'description', 'ingredients', 'steps'] },
// ─── Stretch ──────────────────────────────────────────────
// Health/wellness data — GDPR Art. 9 sensitive. Exercise names/descriptions
// are encrypted (user-created ones contain personal context). Routines
// encrypt the exercises array (contains per-slot notes). Sessions encrypt
// the routine name snapshot + user notes. Assessments encrypt the full
// test results + pain regions. Reminders encrypt only the user-given name.
// Plaintext: bodyRegion, difficulty, routineType, order, timestamps,
// bilateral, isPreset, isPinned, isActive, days, time — all needed for
// indexing/filtering.
stretchExercises: { enabled: true, fields: ['name', 'description'] },
stretchRoutines: { enabled: true, fields: ['name', 'description', 'exercises'] },
stretchSessions: { enabled: true, fields: ['routineName', 'notes'] },
stretchAssessments: { enabled: true, fields: ['tests', 'painRegions', 'notes'] },
stretchReminders: { enabled: true, fields: ['name'] },
// ─── Mail ────────────────────────────────────────────────
// Only drafts are stored locally (threads/messages come from server).
// Encrypt all user-typed content in drafts.
mailDrafts: { enabled: true, fields: ['to', 'cc', 'subject', 'body', 'htmlBody'] },
// ─── Meditate ────────────────────────────────────────────
// Meditation presets encrypt user-typed names, descriptions, and body scan
// step text. Sessions encrypt only the optional reflection notes.
// Plaintext: category, breathPattern, defaultDurationSec, order, startedAt,
// durationSec, completed, moodBefore, moodAfter — needed for stats/filtering.
// Settings are structural only (no user-typed text), so encryption is off.
meditatePresets: { enabled: true, fields: ['name', 'description', 'bodyScanSteps'] },
meditateSessions: { enabled: true, fields: ['notes'] },
meditateSettings: { enabled: false, fields: [] },
};
/**

View file

@ -428,6 +428,21 @@ db.version(10).stores({
'++seq, type, meta.appId, meta.timestamp, meta.recordId, [meta.appId+meta.timestamp], [type+meta.timestamp]',
});
// Schema version 11 — adds the Mail module (local draft cache).
// Mail content lives server-side in Stalwart (JMAP). Only drafts are local-first.
db.version(11).stores({
mailDrafts: 'id, accountId, replyToMessageId',
});
// Schema version 12 — adds the Meditate module (guided meditation, breathing
// exercises, body scans). Presets index category+order for the picker grid.
// Sessions index startedAt for the history timeline (reverse range scan).
db.version(12).stores({
meditatePresets: 'id, category, isPreset, isArchived, order',
meditateSessions: 'id, presetId, startedAt, [startedAt+presetId]',
meditateSettings: 'id',
});
// ─── Sync Routing ──────────────────────────────────────────
// SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE,
// toSyncName() and fromSyncName() are now derived from per-module

View file

@ -90,6 +90,9 @@ import { bodyModuleConfig } from '$lib/modules/body/module.config';
import { firstsModuleConfig } from '$lib/modules/firsts/module.config';
import { drinkModuleConfig } from '$lib/modules/drink/module.config';
import { recipesModuleConfig } from '$lib/modules/recipes/module.config';
import { stretchModuleConfig } from '$lib/modules/stretch/module.config';
import { mailModuleConfig } from '$lib/modules/mail/module.config';
import { meditateModuleConfig } from '$lib/modules/meditate/module.config';
export const MODULE_CONFIGS: readonly ModuleConfig[] = [
manaCoreConfig,
@ -135,6 +138,9 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
firstsModuleConfig,
drinkModuleConfig,
recipesModuleConfig,
stretchModuleConfig,
mailModuleConfig,
meditateModuleConfig,
];
// ─── Derived Maps ──────────────────────────────────────────

View file

@ -30,6 +30,8 @@ import { TIMES_GUEST_SEED } from '$lib/modules/times/collections';
import { PLANTS_GUEST_SEED } from '$lib/modules/plants/collections';
import { DRINK_GUEST_SEED } from '$lib/modules/drink/collections';
import { RECIPES_GUEST_SEED } from '$lib/modules/recipes/collections';
import { STRETCH_GUEST_SEED } from '$lib/modules/stretch/collections';
import { MEDITATE_GUEST_SEED } from '$lib/modules/meditate/collections';
/**
* Flat list of { tableName, rows } entries. Only modules with non-empty
@ -64,6 +66,8 @@ register(TIMES_GUEST_SEED);
register(PLANTS_GUEST_SEED);
register(DRINK_GUEST_SEED);
register(RECIPES_GUEST_SEED);
register(STRETCH_GUEST_SEED);
register(MEDITATE_GUEST_SEED);
/**
* Seed all module guest data into empty tables. Idempotent: tables

View file

@ -0,0 +1,734 @@
<!--
Stretch — ListView (Dashboard)
Streak, quick-start routines, assessment recommendation, recent sessions.
-->
<script lang="ts">
import { getContext } from 'svelte';
import type { Observable } from 'dexie';
import type {
StretchExercise,
StretchRoutine,
StretchSession,
StretchAssessment,
StretchReminder,
} from './types';
import {
getCurrentStreak,
getTodayMinutes,
getWeekSessionCount,
getSessionsPerDay,
getLatestAssessment,
getWeakAreas,
getRecommendedRoutine,
relativeDays,
getTodaySessions,
} from './queries';
import { ROUTINE_TYPE_LABELS, BODY_REGION_LABELS } from './types';
import SessionPlayer from './components/SessionPlayer.svelte';
import AssessmentWizard from './components/AssessmentWizard.svelte';
import RoutineCreator from './components/RoutineCreator.svelte';
import ReminderManager from './components/ReminderManager.svelte';
import SessionHistory from './components/SessionHistory.svelte';
const exercises$: Observable<StretchExercise[]> = getContext('stretchExercises');
const routines$: Observable<StretchRoutine[]> = getContext('stretchRoutines');
const sessions$: Observable<StretchSession[]> = getContext('stretchSessions');
const assessments$: Observable<StretchAssessment[]> = getContext('stretchAssessments');
const reminders$: Observable<StretchReminder[]> = getContext('stretchReminders');
let exercises = $state<StretchExercise[]>([]);
let routines = $state<StretchRoutine[]>([]);
let sessions = $state<StretchSession[]>([]);
let assessments = $state<StretchAssessment[]>([]);
let reminders = $state<StretchReminder[]>([]);
$effect(() => {
const sub = exercises$.subscribe((v) => (exercises = v));
return () => sub.unsubscribe();
});
$effect(() => {
const sub = routines$.subscribe((v) => (routines = v));
return () => sub.unsubscribe();
});
$effect(() => {
const sub = sessions$.subscribe((v) => (sessions = v));
return () => sub.unsubscribe();
});
$effect(() => {
const sub = assessments$.subscribe((v) => (assessments = v));
return () => sub.unsubscribe();
});
$effect(() => {
const sub = reminders$.subscribe((v) => (reminders = v));
return () => sub.unsubscribe();
});
let streak = $derived(getCurrentStreak(sessions));
let todayMinutes = $derived(Math.round(getTodayMinutes(sessions)));
let weekCount = $derived(getWeekSessionCount(sessions));
let todaySessions = $derived(getTodaySessions(sessions));
let latestAssessment = $derived(getLatestAssessment(assessments));
let weakAreas = $derived(getWeakAreas(latestAssessment));
let recommended = $derived(getRecommendedRoutine(routines, weakAreas));
let pinnedRoutines = $derived(routines.filter((r) => r.isPinned));
let last7Days = $derived(getSessionsPerDay(sessions, 7));
// UI state
let activeRoutineId = $state<string | null>(null);
let showAssessment = $state(false);
let showCreateRoutine = $state(false);
let showReminders = $state(false);
let showHistory = $state(false);
let activeTab = $state<'dashboard' | 'routines' | 'exercises'>('dashboard');
function startRoutine(routineId: string) {
activeRoutineId = routineId;
}
function handleSessionComplete() {
activeRoutineId = null;
}
</script>
<!-- Session Player (fullscreen overlay when active) -->
{#if activeRoutineId}
{@const routine = routines.find((r) => r.id === activeRoutineId)}
{#if routine}
<SessionPlayer
{routine}
{exercises}
onComplete={handleSessionComplete}
onCancel={() => (activeRoutineId = null)}
/>
{/if}
{:else if showAssessment}
<AssessmentWizard
onComplete={() => (showAssessment = false)}
onCancel={() => (showAssessment = false)}
/>
{:else if showCreateRoutine}
<RoutineCreator
{exercises}
onComplete={() => (showCreateRoutine = false)}
onCancel={() => (showCreateRoutine = false)}
/>
{:else if showReminders}
<ReminderManager {reminders} {routines} onClose={() => (showReminders = false)} />
{:else if showHistory}
<SessionHistory {sessions} {routines} onClose={() => (showHistory = false)} />
{:else}
<div class="stretch-view">
<!-- Tab Bar -->
<div class="tab-bar">
<button
class="tab"
class:active={activeTab === 'dashboard'}
onclick={() => (activeTab = 'dashboard')}>Dashboard</button
>
<button
class="tab"
class:active={activeTab === 'routines'}
onclick={() => (activeTab = 'routines')}>Routinen</button
>
<button
class="tab"
class:active={activeTab === 'exercises'}
onclick={() => (activeTab = 'exercises')}>Übungen</button
>
</div>
{#if activeTab === 'dashboard'}
<!-- Stats Row -->
<div class="stats-row">
<div class="stat-card">
<span class="stat-value">{streak}</span>
<span class="stat-label">Tage Streak</span>
</div>
<div class="stat-card">
<span class="stat-value">{todayMinutes}</span>
<span class="stat-label">Min heute</span>
</div>
<div class="stat-card">
<span class="stat-value">{weekCount}</span>
<span class="stat-label">Diese Woche</span>
</div>
</div>
<!-- Mini Heatmap (last 7 days) -->
<div class="heatmap-row">
{#each last7Days as day}
<div class="heatmap-day" title="{day.date}: {day.minutes} Min">
<div class="heatmap-dot" class:active={day.count > 0} class:multi={day.count > 1}></div>
<span class="heatmap-label"
>{new Date(day.date + 'T00:00').toLocaleDateString('de', { weekday: 'narrow' })}</span
>
</div>
{/each}
</div>
<!-- Quick Start -->
<div class="section">
<div class="section-header">
<span class="section-title">Schnellstart</span>
</div>
<div class="routine-grid">
{#each pinnedRoutines as routine (routine.id)}
<button class="routine-card" onclick={() => startRoutine(routine.id)}>
<span class="routine-name">{routine.name}</span>
<span class="routine-meta">{routine.estimatedDurationMin} Min</span>
<span class="routine-type"
>{ROUTINE_TYPE_LABELS[routine.routineType]?.de ?? routine.routineType}</span
>
</button>
{/each}
</div>
</div>
<!-- Assessment Recommendation -->
{#if recommended}
<div class="recommendation">
<div class="rec-header">
{#if weakAreas.length > 0}
<span class="rec-title">Empfohlen für dich</span>
<span class="rec-detail">
Schwachstellen: {weakAreas.map((r) => BODY_REGION_LABELS[r]?.de ?? r).join(', ')}
</span>
{:else}
<span class="rec-title">Vorschlag</span>
{/if}
</div>
<button class="rec-btn" onclick={() => startRoutine(recommended.id)}>
{recommended.name} starten ({recommended.estimatedDurationMin} Min)
</button>
</div>
{/if}
<!-- Assessment CTA -->
<button class="action-btn" onclick={() => (showAssessment = true)}>
{latestAssessment ? 'Bestandsaufnahme wiederholen' : 'Erste Bestandsaufnahme starten'}
{#if latestAssessment}
<span class="action-meta"
>Letzte: {relativeDays(latestAssessment.assessedAt)} — Score: {latestAssessment.overallScore}%</span
>
{/if}
</button>
<!-- Recent Sessions -->
{#if todaySessions.length > 0}
<div class="section">
<div class="section-header">
<span class="section-title">Heute</span>
<button class="link-btn" onclick={() => (showHistory = true)}>Alle</button>
</div>
{#each todaySessions.slice(0, 3) as session (session.id)}
<div class="session-row">
<span class="session-name">{session.routineName}</span>
<span class="session-duration">{Math.round(session.totalDurationSec / 60)} Min</span>
<span class="session-time">{session.startedAt.split('T')[1]?.slice(0, 5)}</span>
</div>
{/each}
</div>
{/if}
<!-- Quick Actions -->
<div class="quick-actions">
<button class="action-link" onclick={() => (showReminders = true)}>Erinnerungen</button>
<button class="action-link" onclick={() => (showHistory = true)}>Verlauf</button>
</div>
{:else if activeTab === 'routines'}
<!-- All Routines -->
<div class="routines-list">
{#each routines as routine (routine.id)}
<button class="routine-list-item" onclick={() => startRoutine(routine.id)}>
<div class="rli-left">
<span class="rli-name">{routine.name}</span>
<span class="rli-desc">{routine.description}</span>
</div>
<div class="rli-right">
<span class="rli-duration">{routine.estimatedDurationMin} Min</span>
<span class="rli-type">{ROUTINE_TYPE_LABELS[routine.routineType]?.de ?? ''}</span>
</div>
</button>
{/each}
<button class="add-routine-btn" onclick={() => (showCreateRoutine = true)}>
+ Eigene Routine erstellen
</button>
</div>
{:else if activeTab === 'exercises'}
<!-- Exercise Library -->
<div class="exercises-list">
{#each exercises.filter((e) => !e.isArchived) as ex (ex.id)}
<div class="exercise-item">
<div class="ex-left">
<span class="ex-name">{ex.name}</span>
<span class="ex-desc">{ex.description}</span>
</div>
<div class="ex-right">
<span class="ex-region">{BODY_REGION_LABELS[ex.bodyRegion]?.de ?? ex.bodyRegion}</span
>
<span class="ex-duration"
>{ex.defaultDurationSec}s{ex.bilateral ? ' /Seite' : ''}</span
>
</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}
<style>
.stretch-view {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.5rem;
}
/* ── Tab Bar ──────────────────────────────────── */
.tab-bar {
display: flex;
gap: 0.25rem;
background: hsl(var(--color-muted));
border-radius: 0.5rem;
padding: 0.125rem;
}
.tab {
flex: 1;
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
font-size: 0.75rem;
font-weight: 500;
background: transparent;
border: none;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
transition: all 0.15s;
}
.tab.active {
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
/* ── Stats ────────────────────────────────────── */
.stats-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
}
.stat-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.5rem;
border-radius: 0.75rem;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
}
.stat-value {
font-size: 1.25rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: hsl(var(--color-foreground));
}
.stat-label {
font-size: 0.625rem;
color: hsl(var(--color-muted-foreground));
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* ── Heatmap ──────────────────────────────────── */
.heatmap-row {
display: flex;
justify-content: space-between;
gap: 0.25rem;
padding: 0.375rem 0.5rem;
}
.heatmap-day {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.125rem;
}
.heatmap-dot {
width: 12px;
height: 12px;
border-radius: 3px;
background: hsl(var(--color-border));
transition: background 0.2s;
}
.heatmap-dot.active {
background: #10b981;
}
.heatmap-dot.multi {
background: #059669;
}
.heatmap-label {
font-size: 0.5625rem;
color: hsl(var(--color-muted-foreground));
}
/* ── Section ──────────────────────────────────── */
.section {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.section-title {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: hsl(var(--color-muted-foreground));
}
.link-btn {
font-size: 0.6875rem;
color: hsl(var(--color-primary));
background: none;
border: none;
cursor: pointer;
text-decoration: underline;
}
/* ── Routine Grid ─────────────────────────────── */
.routine-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 0.5rem;
}
.routine-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.125rem;
padding: 0.625rem 0.375rem;
border-radius: 0.75rem;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
cursor: pointer;
transition:
transform 0.15s,
box-shadow 0.15s;
color: hsl(var(--color-foreground));
}
.routine-card:hover {
transform: scale(1.03);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}
.routine-card:active {
transform: scale(0.97);
}
.routine-name {
font-size: 0.75rem;
font-weight: 600;
text-align: center;
}
.routine-meta {
font-size: 0.6875rem;
color: #10b981;
font-weight: 600;
}
.routine-type {
font-size: 0.5625rem;
color: hsl(var(--color-muted-foreground));
}
/* ── Recommendation ───────────────────────────── */
.recommendation {
display: flex;
flex-direction: column;
gap: 0.375rem;
padding: 0.75rem;
border-radius: 0.75rem;
background: hsl(160 60% 96%);
border: 1px solid hsl(160 40% 88%);
}
:global(.dark) .recommendation {
background: hsl(160 30% 12%);
border-color: hsl(160 30% 20%);
}
.rec-header {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.rec-title {
font-size: 0.6875rem;
font-weight: 600;
color: #059669;
}
.rec-detail {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
}
.rec-btn {
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
background: #10b981;
color: white;
border: none;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
transition: filter 0.15s;
}
.rec-btn:hover {
filter: brightness(1.1);
}
/* ── Action Button ────────────────────────────── */
.action-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.125rem;
padding: 0.625rem 0.75rem;
border-radius: 0.75rem;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
}
.action-btn:hover {
background: hsl(var(--color-muted) / 0.8);
}
.action-meta {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
font-weight: 400;
}
/* ── Session Row ──────────────────────────────── */
.session-row {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.375rem;
border-radius: 0.375rem;
font-size: 0.8125rem;
color: hsl(var(--color-foreground));
}
.session-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-duration {
color: #10b981;
font-weight: 600;
font-variant-numeric: tabular-nums;
font-size: 0.75rem;
}
.session-time {
color: hsl(var(--color-muted-foreground));
font-variant-numeric: tabular-nums;
font-size: 0.75rem;
}
/* ── Quick Actions ────────────────────────────── */
.quick-actions {
display: flex;
gap: 0.75rem;
justify-content: center;
padding: 0.375rem 0;
}
.action-link {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
background: none;
border: none;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.action-link:hover {
color: hsl(var(--color-foreground));
}
/* ── Routines List ────────────────────────────── */
.routines-list {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.routine-list-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.625rem 0.75rem;
border-radius: 0.75rem;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
cursor: pointer;
text-align: left;
color: hsl(var(--color-foreground));
transition: transform 0.15s;
}
.routine-list-item:hover {
transform: translateX(2px);
}
.rli-left {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
flex: 1;
}
.rli-name {
font-size: 0.8125rem;
font-weight: 600;
}
.rli-desc {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rli-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.125rem;
flex-shrink: 0;
}
.rli-duration {
font-size: 0.75rem;
font-weight: 600;
color: #10b981;
}
.rli-type {
font-size: 0.5625rem;
color: hsl(var(--color-muted-foreground));
}
.add-routine-btn {
padding: 0.625rem 0.75rem;
border-radius: 0.75rem;
border: 2px dashed hsl(var(--color-border));
background: transparent;
color: hsl(var(--color-muted-foreground));
font-size: 0.8125rem;
cursor: pointer;
transition:
border-color 0.15s,
color 0.15s;
}
.add-routine-btn:hover {
border-color: #10b981;
color: #10b981;
}
/* ── Exercises List ────────────────────────────── */
.exercises-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.exercise-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.625rem;
border-radius: 0.5rem;
font-size: 0.8125rem;
}
.exercise-item:hover {
background: hsl(var(--color-muted));
}
.ex-left {
display: flex;
flex-direction: column;
gap: 0.0625rem;
min-width: 0;
flex: 1;
}
.ex-name {
font-weight: 500;
color: hsl(var(--color-foreground));
}
.ex-desc {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ex-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.0625rem;
flex-shrink: 0;
}
.ex-region {
font-size: 0.625rem;
color: #10b981;
font-weight: 500;
}
.ex-duration {
font-size: 0.625rem;
color: hsl(var(--color-muted-foreground));
font-variant-numeric: tabular-nums;
}
</style>

View file

@ -0,0 +1,481 @@
/**
* Stretch module collection accessors and guest seed data.
*
* Tables are defined in the unified database.ts as:
* stretchExercises, stretchRoutines, stretchSessions,
* stretchAssessments, stretchReminders.
*/
import { db } from '$lib/data/database';
import type {
LocalStretchExercise,
LocalStretchRoutine,
LocalStretchSession,
LocalStretchAssessment,
LocalStretchReminder,
} from './types';
// ─── Collection Accessors ───────────────────────────────────
export const stretchExerciseTable = db.table<LocalStretchExercise>('stretchExercises');
export const stretchRoutineTable = db.table<LocalStretchRoutine>('stretchRoutines');
export const stretchSessionTable = db.table<LocalStretchSession>('stretchSessions');
export const stretchAssessmentTable = db.table<LocalStretchAssessment>('stretchAssessments');
export const stretchReminderTable = db.table<LocalStretchReminder>('stretchReminders');
// ─── Guest Seed ─────────────────────────────────────────────
/**
* Preset exercise library so a fresh guest can immediately start a routine.
* isPreset:true prevents deletion; users can add their own exercises freely.
*/
export const STRETCH_GUEST_SEED = {
stretchExercises: [
// ─── Neck ─────────────────────────────────────────
{
id: 'stretch-ex-neck-lateral',
name: 'Nacken seitlich neigen',
description:
'Kopf sanft zur Seite neigen, gegenüberliegende Schulter nach unten drücken. Dehnung an der Halsseite spüren.',
bodyRegion: 'neck',
difficulty: 'beginner',
defaultDurationSec: 30,
bilateral: true,
tags: ['nacken', 'büro'],
isPreset: true,
isArchived: false,
order: 0,
},
{
id: 'stretch-ex-neck-rotation',
name: 'Nacken-Rotation',
description:
'Kopf langsam nach rechts drehen, kurz halten, dann nach links. Sanft bis zur Dehnungsgrenze.',
bodyRegion: 'neck',
difficulty: 'beginner',
defaultDurationSec: 20,
bilateral: true,
tags: ['nacken', 'büro'],
isPreset: true,
isArchived: false,
order: 1,
},
// ─── Shoulders ────────────────────────────────────
{
id: 'stretch-ex-shoulder-cross',
name: 'Schulter-Dehnung quer',
description:
'Arm vor der Brust quer halten, mit der anderen Hand sanft heranziehen. Dehnung in der hinteren Schulter spüren.',
bodyRegion: 'shoulders',
difficulty: 'beginner',
defaultDurationSec: 30,
bilateral: true,
tags: ['schulter', 'büro'],
isPreset: true,
isArchived: false,
order: 2,
},
{
id: 'stretch-ex-triceps-overhead',
name: 'Trizeps-Dehnung über Kopf',
description:
'Arm über den Kopf heben, Ellbogen beugen, Hand zum gegenüberliegenden Schulterblatt. Mit der anderen Hand sanft am Ellbogen drücken.',
bodyRegion: 'arms',
difficulty: 'beginner',
defaultDurationSec: 30,
bilateral: true,
tags: ['arme', 'schulter'],
isPreset: true,
isArchived: false,
order: 3,
},
// ─── Chest ────────────────────────────────────────
{
id: 'stretch-ex-chest-doorway',
name: 'Brustöffner am Türrahmen',
description:
'Unterarm am Türrahmen, Ellbogen auf Schulterhöhe. Oberkörper sanft nach vorne lehnen, bis Dehnung in der Brust spürbar.',
bodyRegion: 'chest',
difficulty: 'beginner',
defaultDurationSec: 30,
bilateral: true,
tags: ['brust', 'büro'],
isPreset: true,
isArchived: false,
order: 4,
},
// ─── Upper Back ───────────────────────────────────
{
id: 'stretch-ex-cat-cow',
name: 'Katze-Kuh (Cat-Cow)',
description:
'Vierfüßlerstand. Einatmen: Rücken durchhängen, Blick nach oben. Ausatmen: Rücken runden, Kinn zur Brust.',
bodyRegion: 'upper_back',
difficulty: 'beginner',
defaultDurationSec: 30,
bilateral: false,
tags: ['rücken', 'mobilität'],
isPreset: true,
isArchived: false,
order: 5,
},
{
id: 'stretch-ex-thread-needle',
name: 'Faden durch das Nadelöhr',
description:
'Vierfüßlerstand. Einen Arm unter dem Körper durchfädeln, Schulter ablegen. Rotation in der Brustwirbelsäule.',
bodyRegion: 'upper_back',
difficulty: 'beginner',
defaultDurationSec: 30,
bilateral: true,
tags: ['rücken', 'rotation'],
isPreset: true,
isArchived: false,
order: 6,
},
// ─── Lower Back ───────────────────────────────────
{
id: 'stretch-ex-child-pose',
name: "Kindshaltung (Child's Pose)",
description:
'Knie am Boden, Gesäß auf die Fersen setzen, Arme nach vorne strecken, Stirn ablegen. Rücken sanft dehnen.',
bodyRegion: 'lower_back',
difficulty: 'beginner',
defaultDurationSec: 45,
bilateral: false,
tags: ['rücken', 'entspannung'],
isPreset: true,
isArchived: false,
order: 7,
},
{
id: 'stretch-ex-cobra',
name: 'Kobra (Cobra)',
description:
'Bauchlage, Hände neben den Schultern. Oberkörper langsam hochdrücken, Hüfte bleibt am Boden. Dehnung in Bauch und unterem Rücken.',
bodyRegion: 'lower_back',
difficulty: 'beginner',
defaultDurationSec: 30,
bilateral: false,
tags: ['rücken', 'bauch'],
isPreset: true,
isArchived: false,
order: 8,
},
{
id: 'stretch-ex-spinal-twist',
name: 'Liegende Wirbelsäulendrehung',
description:
'Rückenlage, ein Knie zur Seite fallen lassen, Schultern am Boden. Sanfte Rotation der Wirbelsäule.',
bodyRegion: 'lower_back',
difficulty: 'beginner',
defaultDurationSec: 30,
bilateral: true,
tags: ['rücken', 'rotation'],
isPreset: true,
isArchived: false,
order: 9,
},
// ─── Hips ─────────────────────────────────────────
{
id: 'stretch-ex-hip-flexor-lunge',
name: 'Hüftbeuger-Stretch (Ausfallschritt)',
description:
'Tiefer Ausfallschritt, hinteres Knie am Boden. Hüfte nach vorne schieben. Dehnung im vorderen Hüftbeuger des hinteren Beins.',
bodyRegion: 'hips',
difficulty: 'beginner',
defaultDurationSec: 30,
bilateral: true,
tags: ['hüfte', 'beine'],
isPreset: true,
isArchived: false,
order: 10,
},
{
id: 'stretch-ex-pigeon',
name: 'Tauben-Haltung (Pigeon Pose)',
description:
'Ein Bein angewinkelt vor dem Körper, das andere gestreckt nach hinten. Oberkörper über das vordere Bein senken.',
bodyRegion: 'hips',
difficulty: 'intermediate',
defaultDurationSec: 45,
bilateral: true,
tags: ['hüfte', 'yoga'],
isPreset: true,
isArchived: false,
order: 11,
},
{
id: 'stretch-ex-butterfly',
name: 'Schmetterling (Butterfly)',
description:
'Sitzen, Fußsohlen zusammen, Knie nach außen fallen lassen. Sanft die Knie Richtung Boden drücken.',
bodyRegion: 'hips',
difficulty: 'beginner',
defaultDurationSec: 30,
bilateral: false,
tags: ['hüfte', 'leiste'],
isPreset: true,
isArchived: false,
order: 12,
},
{
id: 'stretch-ex-90-90',
name: '90/90 Hüft-Stretch',
description:
'Sitzen, beide Beine im 90°-Winkel gebeugt. Vorderes Bein außenrotiert, hinteres innenrotiert. Oberkörper aufrecht.',
bodyRegion: 'hips',
difficulty: 'intermediate',
defaultDurationSec: 30,
bilateral: true,
tags: ['hüfte', 'mobilität'],
isPreset: true,
isArchived: false,
order: 13,
},
// ─── Hamstrings ───────────────────────────────────
{
id: 'stretch-ex-standing-forward-fold',
name: 'Stehende Vorbeuge',
description:
'Stehend, Beine gestreckt, Oberkörper langsam nach vorne hängen lassen. Schwerkraft arbeiten lassen.',
bodyRegion: 'hamstrings',
difficulty: 'beginner',
defaultDurationSec: 30,
bilateral: false,
tags: ['beine', 'rücken'],
isPreset: true,
isArchived: false,
order: 14,
},
{
id: 'stretch-ex-seated-forward-fold',
name: 'Sitzende Vorbeuge',
description:
'Sitzen, Beine gestreckt. Oberkörper langsam nach vorne beugen, Hände Richtung Zehen.',
bodyRegion: 'hamstrings',
difficulty: 'beginner',
defaultDurationSec: 30,
bilateral: false,
tags: ['beine'],
isPreset: true,
isArchived: false,
order: 15,
},
// ─── Quads ────────────────────────────────────────
{
id: 'stretch-ex-quad-standing',
name: 'Quadrizeps-Dehnung stehend',
description:
'Stehend, Fuß zum Gesäß ziehen, Knie zusammen. Dehnung an der Oberschenkel-Vorderseite.',
bodyRegion: 'quads',
difficulty: 'beginner',
defaultDurationSec: 30,
bilateral: true,
tags: ['beine'],
isPreset: true,
isArchived: false,
order: 16,
},
// ─── Calves ───────────────────────────────────────
{
id: 'stretch-ex-calf-wall',
name: 'Wadenstretch an der Wand',
description:
'Hände an der Wand, ein Bein nach hinten gestreckt, Ferse am Boden. Vorderes Knie beugen.',
bodyRegion: 'calves',
difficulty: 'beginner',
defaultDurationSec: 30,
bilateral: true,
tags: ['beine', 'waden'],
isPreset: true,
isArchived: false,
order: 17,
},
// ─── Wrists ───────────────────────────────────────
{
id: 'stretch-ex-wrist-circles',
name: 'Handgelenk-Kreise',
description: 'Hände zu Fäusten ballen, langsame Kreise in beide Richtungen. 10× je Richtung.',
bodyRegion: 'wrists',
difficulty: 'beginner',
defaultDurationSec: 20,
bilateral: false,
tags: ['handgelenke', 'büro'],
isPreset: true,
isArchived: false,
order: 18,
},
{
id: 'stretch-ex-wrist-flexor',
name: 'Handgelenk-Beuger Dehnung',
description:
'Arm nach vorne strecken, Handfläche nach oben, mit der anderen Hand die Finger sanft nach unten ziehen.',
bodyRegion: 'wrists',
difficulty: 'beginner',
defaultDurationSec: 20,
bilateral: true,
tags: ['handgelenke', 'büro'],
isPreset: true,
isArchived: false,
order: 19,
},
// ─── Full Body ────────────────────────────────────
{
id: 'stretch-ex-downward-dog',
name: 'Herabschauender Hund',
description:
'Hände und Füße am Boden, Hüfte nach oben drücken. Umgekehrtes V. Fersen Richtung Boden drücken.',
bodyRegion: 'full_body',
difficulty: 'beginner',
defaultDurationSec: 30,
bilateral: false,
tags: ['ganzkörper', 'yoga'],
isPreset: true,
isArchived: false,
order: 20,
},
{
id: 'stretch-ex-worlds-greatest',
name: "World's Greatest Stretch",
description:
'Ausfallschritt, Hand neben dem vorderen Fuß, andere Hand zur Decke rotieren. Kombiniert Hüft-, Brust- und Wirbelsäulenrotation.',
bodyRegion: 'full_body',
difficulty: 'intermediate',
defaultDurationSec: 30,
bilateral: true,
tags: ['ganzkörper', 'mobilität'],
isPreset: true,
isArchived: false,
order: 21,
},
] satisfies LocalStretchExercise[],
stretchRoutines: [
{
id: 'stretch-routine-morning',
name: 'Guten Morgen',
description:
'Sanftes Aufwachen — Durchblutung anregen und den Körper für den Tag vorbereiten.',
routineType: 'morning',
targetBodyRegions: ['neck', 'shoulders', 'upper_back', 'lower_back', 'hips', 'hamstrings'],
exercises: [
{ exerciseId: 'stretch-ex-cat-cow', durationSec: 30, restAfterSec: 5, notes: '' },
{ exerciseId: 'stretch-ex-child-pose', durationSec: 30, restAfterSec: 5, notes: '' },
{ exerciseId: 'stretch-ex-spinal-twist', durationSec: 30, restAfterSec: 5, notes: '' },
{ exerciseId: 'stretch-ex-downward-dog', durationSec: 30, restAfterSec: 5, notes: '' },
{ exerciseId: 'stretch-ex-hip-flexor-lunge', durationSec: 30, restAfterSec: 5, notes: '' },
{
exerciseId: 'stretch-ex-standing-forward-fold',
durationSec: 30,
restAfterSec: 0,
notes: '',
},
],
estimatedDurationMin: 5,
isPreset: true,
isCustom: false,
isPinned: true,
order: 0,
},
{
id: 'stretch-routine-desk',
name: 'Schreibtisch-Pause',
description:
'Kurze Pause vom Bildschirm — Nacken, Schultern, Handgelenke und Hüftbeuger lösen.',
routineType: 'desk_break',
targetBodyRegions: ['neck', 'shoulders', 'wrists', 'hips', 'chest'],
exercises: [
{ exerciseId: 'stretch-ex-neck-lateral', durationSec: 20, restAfterSec: 5, notes: '' },
{ exerciseId: 'stretch-ex-neck-rotation', durationSec: 20, restAfterSec: 5, notes: '' },
{ exerciseId: 'stretch-ex-shoulder-cross', durationSec: 25, restAfterSec: 5, notes: '' },
{ exerciseId: 'stretch-ex-wrist-circles', durationSec: 20, restAfterSec: 5, notes: '' },
{ exerciseId: 'stretch-ex-chest-doorway', durationSec: 25, restAfterSec: 0, notes: '' },
],
estimatedDurationMin: 3,
isPreset: true,
isCustom: false,
isPinned: true,
order: 1,
},
{
id: 'stretch-routine-evening',
name: 'Feierabend-Flow',
description:
'Entspannung und Entlastung nach einem langen Tag — Fokus auf unteren Rücken und Hüften.',
routineType: 'evening',
targetBodyRegions: ['lower_back', 'hips', 'hamstrings', 'shoulders', 'neck'],
exercises: [
{ exerciseId: 'stretch-ex-neck-lateral', durationSec: 25, restAfterSec: 5, notes: '' },
{ exerciseId: 'stretch-ex-shoulder-cross', durationSec: 25, restAfterSec: 5, notes: '' },
{ exerciseId: 'stretch-ex-cat-cow', durationSec: 30, restAfterSec: 5, notes: '' },
{ exerciseId: 'stretch-ex-child-pose', durationSec: 45, restAfterSec: 5, notes: '' },
{ exerciseId: 'stretch-ex-spinal-twist', durationSec: 30, restAfterSec: 5, notes: '' },
{ exerciseId: 'stretch-ex-butterfly', durationSec: 30, restAfterSec: 5, notes: '' },
{ exerciseId: 'stretch-ex-pigeon', durationSec: 40, restAfterSec: 5, notes: '' },
{
exerciseId: 'stretch-ex-seated-forward-fold',
durationSec: 30,
restAfterSec: 0,
notes: '',
},
],
estimatedDurationMin: 8,
isPreset: true,
isCustom: false,
isPinned: true,
order: 2,
},
{
id: 'stretch-routine-upper',
name: 'Oberkörper-Löser',
description:
'Nacken, Schultern, Brust und oberer Rücken — ideal bei Verspannungen vom Sitzen.',
routineType: 'focus_region',
targetBodyRegions: ['neck', 'shoulders', 'chest', 'upper_back', 'arms'],
exercises: [
{ exerciseId: 'stretch-ex-neck-lateral', durationSec: 25, restAfterSec: 5, notes: '' },
{ exerciseId: 'stretch-ex-neck-rotation', durationSec: 20, restAfterSec: 5, notes: '' },
{ exerciseId: 'stretch-ex-shoulder-cross', durationSec: 30, restAfterSec: 5, notes: '' },
{ exerciseId: 'stretch-ex-triceps-overhead', durationSec: 25, restAfterSec: 5, notes: '' },
{ exerciseId: 'stretch-ex-chest-doorway', durationSec: 30, restAfterSec: 5, notes: '' },
{ exerciseId: 'stretch-ex-thread-needle', durationSec: 30, restAfterSec: 0, notes: '' },
],
estimatedDurationMin: 5,
isPreset: true,
isCustom: false,
isPinned: false,
order: 3,
},
{
id: 'stretch-routine-lower',
name: 'Unterkörper-Öffner',
description:
'Hüften, Beinrückseite, Oberschenkel und Waden — für mehr Beweglichkeit in den Beinen.',
routineType: 'focus_region',
targetBodyRegions: ['hips', 'hamstrings', 'quads', 'calves'],
exercises: [
{ exerciseId: 'stretch-ex-hip-flexor-lunge', durationSec: 30, restAfterSec: 5, notes: '' },
{ exerciseId: 'stretch-ex-butterfly', durationSec: 30, restAfterSec: 5, notes: '' },
{ exerciseId: 'stretch-ex-pigeon', durationSec: 40, restAfterSec: 5, notes: '' },
{
exerciseId: 'stretch-ex-standing-forward-fold',
durationSec: 30,
restAfterSec: 5,
notes: '',
},
{ exerciseId: 'stretch-ex-quad-standing', durationSec: 30, restAfterSec: 5, notes: '' },
{ exerciseId: 'stretch-ex-calf-wall', durationSec: 30, restAfterSec: 0, notes: '' },
],
estimatedDurationMin: 6,
isPreset: true,
isCustom: false,
isPinned: false,
order: 4,
},
] satisfies LocalStretchRoutine[],
stretchSessions: [] satisfies LocalStretchSession[],
stretchAssessments: [] satisfies LocalStretchAssessment[],
stretchReminders: [] satisfies LocalStretchReminder[],
};

View file

@ -0,0 +1,544 @@
<!--
AssessmentWizard — Step-by-step mobility self-assessment.
6 tests with score selection, pain region marking, and result summary.
-->
<script lang="ts">
import {
ASSESSMENT_TESTS,
BODY_REGION_LABELS,
BODY_REGIONS,
type BodyRegion,
type AssessmentTest,
type PainRegion,
} from '../types';
import { stretchStore } from '../stores/stretch.svelte';
interface Props {
onComplete: () => void;
onCancel: () => void;
}
let { onComplete, onCancel }: Props = $props();
let currentStep = $state(0);
let scores = $state<Record<string, number>>({});
let painRegions = $state<PainRegion[]>([]);
let showResults = $state(false);
const tests = ASSESSMENT_TESTS;
const totalSteps = tests.length;
let currentTest = $derived(tests[currentStep]);
let progress = $derived((currentStep + 1) / totalSteps);
// Pain check step state
let painRegion = $state<BodyRegion>('neck');
let painIntensity = $state(5);
function selectScore(score: number) {
if (currentTest) {
scores[currentTest.id] = score;
}
if (currentStep < totalSteps - 1) {
currentStep++;
} else {
showResults = true;
}
}
function goBack() {
if (showResults) {
showResults = false;
return;
}
if (currentStep > 0) currentStep--;
}
function addPainRegion() {
if (painRegions.some((p) => p.region === painRegion)) return;
painRegions = [
...painRegions,
{ region: painRegion, intensity: painIntensity, description: '' },
];
}
function removePainRegion(region: BodyRegion) {
painRegions = painRegions.filter((p) => p.region !== region);
}
let overallScore = $derived(() => {
const values = Object.values(scores);
if (values.length === 0) return 0;
const total = values.reduce((sum, v) => sum + v, 0);
return Math.round((total / (values.length * 5)) * 100);
});
let weakAreas = $derived(() => {
return Object.entries(scores)
.filter(([_, score]) => score <= 2)
.map(([testId]) => {
const test = tests.find((t) => t.id === testId);
return test?.bodyRegion;
})
.filter(Boolean) as BodyRegion[];
});
async function saveAndFinish() {
const testResults: AssessmentTest[] = tests.map((test) => ({
testId: test.id,
bodyRegion: test.bodyRegion,
score: scores[test.id] ?? 3,
notes: '',
}));
await stretchStore.saveAssessment({
tests: testResults,
painRegions,
});
onComplete();
}
</script>
<div class="wizard-overlay">
<div class="wizard-header">
<button class="back-btn" onclick={currentStep === 0 && !showResults ? onCancel : goBack}>
{currentStep === 0 && !showResults ? '×' : '←'}
</button>
<span class="step-label">
{showResults ? 'Ergebnis' : `Schritt ${currentStep + 1} von ${totalSteps}`}
</span>
</div>
<!-- Progress Bar -->
<div class="progress-bar">
<div class="progress-fill" style:width="{(showResults ? 1 : progress) * 100}%"></div>
</div>
{#if showResults}
<!-- Results -->
<div class="results-screen">
<div class="score-circle">
<span class="score-value">{overallScore()}%</span>
<span class="score-label">Beweglichkeit</span>
</div>
<div class="results-grid">
{#each tests as test}
{@const score = scores[test.id] ?? 0}
<div class="result-row">
<span class="result-name">{test.name.de}</span>
<div class="result-dots">
{#each [1, 2, 3, 4, 5] as val}
<span
class="result-dot"
class:filled={val <= score}
class:low={score <= 2 && val <= score}
></span>
{/each}
</div>
</div>
{/each}
</div>
{#if weakAreas().length > 0}
<div class="weak-notice">
<span class="weak-title">Verbesserungsbedarf:</span>
<span class="weak-areas">
{weakAreas()
.map((r) => BODY_REGION_LABELS[r]?.de ?? r)
.join(', ')}
</span>
</div>
{/if}
<!-- Pain Regions (optional) -->
<div class="pain-section">
<span class="pain-title">Schmerzbereiche (optional)</span>
<div class="pain-add-row">
<select class="pain-select" bind:value={painRegion}>
{#each BODY_REGIONS.filter((r) => r !== 'full_body') as region}
<option value={region}>{BODY_REGION_LABELS[region]?.de ?? region}</option>
{/each}
</select>
<input class="pain-slider" type="range" min="1" max="10" bind:value={painIntensity} />
<span class="pain-val">{painIntensity}</span>
<button class="pain-add-btn" onclick={addPainRegion}>+</button>
</div>
{#each painRegions as pr}
<div class="pain-tag">
{BODY_REGION_LABELS[pr.region]?.de ?? pr.region} ({pr.intensity}/10)
<button class="pain-remove" onclick={() => removePainRegion(pr.region)}>×</button>
</div>
{/each}
</div>
<button class="save-btn" onclick={saveAndFinish}>Speichern</button>
</div>
{:else if currentTest}
<!-- Test Step -->
<div class="test-screen">
<h2 class="test-name">{currentTest.name.de}</h2>
<p class="test-instruction">{currentTest.instruction.de}</p>
<div class="options">
{#each currentTest.options as option}
<button
class="option-btn"
class:selected={scores[currentTest.id] === option.score}
onclick={() => selectScore(option.score)}
>
<span class="option-score">{option.score}</span>
<span class="option-text">{option.de}</span>
</button>
{/each}
</div>
</div>
{/if}
</div>
<style>
.wizard-overlay {
position: fixed;
inset: 0;
z-index: 100;
background: hsl(var(--color-background));
display: flex;
flex-direction: column;
overflow-y: auto;
}
.wizard-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
}
.back-btn {
width: 2rem;
height: 2rem;
border-radius: 50%;
background: hsl(var(--color-muted));
border: none;
font-size: 1.125rem;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.step-label {
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--color-muted-foreground));
}
.progress-bar {
height: 3px;
background: hsl(var(--color-border));
overflow: hidden;
}
.progress-fill {
height: 100%;
background: #10b981;
transition: width 0.3s ease;
}
/* ── Test Screen ──────────────────────────────── */
.test-screen {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 1.5rem 1rem;
gap: 1rem;
}
.test-name {
font-size: 1.25rem;
font-weight: 700;
color: hsl(var(--color-foreground));
margin: 0;
text-align: center;
}
.test-instruction {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
text-align: center;
max-width: 360px;
line-height: 1.5;
margin: 0;
}
.options {
display: flex;
flex-direction: column;
gap: 0.375rem;
width: 100%;
max-width: 400px;
margin-top: 0.5rem;
}
.option-btn {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
border-radius: 0.75rem;
background: hsl(var(--color-muted));
border: 2px solid transparent;
cursor: pointer;
text-align: left;
color: hsl(var(--color-foreground));
transition:
border-color 0.15s,
transform 0.15s;
}
.option-btn:hover {
border-color: hsl(var(--color-border));
transform: translateX(2px);
}
.option-btn.selected {
border-color: #10b981;
background: hsl(160 60% 96%);
}
:global(.dark) .option-btn.selected {
background: hsl(160 30% 12%);
}
.option-score {
width: 1.75rem;
height: 1.75rem;
border-radius: 50%;
background: hsl(var(--color-border));
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 700;
flex-shrink: 0;
}
.option-btn.selected .option-score {
background: #10b981;
color: white;
}
.option-text {
font-size: 0.8125rem;
}
/* ── Results ──────────────────────────────────── */
.results-screen {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 1.5rem 1rem;
gap: 1rem;
}
.score-circle {
width: 100px;
height: 100px;
border-radius: 50%;
background: hsl(var(--color-muted));
border: 3px solid #10b981;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.score-value {
font-size: 1.5rem;
font-weight: 800;
color: #10b981;
}
.score-label {
font-size: 0.5625rem;
color: hsl(var(--color-muted-foreground));
text-transform: uppercase;
letter-spacing: 0.05em;
}
.results-grid {
width: 100%;
max-width: 360px;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.result-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.25rem 0;
}
.result-name {
font-size: 0.8125rem;
color: hsl(var(--color-foreground));
}
.result-dots {
display: flex;
gap: 0.25rem;
}
.result-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: hsl(var(--color-border));
}
.result-dot.filled {
background: #10b981;
}
.result-dot.low {
background: #ef4444;
}
.weak-notice {
width: 100%;
max-width: 360px;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
background: hsl(0 60% 96%);
border: 1px solid hsl(0 40% 88%);
display: flex;
flex-direction: column;
gap: 0.125rem;
}
:global(.dark) .weak-notice {
background: hsl(0 30% 12%);
border-color: hsl(0 30% 20%);
}
.weak-title {
font-size: 0.6875rem;
font-weight: 600;
color: #ef4444;
}
.weak-areas {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
/* ── Pain Section ─────────────────────────────── */
.pain-section {
width: 100%;
max-width: 360px;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.pain-title {
font-size: 0.6875rem;
font-weight: 600;
color: hsl(var(--color-muted-foreground));
text-transform: uppercase;
letter-spacing: 0.05em;
}
.pain-add-row {
display: flex;
gap: 0.375rem;
align-items: center;
}
.pain-select {
flex: 1;
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
color: hsl(var(--color-foreground));
font-size: 0.75rem;
}
.pain-slider {
width: 80px;
accent-color: #ef4444;
}
.pain-val {
font-size: 0.75rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: hsl(var(--color-foreground));
width: 1.5rem;
text-align: center;
}
.pain-add-btn {
width: 1.75rem;
height: 1.75rem;
border-radius: 50%;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
font-size: 1rem;
color: hsl(var(--color-foreground));
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.pain-tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border-radius: 1rem;
background: hsl(0 60% 96%);
border: 1px solid hsl(0 40% 88%);
font-size: 0.6875rem;
color: hsl(var(--color-foreground));
}
:global(.dark) .pain-tag {
background: hsl(0 30% 12%);
border-color: hsl(0 30% 20%);
}
.pain-remove {
background: none;
border: none;
color: #ef4444;
cursor: pointer;
font-size: 0.875rem;
line-height: 1;
padding: 0;
}
.save-btn {
margin-top: 0.5rem;
padding: 0.625rem 2rem;
border-radius: 2rem;
background: #10b981;
color: white;
border: none;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
}
.save-btn:hover {
filter: brightness(1.1);
}
</style>

View file

@ -0,0 +1,375 @@
<!--
ReminderManager — Configure stretch reminders (time, days, linked routine).
-->
<script lang="ts">
import type { StretchReminder, StretchRoutine } from '../types';
import { stretchStore } from '../stores/stretch.svelte';
interface Props {
reminders: StretchReminder[];
routines: StretchRoutine[];
onClose: () => void;
}
let { reminders, routines, onClose }: Props = $props();
let showCreate = $state(false);
let newName = $state('Dehn-Erinnerung');
let newTime = $state('09:00');
let newDays = $state<number[]>([1, 2, 3, 4, 5]); // MonFri
let newRoutineId = $state<string | null>(null);
const DAY_LABELS = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
function toggleDay(day: number) {
if (newDays.includes(day)) {
newDays = newDays.filter((d) => d !== day);
} else {
newDays = [...newDays, day].sort();
}
}
async function handleCreate() {
if (!newName.trim() || newDays.length === 0) return;
await stretchStore.createReminder({
name: newName.trim(),
routineId: newRoutineId,
time: newTime,
days: newDays,
});
showCreate = false;
newName = 'Dehn-Erinnerung';
newTime = '09:00';
newDays = [1, 2, 3, 4, 5];
newRoutineId = null;
}
async function toggleActive(id: string) {
await stretchStore.toggleReminder(id);
}
async function deleteReminder(id: string) {
await stretchStore.deleteReminder(id);
}
</script>
<div class="reminder-overlay">
<div class="reminder-header">
<button class="back-btn" onclick={onClose}>←</button>
<span class="header-title">Erinnerungen</span>
</div>
<div class="reminder-body">
{#each reminders as reminder (reminder.id)}
<div class="reminder-card" class:inactive={!reminder.isActive}>
<div class="rem-top">
<span class="rem-name">{reminder.name}</span>
<label class="toggle-label">
<input
type="checkbox"
checked={reminder.isActive}
onchange={() => toggleActive(reminder.id)}
/>
<span class="toggle-track"><span class="toggle-thumb"></span></span>
</label>
</div>
<div class="rem-details">
<span class="rem-time">{reminder.time}</span>
<span class="rem-days">
{reminder.days.map((d) => DAY_LABELS[d]).join(', ')}
</span>
</div>
{#if reminder.routineId}
{@const linked = routines.find((r) => r.id === reminder.routineId)}
{#if linked}
<span class="rem-routine">{linked.name}</span>
{/if}
{/if}
<button class="rem-delete" onclick={() => deleteReminder(reminder.id)}>Löschen</button>
</div>
{/each}
{#if showCreate}
<div class="create-form">
<input class="form-input" type="text" placeholder="Name..." bind:value={newName} />
<input class="form-input time-input" type="time" bind:value={newTime} />
<div class="days-row">
{#each [0, 1, 2, 3, 4, 5, 6] as day}
<button
class="day-btn"
class:active={newDays.includes(day)}
onclick={() => toggleDay(day)}>{DAY_LABELS[day]}</button
>
{/each}
</div>
<select class="form-select" bind:value={newRoutineId}>
<option value={null}>Keine Routine verknüpft</option>
{#each routines as routine}
<option value={routine.id}>{routine.name}</option>
{/each}
</select>
<div class="form-actions">
<button class="btn-cancel" onclick={() => (showCreate = false)}>Abbrechen</button>
<button class="btn-save" onclick={handleCreate} disabled={newDays.length === 0}>
Erstellen
</button>
</div>
</div>
{:else}
<button class="add-btn" onclick={() => (showCreate = true)}> + Neue Erinnerung </button>
{/if}
{#if reminders.length === 0 && !showCreate}
<p class="empty-text">Noch keine Erinnerungen eingerichtet.</p>
{/if}
</div>
</div>
<style>
.reminder-overlay {
position: fixed;
inset: 0;
z-index: 100;
background: hsl(var(--color-background));
display: flex;
flex-direction: column;
overflow-y: auto;
}
.reminder-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid hsl(var(--color-border));
}
.back-btn {
width: 2rem;
height: 2rem;
border-radius: 50%;
background: hsl(var(--color-muted));
border: none;
font-size: 1.125rem;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.header-title {
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.reminder-body {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.reminder-card {
padding: 0.625rem 0.75rem;
border-radius: 0.75rem;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.reminder-card.inactive {
opacity: 0.5;
}
.rem-top {
display: flex;
justify-content: space-between;
align-items: center;
}
.rem-name {
font-size: 0.8125rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.toggle-label {
position: relative;
cursor: pointer;
}
.toggle-label input {
opacity: 0;
width: 0;
height: 0;
position: absolute;
}
.toggle-track {
display: block;
width: 2rem;
height: 1.125rem;
border-radius: 0.5625rem;
background: hsl(var(--color-border));
transition: background 0.2s;
position: relative;
}
.toggle-label input:checked + .toggle-track {
background: #10b981;
}
.toggle-thumb {
position: absolute;
top: 2px;
left: 2px;
width: 0.875rem;
height: 0.875rem;
border-radius: 50%;
background: white;
transition: transform 0.2s;
}
.toggle-label input:checked + .toggle-track .toggle-thumb {
transform: translateX(0.875rem);
}
.rem-details {
display: flex;
gap: 0.5rem;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.rem-time {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.rem-routine {
font-size: 0.6875rem;
color: #10b981;
}
.rem-delete {
align-self: flex-start;
font-size: 0.6875rem;
color: #ef4444;
background: none;
border: none;
cursor: pointer;
padding: 0;
margin-top: 0.125rem;
}
/* ── Create Form ──────────────────────────────── */
.create-form {
padding: 0.75rem;
border-radius: 0.75rem;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-input,
.form-select {
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
background: hsl(var(--color-background));
border: 1px solid hsl(var(--color-border));
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
}
.form-input:focus {
outline: none;
border-color: #10b981;
}
.time-input {
width: 7rem;
}
.days-row {
display: flex;
gap: 0.25rem;
}
.day-btn {
flex: 1;
padding: 0.25rem;
border-radius: 0.25rem;
font-size: 0.625rem;
font-weight: 600;
border: 1px solid hsl(var(--color-border));
background: transparent;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
}
.day-btn.active {
background: #10b981;
color: white;
border-color: #10b981;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.375rem;
}
.btn-cancel,
.btn-save {
padding: 0.375rem 0.75rem;
border-radius: 0.375rem;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
border: none;
}
.btn-cancel {
background: transparent;
color: hsl(var(--color-muted-foreground));
}
.btn-save {
background: #10b981;
color: white;
}
.btn-save:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.add-btn {
padding: 0.625rem;
border-radius: 0.75rem;
border: 2px dashed hsl(var(--color-border));
background: transparent;
color: hsl(var(--color-muted-foreground));
font-size: 0.8125rem;
cursor: pointer;
}
.add-btn:hover {
border-color: #10b981;
color: #10b981;
}
.empty-text {
text-align: center;
color: hsl(var(--color-muted-foreground));
font-size: 0.8125rem;
padding: 1.5rem 0;
margin: 0;
}
</style>

View file

@ -0,0 +1,430 @@
<!--
RoutineCreator — Build a custom stretch routine from the exercise library.
-->
<script lang="ts">
import type { StretchExercise, RoutineExercise, BodyRegion } from '../types';
import { BODY_REGION_LABELS } from '../types';
import { stretchStore } from '../stores/stretch.svelte';
interface Props {
exercises: StretchExercise[];
onComplete: () => void;
onCancel: () => void;
}
let { exercises, onComplete, onCancel }: Props = $props();
let name = $state('');
let description = $state('');
let selectedExercises = $state<RoutineExercise[]>([]);
let filterRegion = $state<BodyRegion | 'all'>('all');
let activeExercises = $derived(exercises.filter((e) => !e.isArchived));
let filteredExercises = $derived(
filterRegion === 'all'
? activeExercises
: activeExercises.filter((e) => e.bodyRegion === filterRegion)
);
let totalDurationSec = $derived(
selectedExercises.reduce((sum, e) => sum + e.durationSec + e.restAfterSec, 0)
);
let estimatedMin = $derived(Math.ceil(totalDurationSec / 60));
// Unique regions from selected exercises
let targetRegions = $derived(() => {
const regions = new Set<BodyRegion>();
for (const slot of selectedExercises) {
const ex = exercises.find((e) => e.id === slot.exerciseId);
if (ex) regions.add(ex.bodyRegion);
}
return [...regions];
});
function addExercise(ex: StretchExercise) {
if (selectedExercises.some((s) => s.exerciseId === ex.id)) return;
selectedExercises = [
...selectedExercises,
{
exerciseId: ex.id,
durationSec: ex.defaultDurationSec,
restAfterSec: 5,
notes: '',
},
];
}
function removeExercise(exerciseId: string) {
selectedExercises = selectedExercises.filter((s) => s.exerciseId !== exerciseId);
}
function moveUp(index: number) {
if (index <= 0) return;
const arr = [...selectedExercises];
[arr[index - 1], arr[index]] = [arr[index], arr[index - 1]];
selectedExercises = arr;
}
function moveDown(index: number) {
if (index >= selectedExercises.length - 1) return;
const arr = [...selectedExercises];
[arr[index], arr[index + 1]] = [arr[index + 1], arr[index]];
selectedExercises = arr;
}
async function handleSave() {
if (!name.trim() || selectedExercises.length === 0) return;
await stretchStore.createRoutine({
name: name.trim(),
description: description.trim(),
exercises: selectedExercises,
targetBodyRegions: targetRegions(),
});
onComplete();
}
</script>
<div class="creator-overlay">
<div class="creator-header">
<button class="back-btn" onclick={onCancel}>×</button>
<span class="header-title">Neue Routine</span>
<button
class="save-btn"
onclick={handleSave}
disabled={!name.trim() || selectedExercises.length === 0}
>
Speichern
</button>
</div>
<div class="creator-body">
<!-- Name & Description -->
<div class="form-section">
<!-- svelte-ignore a11y_autofocus -->
<input
class="name-input"
type="text"
placeholder="Name der Routine..."
bind:value={name}
autofocus
/>
<input
class="desc-input"
type="text"
placeholder="Beschreibung (optional)..."
bind:value={description}
/>
</div>
<!-- Selected Exercises -->
{#if selectedExercises.length > 0}
<div class="selected-section">
<span class="section-label">
{selectedExercises.length} Übungen &middot; ~{estimatedMin} Min
</span>
{#each selectedExercises as slot, i (slot.exerciseId)}
{@const ex = exercises.find((e) => e.id === slot.exerciseId)}
<div class="selected-item">
<span class="sel-num">{i + 1}</span>
<span class="sel-name">{ex?.name ?? '?'}</span>
<span class="sel-dur">{slot.durationSec}s</span>
<button class="sel-btn" onclick={() => moveUp(i)} disabled={i === 0}></button>
<button
class="sel-btn"
onclick={() => moveDown(i)}
disabled={i === selectedExercises.length - 1}>↓</button
>
<button class="sel-btn remove" onclick={() => removeExercise(slot.exerciseId)}>×</button
>
</div>
{/each}
</div>
{/if}
<!-- Exercise Picker -->
<div class="picker-section">
<span class="section-label">Übung hinzufügen</span>
<!-- Region Filter -->
<div class="filter-row">
<button
class="filter-chip"
class:active={filterRegion === 'all'}
onclick={() => (filterRegion = 'all')}>Alle</button
>
{#each ['neck', 'shoulders', 'upper_back', 'lower_back', 'hips', 'hamstrings', 'quads', 'full_body'] as region}
<button
class="filter-chip"
class:active={filterRegion === region}
onclick={() => (filterRegion = region as BodyRegion)}
>{BODY_REGION_LABELS[region as BodyRegion]?.de ?? region}</button
>
{/each}
</div>
<div class="picker-list">
{#each filteredExercises as ex (ex.id)}
{@const alreadyAdded = selectedExercises.some((s) => s.exerciseId === ex.id)}
<button
class="picker-item"
class:added={alreadyAdded}
onclick={() => addExercise(ex)}
disabled={alreadyAdded}
>
<span class="pick-name">{ex.name}</span>
<span class="pick-region">{BODY_REGION_LABELS[ex.bodyRegion]?.de ?? ex.bodyRegion}</span
>
<span class="pick-dur">{ex.defaultDurationSec}s</span>
</button>
{/each}
</div>
</div>
</div>
</div>
<style>
.creator-overlay {
position: fixed;
inset: 0;
z-index: 100;
background: hsl(var(--color-background));
display: flex;
flex-direction: column;
overflow-y: auto;
}
.creator-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid hsl(var(--color-border));
}
.back-btn {
width: 2rem;
height: 2rem;
border-radius: 50%;
background: hsl(var(--color-muted));
border: none;
font-size: 1.125rem;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.header-title {
flex: 1;
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.save-btn {
padding: 0.375rem 0.75rem;
border-radius: 0.375rem;
background: #10b981;
color: white;
border: none;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
}
.save-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.creator-body {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.form-section {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.name-input,
.desc-input {
width: 100%;
padding: 0.5rem 0.625rem;
border-radius: 0.5rem;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
color: hsl(var(--color-foreground));
font-size: 0.875rem;
}
.name-input:focus,
.desc-input:focus {
outline: none;
border-color: #10b981;
}
.name-input::placeholder,
.desc-input::placeholder {
color: hsl(var(--color-muted-foreground));
}
.desc-input {
font-size: 0.8125rem;
}
.section-label {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: hsl(var(--color-muted-foreground));
}
/* ── Selected ─────────────────────────────────── */
.selected-section {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.selected-item {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
background: hsl(var(--color-muted));
font-size: 0.8125rem;
}
.sel-num {
width: 1.25rem;
font-weight: 600;
color: hsl(var(--color-muted-foreground));
font-variant-numeric: tabular-nums;
text-align: center;
flex-shrink: 0;
}
.sel-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: hsl(var(--color-foreground));
}
.sel-dur {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
font-variant-numeric: tabular-nums;
}
.sel-btn {
width: 1.5rem;
height: 1.5rem;
border-radius: 0.25rem;
background: transparent;
border: none;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
font-size: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
}
.sel-btn:hover:not(:disabled) {
background: hsl(var(--color-border));
}
.sel-btn:disabled {
opacity: 0.3;
}
.sel-btn.remove {
color: #ef4444;
}
/* ── Picker ───────────────────────────────────── */
.picker-section {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.filter-row {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
}
.filter-chip {
padding: 0.25rem 0.5rem;
border-radius: 1rem;
font-size: 0.625rem;
font-weight: 500;
border: 1px solid hsl(var(--color-border));
background: transparent;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
transition:
background 0.15s,
color 0.15s;
}
.filter-chip.active {
background: #10b981;
color: white;
border-color: #10b981;
}
.picker-list {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.picker-item {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
transition: background 0.15s;
}
.picker-item:hover:not(:disabled) {
background: hsl(var(--color-muted));
}
.picker-item.added {
opacity: 0.4;
cursor: not-allowed;
}
.pick-name {
flex: 1;
}
.pick-region {
font-size: 0.625rem;
color: #10b981;
}
.pick-dur {
font-size: 0.625rem;
color: hsl(var(--color-muted-foreground));
font-variant-numeric: tabular-nums;
}
</style>

View file

@ -0,0 +1,349 @@
<!--
SessionHistory — Past stretch sessions with calendar heatmap and stats.
-->
<script lang="ts">
import type { StretchSession, StretchRoutine } from '../types';
import {
getSessionsPerDay,
getBodyRegionBalance,
getCurrentStreak,
relativeDays,
} from '../queries';
import { BODY_REGION_LABELS } from '../types';
import { stretchStore } from '../stores/stretch.svelte';
interface Props {
sessions: StretchSession[];
routines: StretchRoutine[];
onClose: () => void;
}
let { sessions, routines, onClose }: Props = $props();
let last30 = $derived(getSessionsPerDay(sessions, 30));
let streak = $derived(getCurrentStreak(sessions));
let totalSessions = $derived(sessions.length);
let totalMinutes = $derived(
Math.round(sessions.reduce((sum, s) => sum + s.totalDurationSec, 0) / 60)
);
let regionBalance = $derived(getBodyRegionBalance(sessions, routines));
async function deleteSession(id: string) {
await stretchStore.deleteSession(id);
}
</script>
<div class="history-overlay">
<div class="history-header">
<button class="back-btn" onclick={onClose}>←</button>
<span class="header-title">Verlauf</span>
</div>
<div class="history-body">
<!-- Stats Summary -->
<div class="stats-row">
<div class="stat">
<span class="stat-val">{totalSessions}</span>
<span class="stat-lbl">Sessions</span>
</div>
<div class="stat">
<span class="stat-val">{totalMinutes}</span>
<span class="stat-lbl">Minuten</span>
</div>
<div class="stat">
<span class="stat-val">{streak}</span>
<span class="stat-lbl">Streak</span>
</div>
</div>
<!-- 30-Day Heatmap -->
<div class="heatmap-section">
<span class="section-label">Letzte 30 Tage</span>
<div class="heatmap-grid">
{#each last30 as day}
<div
class="heat-cell"
class:l1={day.count === 1}
class:l2={day.count === 2}
class:l3={day.count >= 3}
title="{day.date}: {day.count} Sessions, {day.minutes} Min"
></div>
{/each}
</div>
</div>
<!-- Body Region Balance -->
{#if regionBalance.length > 0}
<div class="balance-section">
<span class="section-label">Körperregion-Balance</span>
{#each regionBalance.slice(0, 6) as rb}
{@const maxCount = regionBalance[0]?.count ?? 1}
<div class="balance-row">
<span class="bal-name">{BODY_REGION_LABELS[rb.region]?.de ?? rb.region}</span>
<div class="bal-bar-track">
<div class="bal-bar-fill" style:width="{(rb.count / maxCount) * 100}%"></div>
</div>
<span class="bal-count">{rb.count}</span>
</div>
{/each}
</div>
{/if}
<!-- Session List -->
<div class="session-list">
<span class="section-label">Alle Sessions</span>
{#each sessions.slice(0, 50) as session (session.id)}
<div class="session-item">
<div class="si-left">
<span class="si-name">{session.routineName}</span>
<span class="si-meta">
{Math.round(session.totalDurationSec / 60)} Min &middot;
{session.completedExercises}/{session.totalExercises} Übungen
{#if session.mood}
&middot; {['😫', '😕', '😐', '😊', '🤩'][session.mood - 1]}
{/if}
</span>
</div>
<div class="si-right">
<span class="si-date">{relativeDays(session.startedAt)}</span>
<button class="si-delete" onclick={() => deleteSession(session.id)}>×</button>
</div>
</div>
{/each}
</div>
</div>
</div>
<style>
.history-overlay {
position: fixed;
inset: 0;
z-index: 100;
background: hsl(var(--color-background));
display: flex;
flex-direction: column;
overflow-y: auto;
}
.history-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid hsl(var(--color-border));
}
.back-btn {
width: 2rem;
height: 2rem;
border-radius: 50%;
background: hsl(var(--color-muted));
border: none;
font-size: 1.125rem;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.header-title {
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.history-body {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
/* ── Stats ────────────────────────────────────── */
.stats-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.5rem;
border-radius: 0.75rem;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
}
.stat-val {
font-size: 1.25rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: hsl(var(--color-foreground));
}
.stat-lbl {
font-size: 0.5625rem;
color: hsl(var(--color-muted-foreground));
text-transform: uppercase;
letter-spacing: 0.05em;
}
.section-label {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: hsl(var(--color-muted-foreground));
}
/* ── Heatmap ──────────────────────────────────── */
.heatmap-section {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.heatmap-grid {
display: grid;
grid-template-columns: repeat(10, 1fr);
gap: 3px;
}
.heat-cell {
aspect-ratio: 1;
border-radius: 3px;
background: hsl(var(--color-border));
}
.heat-cell.l1 {
background: hsl(160 50% 70%);
}
.heat-cell.l2 {
background: hsl(160 60% 50%);
}
.heat-cell.l3 {
background: hsl(160 70% 35%);
}
/* ── Balance ──────────────────────────────────── */
.balance-section {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.balance-row {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
}
.bal-name {
width: 6rem;
flex-shrink: 0;
color: hsl(var(--color-foreground));
}
.bal-bar-track {
flex: 1;
height: 6px;
border-radius: 3px;
background: hsl(var(--color-border));
overflow: hidden;
}
.bal-bar-fill {
height: 100%;
border-radius: 3px;
background: #10b981;
transition: width 0.3s;
}
.bal-count {
width: 1.5rem;
text-align: right;
font-variant-numeric: tabular-nums;
color: hsl(var(--color-muted-foreground));
}
/* ── Session List ─────────────────────────────── */
.session-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.session-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
font-size: 0.8125rem;
}
.session-item:hover {
background: hsl(var(--color-muted));
}
.si-left {
display: flex;
flex-direction: column;
gap: 0.0625rem;
min-width: 0;
flex: 1;
}
.si-name {
font-weight: 500;
color: hsl(var(--color-foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.si-meta {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
}
.si-right {
display: flex;
align-items: center;
gap: 0.375rem;
flex-shrink: 0;
}
.si-date {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
}
.si-delete {
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
background: transparent;
border: none;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
font-size: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.15s;
}
.session-item:hover .si-delete {
opacity: 1;
}
.si-delete:hover {
color: #ef4444;
}
</style>

View file

@ -0,0 +1,668 @@
<!--
SessionPlayer — Timer-guided stretching session.
Fullscreen overlay with exercise instructions, countdown, side-switch, skip/pause.
-->
<script lang="ts">
import type { StretchRoutine, StretchExercise, RoutineExercise } from '../types';
import { BODY_REGION_LABELS } from '../types';
import { stretchStore } from '../stores/stretch.svelte';
interface Props {
routine: StretchRoutine;
exercises: StretchExercise[];
onComplete: () => void;
onCancel: () => void;
}
let { routine, exercises, onComplete, onCancel }: Props = $props();
// ─── State ──────────────────────────────────────
type SessionPhase = 'ready' | 'exercise' | 'side_switch' | 'rest' | 'finished';
let sessionId = $state<string | null>(null);
let phase = $state<SessionPhase>('ready');
let currentIndex = $state(0);
let currentSide = $state<'left' | 'right' | null>(null);
let timeRemaining = $state(0);
let totalTime = $state(0);
let isPaused = $state(false);
let skippedIds = $state<string[]>([]);
let completedCount = $state(0);
let sessionStartTime = $state(0);
let moodRating = $state<number | null>(null);
// Timer internals
let timerRef = $state<number | null>(null);
let lastTick = $state(0);
let slots = $derived(routine.exercises);
let currentSlot = $derived<RoutineExercise | null>(slots[currentIndex] ?? null);
let currentExercise = $derived(
currentSlot ? (exercises.find((e) => e.id === currentSlot.exerciseId) ?? null) : null
);
let totalSlots = $derived(slots.length);
let progress = $derived(totalSlots > 0 ? currentIndex / totalSlots : 0);
let timerProgress = $derived(totalTime > 0 ? 1 - timeRemaining / totalTime : 0);
function formatTime(sec: number): string {
const m = Math.floor(sec / 60);
const s = sec % 60;
return m > 0 ? `${m}:${String(s).padStart(2, '0')}` : `${s}`;
}
// ─── Timer Engine ───────────────────────────────
function startTimer(seconds: number) {
timeRemaining = seconds;
totalTime = seconds;
isPaused = false;
lastTick = performance.now();
tick();
}
function tick() {
if (isPaused) return;
const now = performance.now();
const elapsed = (now - lastTick) / 1000;
lastTick = now;
timeRemaining = Math.max(0, timeRemaining - elapsed);
if (timeRemaining <= 0) {
handleTimerEnd();
return;
}
timerRef = requestAnimationFrame(tick);
}
function togglePause() {
isPaused = !isPaused;
if (!isPaused) {
lastTick = performance.now();
tick();
}
}
function handleTimerEnd() {
if (timerRef) cancelAnimationFrame(timerRef);
if (phase === 'exercise') {
if (currentExercise?.bilateral && currentSide === 'left') {
// Switch to right side
currentSide = 'right';
phase = 'side_switch';
startTimer(3); // 3s side-switch countdown
return;
}
completedCount++;
if (currentSlot && currentSlot.restAfterSec > 0 && currentIndex < totalSlots - 1) {
phase = 'rest';
startTimer(currentSlot.restAfterSec);
} else {
nextExercise();
}
} else if (phase === 'side_switch') {
phase = 'exercise';
startTimer(currentSlot?.durationSec ?? 30);
} else if (phase === 'rest') {
nextExercise();
}
}
function nextExercise() {
if (currentIndex >= totalSlots - 1) {
finishSession();
return;
}
currentIndex++;
startExercise();
}
function startExercise() {
const ex = exercises.find((e) => e.id === slots[currentIndex]?.exerciseId);
phase = 'exercise';
currentSide = ex?.bilateral ? 'left' : null;
startTimer(slots[currentIndex]?.durationSec ?? 30);
}
function skipExercise() {
if (timerRef) cancelAnimationFrame(timerRef);
if (currentSlot) {
skippedIds = [...skippedIds, currentSlot.exerciseId];
}
nextExercise();
}
function previousExercise() {
if (currentIndex <= 0) return;
if (timerRef) cancelAnimationFrame(timerRef);
currentIndex--;
startExercise();
}
// ─── Session Lifecycle ──────────────────────────
async function startSession() {
sessionStartTime = Date.now();
const session = await stretchStore.startSession({
routineId: routine.id,
routineName: routine.name,
totalExercises: totalSlots,
});
sessionId = session.id;
currentIndex = 0;
completedCount = 0;
skippedIds = [];
startExercise();
}
async function finishSession() {
if (timerRef) cancelAnimationFrame(timerRef);
phase = 'finished';
const totalDurationSec = Math.round((Date.now() - sessionStartTime) / 1000);
if (sessionId) {
await stretchStore.finishSession(sessionId, {
totalDurationSec,
completedExercises: completedCount,
skippedExerciseIds: skippedIds,
mood: moodRating,
});
}
}
async function handleFinishWithMood() {
if (sessionId && moodRating) {
await stretchStore.finishSession(sessionId, {
totalDurationSec: Math.round((Date.now() - sessionStartTime) / 1000),
completedExercises: completedCount,
skippedExerciseIds: skippedIds,
mood: moodRating,
});
}
onComplete();
}
function handleCancel() {
if (timerRef) cancelAnimationFrame(timerRef);
onCancel();
}
// Request wake lock to keep screen on
let wakeLock = $state<WakeLockSentinel | null>(null);
$effect(() => {
if (phase !== 'ready' && phase !== 'finished' && 'wakeLock' in navigator) {
navigator.wakeLock
.request('screen')
.then((wl) => {
wakeLock = wl;
})
.catch(() => {});
}
return () => {
wakeLock?.release().catch(() => {});
};
});
</script>
<div class="player-overlay">
{#if phase === 'ready'}
<!-- Ready Screen -->
<div class="ready-screen">
<button class="close-btn" onclick={handleCancel}>×</button>
<div class="ready-content">
<h2 class="ready-title">{routine.name}</h2>
<p class="ready-desc">{routine.description}</p>
<p class="ready-meta">
{totalSlots} Übungen &middot; ~{routine.estimatedDurationMin} Min
</p>
<button class="start-btn" onclick={startSession}> Starten </button>
</div>
</div>
{:else if phase === 'finished'}
<!-- Finish Screen -->
<div class="finish-screen">
<div class="finish-content">
<div class="finish-check">&#10003;</div>
<h2 class="finish-title">Geschafft!</h2>
<p class="finish-stats">
{completedCount} von {totalSlots} Übungen &middot;
{Math.round((Date.now() - sessionStartTime) / 60000)} Min
</p>
{#if skippedIds.length > 0}
<p class="finish-skipped">{skippedIds.length} übersprungen</p>
{/if}
<div class="mood-section">
<p class="mood-label">Wie fühlst du dich?</p>
<div class="mood-row">
{#each [1, 2, 3, 4, 5] as val}
<button
class="mood-btn"
class:selected={moodRating === val}
onclick={() => (moodRating = val)}
>
{['😫', '😕', '😐', '😊', '🤩'][val - 1]}
</button>
{/each}
</div>
</div>
<button class="done-btn" onclick={handleFinishWithMood}>Fertig</button>
</div>
</div>
{:else}
<!-- Active Session -->
<div class="active-screen">
<div class="player-header">
<button class="close-btn" onclick={handleCancel}>×</button>
<span class="exercise-counter">{currentIndex + 1} / {totalSlots}</span>
{#if isPaused}
<span class="pause-badge">Pause</span>
{/if}
</div>
<div class="exercise-display">
{#if currentExercise}
<h2 class="exercise-name">{currentExercise.name}</h2>
{#if currentSide}
<span class="side-badge">{currentSide === 'left' ? 'Linke Seite' : 'Rechte Seite'}</span
>
{/if}
<span class="exercise-region"
>{BODY_REGION_LABELS[currentExercise.bodyRegion]?.de ?? ''}</span
>
<p class="exercise-instruction">{currentExercise.description}</p>
{/if}
{#if phase === 'side_switch'}
<div class="side-switch-notice">Seitenwechsel...</div>
{:else if phase === 'rest'}
<div class="rest-notice">Pause</div>
{/if}
</div>
<!-- Timer -->
<div class="timer-section">
<div class="timer-display">{formatTime(Math.ceil(timeRemaining))}</div>
<div class="timer-bar">
<div class="timer-fill" style:width="{timerProgress * 100}%"></div>
</div>
</div>
<!-- Controls -->
<div class="controls">
<button class="ctrl-btn" onclick={previousExercise} disabled={currentIndex <= 0}>
&#9664; Zurück
</button>
<button class="ctrl-btn pause-btn" onclick={togglePause}>
{isPaused ? '&#9654; Weiter' : '&#10074;&#10074; Pause'}
</button>
<button class="ctrl-btn" onclick={skipExercise}> Weiter &#9654; </button>
</div>
<!-- Overall Progress -->
<div class="overall-bar">
<div class="overall-fill" style:width="{progress * 100}%"></div>
</div>
</div>
{/if}
</div>
<style>
.player-overlay {
position: fixed;
inset: 0;
z-index: 100;
background: hsl(var(--color-background));
display: flex;
flex-direction: column;
}
/* ── Close ────────────────────────────────────── */
.close-btn {
position: absolute;
top: 0.75rem;
right: 0.75rem;
width: 2rem;
height: 2rem;
border-radius: 50%;
background: hsl(var(--color-muted));
border: none;
font-size: 1.125rem;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
/* ── Ready ────────────────────────────────────── */
.ready-screen {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.ready-content {
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1.5rem;
}
.ready-title {
font-size: 1.5rem;
font-weight: 700;
color: hsl(var(--color-foreground));
margin: 0;
}
.ready-desc {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
max-width: 320px;
margin: 0;
}
.ready-meta {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
margin: 0;
}
.start-btn {
margin-top: 1rem;
padding: 0.75rem 2.5rem;
border-radius: 2rem;
background: #10b981;
color: white;
border: none;
font-size: 1.125rem;
font-weight: 700;
cursor: pointer;
transition:
transform 0.15s,
filter 0.15s;
}
.start-btn:hover {
filter: brightness(1.1);
transform: scale(1.03);
}
/* ── Active ───────────────────────────────────── */
.active-screen {
flex: 1;
display: flex;
flex-direction: column;
padding: 1rem;
gap: 1rem;
}
.player-header {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding-top: 0.25rem;
}
.exercise-counter {
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--color-muted-foreground));
}
.pause-badge {
font-size: 0.625rem;
font-weight: 600;
padding: 0.125rem 0.5rem;
border-radius: 1rem;
background: hsl(var(--color-muted));
color: hsl(var(--color-muted-foreground));
}
/* ── Exercise Display ─────────────────────────── */
.exercise-display {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 0.5rem;
padding: 1rem;
}
.exercise-name {
font-size: 1.375rem;
font-weight: 700;
color: hsl(var(--color-foreground));
margin: 0;
}
.side-badge {
display: inline-block;
padding: 0.125rem 0.625rem;
border-radius: 1rem;
background: #10b981;
color: white;
font-size: 0.75rem;
font-weight: 600;
}
.exercise-region {
font-size: 0.6875rem;
color: #10b981;
font-weight: 500;
}
.exercise-instruction {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
max-width: 360px;
line-height: 1.5;
margin: 0;
}
.side-switch-notice,
.rest-notice {
font-size: 1rem;
font-weight: 600;
color: hsl(var(--color-muted-foreground));
margin-top: 0.5rem;
}
/* ── Timer ────────────────────────────────────── */
.timer-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.timer-display {
font-size: 3rem;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: hsl(var(--color-foreground));
line-height: 1;
}
.timer-bar {
width: 100%;
max-width: 300px;
height: 6px;
border-radius: 3px;
background: hsl(var(--color-border));
overflow: hidden;
}
.timer-fill {
height: 100%;
border-radius: 3px;
background: #10b981;
transition: width 0.1s linear;
}
/* ── Controls ─────────────────────────────────── */
.controls {
display: flex;
justify-content: center;
gap: 0.75rem;
padding: 0.5rem 0;
}
.ctrl-btn {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
}
.ctrl-btn:hover:not(:disabled) {
background: hsl(var(--color-border));
}
.ctrl-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.pause-btn {
background: #10b981;
color: white;
border-color: #10b981;
}
.pause-btn:hover {
filter: brightness(1.1);
background: #10b981;
}
/* ── Overall Progress ─────────────────────────── */
.overall-bar {
width: 100%;
height: 3px;
border-radius: 1.5px;
background: hsl(var(--color-border));
overflow: hidden;
}
.overall-fill {
height: 100%;
background: #10b981;
transition: width 0.3s ease;
}
/* ── Finish ───────────────────────────────────── */
.finish-screen {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.finish-content {
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1.5rem;
}
.finish-check {
font-size: 3rem;
color: #10b981;
line-height: 1;
}
.finish-title {
font-size: 1.5rem;
font-weight: 700;
color: hsl(var(--color-foreground));
margin: 0;
}
.finish-stats {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
margin: 0;
}
.finish-skipped {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
margin: 0;
}
.mood-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.375rem;
margin-top: 0.5rem;
}
.mood-label {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
margin: 0;
}
.mood-row {
display: flex;
gap: 0.5rem;
}
.mood-btn {
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
background: hsl(var(--color-muted));
border: 2px solid transparent;
font-size: 1.25rem;
cursor: pointer;
transition:
transform 0.15s,
border-color 0.15s;
display: flex;
align-items: center;
justify-content: center;
}
.mood-btn:hover {
transform: scale(1.15);
}
.mood-btn.selected {
border-color: #10b981;
transform: scale(1.15);
}
.done-btn {
margin-top: 0.75rem;
padding: 0.625rem 2rem;
border-radius: 2rem;
background: #10b981;
color: white;
border: none;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
}
.done-btn:hover {
filter: brightness(1.1);
}
</style>

View file

@ -0,0 +1,23 @@
/**
* Stretch module typed contexts.
*
* Usage:
* Layout: stretchExercisesCtx.provide(useAllStretchExercises());
* Page: const exercises = stretchExercisesCtx.consume();
* let list = $derived(exercises.value);
*/
import { createModuleContext } from '$lib/data/module-context';
import type {
StretchExercise,
StretchRoutine,
StretchSession,
StretchAssessment,
StretchReminder,
} from './types';
export const stretchExercisesCtx = createModuleContext<StretchExercise[]>('stretchExercises');
export const stretchRoutinesCtx = createModuleContext<StretchRoutine[]>('stretchRoutines');
export const stretchSessionsCtx = createModuleContext<StretchSession[]>('stretchSessions');
export const stretchAssessmentsCtx = createModuleContext<StretchAssessment[]>('stretchAssessments');
export const stretchRemindersCtx = createModuleContext<StretchReminder[]>('stretchReminders');

View file

@ -0,0 +1,79 @@
/**
* Stretch module barrel exports.
*/
// ─── Stores ──────────────────────────────────────────────
export { stretchStore } from './stores/stretch.svelte';
// ─── Queries ─────────────────────────────────────────────
export {
useAllStretchExercises,
useAllStretchRoutines,
useAllStretchSessions,
useAllStretchAssessments,
useAllStretchReminders,
toStretchExercise,
toStretchRoutine,
toStretchSession,
toStretchAssessment,
toStretchReminder,
todayDateStr,
getTodaySessions,
getTodayMinutes,
getCurrentStreak,
getSessionsPerDay,
getBodyRegionBalance,
getLatestAssessment,
getWeakAreas,
getRecommendedRoutine,
getActiveExercises,
getExercisesByRegion,
getWeekSessionCount,
relativeDays,
} from './queries';
// ─── Collections ─────────────────────────────────────────
export {
stretchExerciseTable,
stretchRoutineTable,
stretchSessionTable,
stretchAssessmentTable,
stretchReminderTable,
STRETCH_GUEST_SEED,
} from './collections';
// ─── Context ─────────────────────────────────────────────
export {
stretchExercisesCtx,
stretchRoutinesCtx,
stretchSessionsCtx,
stretchAssessmentsCtx,
stretchRemindersCtx,
} from './context';
// ─── Types ───────────────────────────────────────────────
export {
BODY_REGIONS,
BODY_REGION_LABELS,
DIFFICULTY_LABELS,
ROUTINE_TYPE_LABELS,
ASSESSMENT_TESTS,
} from './types';
export type {
BodyRegion,
Difficulty,
RoutineType,
RoutineExercise,
AssessmentTest,
PainRegion,
LocalStretchExercise,
LocalStretchRoutine,
LocalStretchSession,
LocalStretchAssessment,
LocalStretchReminder,
StretchExercise,
StretchRoutine,
StretchSession,
StretchAssessment,
StretchReminder,
} from './types';

View file

@ -0,0 +1,12 @@
import type { ModuleConfig } from '$lib/data/module-registry';
export const stretchModuleConfig: ModuleConfig = {
appId: 'stretch',
tables: [
{ name: 'stretchExercises' },
{ name: 'stretchRoutines' },
{ name: 'stretchSessions' },
{ name: 'stretchAssessments' },
{ name: 'stretchReminders' },
],
};

View file

@ -0,0 +1,312 @@
/**
* Reactive Queries & Pure Helpers for the Stretch module.
*
* Read-side only mutations live in stores/stretch.svelte.ts.
*/
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { decryptRecords } from '$lib/data/crypto';
import { db } from '$lib/data/database';
import type {
LocalStretchExercise,
LocalStretchRoutine,
LocalStretchSession,
LocalStretchAssessment,
LocalStretchReminder,
StretchExercise,
StretchRoutine,
StretchSession,
StretchAssessment,
StretchReminder,
BodyRegion,
} from './types';
// ─── Type Converters ────────────────────────────────────────
export function toStretchExercise(local: LocalStretchExercise): StretchExercise {
const now = new Date().toISOString();
return {
id: local.id,
name: local.name,
description: local.description ?? '',
bodyRegion: local.bodyRegion,
difficulty: local.difficulty,
defaultDurationSec: local.defaultDurationSec,
bilateral: local.bilateral,
tags: local.tags ?? [],
isPreset: local.isPreset,
isArchived: local.isArchived,
order: local.order,
createdAt: local.createdAt ?? now,
updatedAt: local.updatedAt ?? now,
};
}
export function toStretchRoutine(local: LocalStretchRoutine): StretchRoutine {
const now = new Date().toISOString();
return {
id: local.id,
name: local.name,
description: local.description ?? '',
routineType: local.routineType,
targetBodyRegions: local.targetBodyRegions ?? [],
exercises: local.exercises ?? [],
estimatedDurationMin: local.estimatedDurationMin,
isPreset: local.isPreset,
isCustom: local.isCustom,
isPinned: local.isPinned,
order: local.order,
createdAt: local.createdAt ?? now,
updatedAt: local.updatedAt ?? now,
};
}
export function toStretchSession(local: LocalStretchSession): StretchSession {
return {
id: local.id,
routineId: local.routineId ?? null,
routineName: local.routineName ?? '',
startedAt: local.startedAt,
endedAt: local.endedAt ?? null,
totalDurationSec: local.totalDurationSec,
completedExercises: local.completedExercises,
totalExercises: local.totalExercises,
skippedExerciseIds: local.skippedExerciseIds ?? [],
mood: local.mood ?? null,
notes: local.notes ?? '',
createdAt: local.createdAt ?? new Date().toISOString(),
};
}
export function toStretchAssessment(local: LocalStretchAssessment): StretchAssessment {
return {
id: local.id,
assessedAt: local.assessedAt,
tests: local.tests ?? [],
overallScore: local.overallScore,
painRegions: local.painRegions ?? [],
notes: local.notes ?? '',
createdAt: local.createdAt ?? new Date().toISOString(),
};
}
export function toStretchReminder(local: LocalStretchReminder): StretchReminder {
const now = new Date().toISOString();
return {
id: local.id,
name: local.name,
routineId: local.routineId ?? null,
time: local.time,
days: local.days ?? [],
isActive: local.isActive,
lastTriggeredAt: local.lastTriggeredAt ?? null,
createdAt: local.createdAt ?? now,
updatedAt: local.updatedAt ?? now,
};
}
// ─── Live Queries ───────────────────────────────────────────
export function useAllStretchExercises() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalStretchExercise>('stretchExercises').toArray();
const visible = locals.filter((e) => !e.deletedAt);
const decrypted = await decryptRecords('stretchExercises', visible);
return decrypted.map(toStretchExercise).sort((a, b) => a.order - b.order);
}, [] as StretchExercise[]);
}
export function useAllStretchRoutines() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalStretchRoutine>('stretchRoutines').toArray();
const visible = locals.filter((r) => !r.deletedAt);
const decrypted = await decryptRecords('stretchRoutines', visible);
return decrypted.map(toStretchRoutine).sort((a, b) => a.order - b.order);
}, [] as StretchRoutine[]);
}
export function useAllStretchSessions() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalStretchSession>('stretchSessions').toArray();
const visible = locals.filter((s) => !s.deletedAt);
const decrypted = await decryptRecords('stretchSessions', visible);
return decrypted.map(toStretchSession).sort((a, b) => b.startedAt.localeCompare(a.startedAt));
}, [] as StretchSession[]);
}
export function useAllStretchAssessments() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalStretchAssessment>('stretchAssessments').toArray();
const visible = locals.filter((a) => !a.deletedAt);
const decrypted = await decryptRecords('stretchAssessments', visible);
return decrypted
.map(toStretchAssessment)
.sort((a, b) => b.assessedAt.localeCompare(a.assessedAt));
}, [] as StretchAssessment[]);
}
export function useAllStretchReminders() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalStretchReminder>('stretchReminders').toArray();
const visible = locals.filter((r) => !r.deletedAt);
const decrypted = await decryptRecords('stretchReminders', visible);
return decrypted.map(toStretchReminder);
}, [] as StretchReminder[]);
}
// ─── Pure Helpers ───────────────────────────────────────────
/** Today as YYYY-MM-DD. */
export function todayDateStr(): string {
return new Date().toISOString().split('T')[0];
}
/** Sessions from today. */
export function getTodaySessions(sessions: StretchSession[]): StretchSession[] {
const today = todayDateStr();
return sessions.filter((s) => s.startedAt.startsWith(today));
}
/** Total minutes stretched today. */
export function getTodayMinutes(sessions: StretchSession[]): number {
return getTodaySessions(sessions).reduce((sum, s) => sum + s.totalDurationSec, 0) / 60;
}
/** Current streak: consecutive days with at least one session. */
export function getCurrentStreak(sessions: StretchSession[]): number {
if (sessions.length === 0) return 0;
const sessionDays = new Set(sessions.map((s) => s.startedAt.split('T')[0]));
let streak = 0;
const d = new Date();
// Check today first — if no session today, start from yesterday
const todayStr = d.toISOString().split('T')[0];
if (!sessionDays.has(todayStr)) {
d.setDate(d.getDate() - 1);
}
while (true) {
const dayStr = d.toISOString().split('T')[0];
if (!sessionDays.has(dayStr)) break;
streak++;
d.setDate(d.getDate() - 1);
}
return streak;
}
/** Sessions per day for the last N days (for heatmap / bar chart). */
export function getSessionsPerDay(
sessions: StretchSession[],
days: number
): { date: string; count: number; minutes: number }[] {
const result: { date: string; count: number; minutes: number }[] = [];
const d = new Date();
for (let i = 0; i < days; i++) {
const dateStr = d.toISOString().split('T')[0];
const daySessions = sessions.filter((s) => s.startedAt.startsWith(dateStr));
result.unshift({
date: dateStr,
count: daySessions.length,
minutes: Math.round(daySessions.reduce((sum, s) => sum + s.totalDurationSec, 0) / 60),
});
d.setDate(d.getDate() - 1);
}
return result;
}
/** Body region frequency: how often each region was stretched (from session routines). */
export function getBodyRegionBalance(
sessions: StretchSession[],
routines: StretchRoutine[]
): { region: BodyRegion; count: number }[] {
const regionMap = new Map<BodyRegion, number>();
for (const session of sessions) {
const routine = routines.find((r) => r.id === session.routineId);
if (!routine) continue;
for (const region of routine.targetBodyRegions) {
regionMap.set(region, (regionMap.get(region) ?? 0) + 1);
}
}
return [...regionMap.entries()]
.map(([region, count]) => ({ region, count }))
.sort((a, b) => b.count - a.count);
}
/** Latest assessment (for the dashboard recommendation). */
export function getLatestAssessment(assessments: StretchAssessment[]): StretchAssessment | null {
return assessments[0] ?? null;
}
/** Weak areas from the latest assessment (score <= 2). */
export function getWeakAreas(assessment: StretchAssessment | null): BodyRegion[] {
if (!assessment) return [];
return assessment.tests.filter((t) => t.score <= 2).map((t) => t.bodyRegion);
}
/** Recommend a routine based on weak areas or time of day. */
export function getRecommendedRoutine(
routines: StretchRoutine[],
weakAreas: BodyRegion[]
): StretchRoutine | null {
if (weakAreas.length > 0) {
// Find a routine that targets the most weak areas
let best: StretchRoutine | null = null;
let bestOverlap = 0;
for (const routine of routines) {
const overlap = routine.targetBodyRegions.filter((r) => weakAreas.includes(r)).length;
if (overlap > bestOverlap) {
bestOverlap = overlap;
best = routine;
}
}
if (best) return best;
}
// Fallback: time-of-day based
const hour = new Date().getHours();
if (hour < 11) return routines.find((r) => r.routineType === 'morning') ?? null;
if (hour < 17) return routines.find((r) => r.routineType === 'desk_break') ?? null;
return routines.find((r) => r.routineType === 'evening') ?? null;
}
/** Active (non-archived) exercises. */
export function getActiveExercises(exercises: StretchExercise[]): StretchExercise[] {
return exercises.filter((e) => !e.isArchived);
}
/** Exercises filtered by body region. */
export function getExercisesByRegion(
exercises: StretchExercise[],
region: BodyRegion
): StretchExercise[] {
return exercises.filter((e) => e.bodyRegion === region && !e.isArchived);
}
/** This week's session count (MonSun). */
export function getWeekSessionCount(sessions: StretchSession[]): number {
const now = new Date();
const dayOfWeek = now.getDay(); // 0=Sun
const monday = new Date(now);
monday.setDate(now.getDate() - ((dayOfWeek + 6) % 7));
monday.setHours(0, 0, 0, 0);
const mondayStr = monday.toISOString().split('T')[0];
return sessions.filter((s) => s.startedAt.split('T')[0] >= mondayStr).length;
}
/** Coarse "X days ago" formatter. */
export function relativeDays(iso: string, now = new Date()): string {
const then = new Date(iso);
const days = Math.floor((now.getTime() - then.getTime()) / (1000 * 60 * 60 * 24));
if (days <= 0) return 'heute';
if (days === 1) return 'gestern';
if (days < 7) return `vor ${days} Tagen`;
if (days < 30) return `vor ${Math.floor(days / 7)} Wochen`;
return `vor ${Math.floor(days / 30)} Monaten`;
}

View file

@ -0,0 +1,318 @@
/**
* Stretch Store mutation-only service for the stretch module.
*
* All reads happen via liveQuery hooks in queries.ts. This file only writes:
* exercise CRUD, routine CRUD, session logging, assessments, and reminders.
*/
import { encryptRecord } from '$lib/data/crypto';
import {
stretchExerciseTable,
stretchRoutineTable,
stretchSessionTable,
stretchAssessmentTable,
stretchReminderTable,
} from '../collections';
import {
toStretchExercise,
toStretchRoutine,
toStretchSession,
toStretchAssessment,
toStretchReminder,
} from '../queries';
import type {
LocalStretchExercise,
LocalStretchRoutine,
LocalStretchSession,
LocalStretchAssessment,
LocalStretchReminder,
BodyRegion,
Difficulty,
RoutineType,
RoutineExercise,
AssessmentTest,
PainRegion,
} from '../types';
export const stretchStore = {
// ─── Exercises ──────────────────────────────────────────
async createExercise(input: {
name: string;
description?: string;
bodyRegion: BodyRegion;
difficulty?: Difficulty;
defaultDurationSec?: number;
bilateral?: boolean;
tags?: string[];
}) {
const existing = await stretchExerciseTable.toArray();
const order = existing.filter((e) => !e.deletedAt).length;
const newLocal: LocalStretchExercise = {
id: crypto.randomUUID(),
name: input.name,
description: input.description ?? '',
bodyRegion: input.bodyRegion,
difficulty: input.difficulty ?? 'beginner',
defaultDurationSec: input.defaultDurationSec ?? 30,
bilateral: input.bilateral ?? false,
tags: input.tags ?? [],
isPreset: false,
isArchived: false,
order,
};
const snapshot = toStretchExercise({ ...newLocal });
await encryptRecord('stretchExercises', newLocal);
await stretchExerciseTable.add(newLocal);
return snapshot;
},
async updateExercise(
id: string,
patch: Partial<
Pick<
LocalStretchExercise,
| 'name'
| 'description'
| 'bodyRegion'
| 'difficulty'
| 'defaultDurationSec'
| 'bilateral'
| 'tags'
| 'isArchived'
>
>
) {
const wrapped = await encryptRecord('stretchExercises', { ...patch });
await stretchExerciseTable.update(id, {
...wrapped,
updatedAt: new Date().toISOString(),
});
},
async deleteExercise(id: string) {
const exercise = await stretchExerciseTable.get(id);
if (!exercise || exercise.isPreset) return;
await stretchExerciseTable.update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
},
// ─── Routines ───────────────────────────────────────────
async createRoutine(input: {
name: string;
description?: string;
routineType?: RoutineType;
targetBodyRegions?: BodyRegion[];
exercises: RoutineExercise[];
estimatedDurationMin?: number;
}) {
const existing = await stretchRoutineTable.toArray();
const order = existing.filter((r) => !r.deletedAt).length;
// Calculate estimated duration from exercises if not provided
const totalSec = input.exercises.reduce((sum, e) => sum + e.durationSec + e.restAfterSec, 0);
const estimatedMin = input.estimatedDurationMin ?? Math.ceil(totalSec / 60);
const newLocal: LocalStretchRoutine = {
id: crypto.randomUUID(),
name: input.name,
description: input.description ?? '',
routineType: input.routineType ?? 'custom',
targetBodyRegions: input.targetBodyRegions ?? [],
exercises: input.exercises,
estimatedDurationMin: estimatedMin,
isPreset: false,
isCustom: true,
isPinned: false,
order,
};
const snapshot = toStretchRoutine({ ...newLocal });
await encryptRecord('stretchRoutines', newLocal);
await stretchRoutineTable.add(newLocal);
return snapshot;
},
async updateRoutine(
id: string,
patch: Partial<
Pick<
LocalStretchRoutine,
| 'name'
| 'description'
| 'routineType'
| 'targetBodyRegions'
| 'exercises'
| 'estimatedDurationMin'
| 'isPinned'
| 'order'
>
>
) {
const wrapped = await encryptRecord('stretchRoutines', { ...patch });
await stretchRoutineTable.update(id, {
...wrapped,
updatedAt: new Date().toISOString(),
});
},
async deleteRoutine(id: string) {
const routine = await stretchRoutineTable.get(id);
if (!routine || routine.isPreset) return;
await stretchRoutineTable.update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
},
async toggleRoutinePin(id: string) {
const routine = await stretchRoutineTable.get(id);
if (!routine) return;
await stretchRoutineTable.update(id, {
isPinned: !routine.isPinned,
updatedAt: new Date().toISOString(),
});
},
// ─── Sessions ───────────────────────────────────────────
async startSession(input: {
routineId: string | null;
routineName: string;
totalExercises: number;
}) {
const newLocal: LocalStretchSession = {
id: crypto.randomUUID(),
routineId: input.routineId,
routineName: input.routineName,
startedAt: new Date().toISOString(),
endedAt: null,
totalDurationSec: 0,
completedExercises: 0,
totalExercises: input.totalExercises,
skippedExerciseIds: [],
mood: null,
notes: '',
};
const snapshot = toStretchSession({ ...newLocal });
await encryptRecord('stretchSessions', newLocal);
await stretchSessionTable.add(newLocal);
return snapshot;
},
async finishSession(
id: string,
result: {
totalDurationSec: number;
completedExercises: number;
skippedExerciseIds: string[];
mood?: number | null;
notes?: string;
}
) {
const now = new Date().toISOString();
const patch: Partial<LocalStretchSession> = {
endedAt: now,
totalDurationSec: result.totalDurationSec,
completedExercises: result.completedExercises,
skippedExerciseIds: result.skippedExerciseIds,
mood: result.mood ?? null,
notes: result.notes ?? '',
};
const wrapped = await encryptRecord('stretchSessions', { ...patch });
await stretchSessionTable.update(id, {
...wrapped,
updatedAt: now,
});
},
async deleteSession(id: string) {
await stretchSessionTable.update(id, {
deletedAt: new Date().toISOString(),
});
},
// ─── Assessments ────────────────────────────────────────
async saveAssessment(input: {
tests: AssessmentTest[];
painRegions?: PainRegion[];
notes?: string;
}) {
// Calculate overall score: average of all test scores, scaled to 0100
const totalScore = input.tests.reduce((sum, t) => sum + t.score, 0);
const maxScore = input.tests.length * 5;
const overallScore = maxScore > 0 ? Math.round((totalScore / maxScore) * 100) : 0;
const newLocal: LocalStretchAssessment = {
id: crypto.randomUUID(),
assessedAt: new Date().toISOString(),
tests: input.tests,
overallScore,
painRegions: input.painRegions ?? [],
notes: input.notes ?? '',
};
const snapshot = toStretchAssessment({ ...newLocal });
await encryptRecord('stretchAssessments', newLocal);
await stretchAssessmentTable.add(newLocal);
return snapshot;
},
async deleteAssessment(id: string) {
await stretchAssessmentTable.update(id, {
deletedAt: new Date().toISOString(),
});
},
// ─── Reminders ──────────────────────────────────────────
async createReminder(input: {
name: string;
routineId?: string | null;
time: string;
days: number[];
}) {
const newLocal: LocalStretchReminder = {
id: crypto.randomUUID(),
name: input.name,
routineId: input.routineId ?? null,
time: input.time,
days: input.days,
isActive: true,
lastTriggeredAt: null,
};
const snapshot = toStretchReminder({ ...newLocal });
await encryptRecord('stretchReminders', newLocal);
await stretchReminderTable.add(newLocal);
return snapshot;
},
async updateReminder(
id: string,
patch: Partial<Pick<LocalStretchReminder, 'name' | 'routineId' | 'time' | 'days' | 'isActive'>>
) {
const wrapped = await encryptRecord('stretchReminders', { ...patch });
await stretchReminderTable.update(id, {
...wrapped,
updatedAt: new Date().toISOString(),
});
},
async toggleReminder(id: string) {
const reminder = await stretchReminderTable.get(id);
if (!reminder) return;
await stretchReminderTable.update(id, {
isActive: !reminder.isActive,
updatedAt: new Date().toISOString(),
});
},
async deleteReminder(id: string) {
await stretchReminderTable.update(id, {
deletedAt: new Date().toISOString(),
});
},
};

View file

@ -0,0 +1,372 @@
/**
* Stretch module types guided stretching routines with mobility assessments.
*
* Tables:
* stretchExercises exercise library (Cat-Cow, Butterfly, )
* stretchRoutines saved routine templates (Morgenroutine, Schreibtisch, )
* stretchSessions completed stretching sessions
* stretchAssessments mobility self-assessments (periodic)
* stretchReminders configurable stretch reminder schedules
*/
import type { BaseRecord } from '@mana/local-store';
// ─── Enums / unions ─────────────────────────────────────────
export type BodyRegion =
| 'neck'
| 'shoulders'
| 'upper_back'
| 'lower_back'
| 'chest'
| 'arms'
| 'wrists'
| 'hips'
| 'quads'
| 'hamstrings'
| 'calves'
| 'ankles'
| 'full_body';
export type Difficulty = 'beginner' | 'intermediate' | 'advanced';
export type RoutineType =
| 'morning'
| 'desk_break'
| 'post_workout'
| 'evening'
| 'focus_region'
| 'warm_up'
| 'custom';
// ─── Embedded Types ─────────────────────────────────────────
/** Single exercise slot inside a routine. */
export interface RoutineExercise {
exerciseId: string;
durationSec: number;
restAfterSec: number;
notes: string;
}
/** Single test result inside an assessment. */
export interface AssessmentTest {
testId: string;
bodyRegion: BodyRegion;
score: number; // 15
notes: string;
}
/** Pain point marked during an assessment. */
export interface PainRegion {
region: BodyRegion;
intensity: number; // 110
description: string;
}
// ─── Local Record Types (Dexie) ─────────────────────────────
export interface LocalStretchExercise extends BaseRecord {
name: string;
description: string;
bodyRegion: BodyRegion;
difficulty: Difficulty;
defaultDurationSec: number;
/** Whether the exercise is done per-side (left/right). */
bilateral: boolean;
tags: string[];
/** Built-in seed vs. user-created. Seeds are not deleteable. */
isPreset: boolean;
isArchived: boolean;
order: number;
}
export interface LocalStretchRoutine extends BaseRecord {
name: string;
description: string;
routineType: RoutineType;
targetBodyRegions: BodyRegion[];
/** Ordered list of exercises with per-slot overrides. */
exercises: RoutineExercise[];
estimatedDurationMin: number;
isPreset: boolean;
isCustom: boolean;
isPinned: boolean;
order: number;
}
export interface LocalStretchSession extends BaseRecord {
routineId: string | null;
/** Snapshot of routine name at session time (survives routine deletion). */
routineName: string;
startedAt: string;
endedAt: string | null;
totalDurationSec: number;
completedExercises: number;
totalExercises: number;
skippedExerciseIds: string[];
/** Post-session mood rating 15. */
mood: number | null;
notes: string;
}
export interface LocalStretchAssessment extends BaseRecord {
assessedAt: string;
tests: AssessmentTest[];
/** Aggregate score 0100 for trend tracking. */
overallScore: number;
painRegions: PainRegion[];
notes: string;
}
export interface LocalStretchReminder extends BaseRecord {
name: string;
routineId: string | null;
/** HH:mm */
time: string;
/** 0=Sun, 1=Mon, … 6=Sat */
days: number[];
isActive: boolean;
lastTriggeredAt: string | null;
}
// ─── Domain Types (UI-facing) ───────────────────────────────
export interface StretchExercise {
id: string;
name: string;
description: string;
bodyRegion: BodyRegion;
difficulty: Difficulty;
defaultDurationSec: number;
bilateral: boolean;
tags: string[];
isPreset: boolean;
isArchived: boolean;
order: number;
createdAt: string;
updatedAt: string;
}
export interface StretchRoutine {
id: string;
name: string;
description: string;
routineType: RoutineType;
targetBodyRegions: BodyRegion[];
exercises: RoutineExercise[];
estimatedDurationMin: number;
isPreset: boolean;
isCustom: boolean;
isPinned: boolean;
order: number;
createdAt: string;
updatedAt: string;
}
export interface StretchSession {
id: string;
routineId: string | null;
routineName: string;
startedAt: string;
endedAt: string | null;
totalDurationSec: number;
completedExercises: number;
totalExercises: number;
skippedExerciseIds: string[];
mood: number | null;
notes: string;
createdAt: string;
}
export interface StretchAssessment {
id: string;
assessedAt: string;
tests: AssessmentTest[];
overallScore: number;
painRegions: PainRegion[];
notes: string;
createdAt: string;
}
export interface StretchReminder {
id: string;
name: string;
routineId: string | null;
time: string;
days: number[];
isActive: boolean;
lastTriggeredAt: string | null;
createdAt: string;
updatedAt: string;
}
// ─── Constants ──────────────────────────────────────────────
export const BODY_REGIONS: readonly BodyRegion[] = [
'neck',
'shoulders',
'upper_back',
'lower_back',
'chest',
'arms',
'wrists',
'hips',
'quads',
'hamstrings',
'calves',
'ankles',
'full_body',
] as const;
export const BODY_REGION_LABELS: Record<BodyRegion, { de: string; en: string }> = {
neck: { de: 'Nacken', en: 'Neck' },
shoulders: { de: 'Schultern', en: 'Shoulders' },
upper_back: { de: 'Oberer Rücken', en: 'Upper Back' },
lower_back: { de: 'Unterer Rücken', en: 'Lower Back' },
chest: { de: 'Brust', en: 'Chest' },
arms: { de: 'Arme', en: 'Arms' },
wrists: { de: 'Handgelenke', en: 'Wrists' },
hips: { de: 'Hüften', en: 'Hips' },
quads: { de: 'Oberschenkel vorne', en: 'Quads' },
hamstrings: { de: 'Oberschenkel hinten', en: 'Hamstrings' },
calves: { de: 'Waden', en: 'Calves' },
ankles: { de: 'Sprunggelenke', en: 'Ankles' },
full_body: { de: 'Ganzkörper', en: 'Full Body' },
};
export const DIFFICULTY_LABELS: Record<Difficulty, { de: string; en: string }> = {
beginner: { de: 'Anfänger', en: 'Beginner' },
intermediate: { de: 'Mittel', en: 'Intermediate' },
advanced: { de: 'Fortgeschritten', en: 'Advanced' },
};
export const ROUTINE_TYPE_LABELS: Record<RoutineType, { de: string; en: string }> = {
morning: { de: 'Morgenroutine', en: 'Morning Routine' },
desk_break: { de: 'Schreibtisch-Pause', en: 'Desk Break' },
post_workout: { de: 'Nach dem Training', en: 'Post Workout' },
evening: { de: 'Abendroutine', en: 'Evening Routine' },
focus_region: { de: 'Fokus-Bereich', en: 'Focus Region' },
warm_up: { de: 'Aufwärmen', en: 'Warm Up' },
custom: { de: 'Eigene Routine', en: 'Custom Routine' },
};
/** Assessment test definitions with instructions. */
export const ASSESSMENT_TESTS = [
{
id: 'toe-touch',
bodyRegion: 'hamstrings' as BodyRegion,
name: { de: 'Zehenberührung', en: 'Toe Touch' },
instruction: {
de: 'Stehe aufrecht, Beine gestreckt. Beuge dich langsam nach vorne. Wie weit kommst du?',
en: 'Stand upright, legs straight. Slowly bend forward. How far can you reach?',
},
options: [
{ score: 5, de: 'Hände flach auf dem Boden', en: 'Hands flat on the floor' },
{ score: 4, de: 'Fingerspitzen berühren Boden', en: 'Fingertips touch floor' },
{ score: 3, de: 'Fingerspitzen erreichen Zehen', en: 'Fingertips reach toes' },
{ score: 2, de: 'Hände erreichen Schienbein', en: 'Hands reach shins' },
{ score: 1, de: 'Hände erreichen nur Knie', en: 'Hands only reach knees' },
],
},
{
id: 'deep-squat',
bodyRegion: 'hips' as BodyRegion,
name: { de: 'Tiefe Hocke', en: 'Deep Squat' },
instruction: {
de: 'Gehe in eine tiefe Hocke. Füße schulterbreit, Fersen am Boden halten.',
en: 'Go into a deep squat. Feet shoulder-width, keep heels on the ground.',
},
options: [
{
score: 5,
de: 'Tiefe Hocke, Fersen am Boden, Rücken gerade',
en: 'Deep squat, heels down, back straight',
},
{
score: 4,
de: 'Tiefe Hocke, Fersen am Boden, Rücken leicht gerundet',
en: 'Deep squat, heels down, slight back rounding',
},
{ score: 3, de: 'Fersen heben leicht ab', en: 'Heels slightly lift' },
{ score: 2, de: 'Kann nur halb runter', en: 'Can only go halfway down' },
{ score: 1, de: 'Tiefe Hocke nicht möglich', en: 'Deep squat not possible' },
],
},
{
id: 'shoulder-reach',
bodyRegion: 'shoulders' as BodyRegion,
name: { de: 'Schulterreichweite', en: 'Shoulder Reach' },
instruction: {
de: 'Rechte Hand über die Schulter nach unten, linke Hand von unten nach oben. Berühren sich die Finger?',
en: 'Right hand over shoulder reaching down, left hand from below reaching up. Do your fingers touch?',
},
options: [
{ score: 5, de: 'Hände greifen ineinander', en: 'Hands clasp together' },
{ score: 4, de: 'Fingerspitzen berühren sich', en: 'Fingertips touch' },
{ score: 3, de: 'Wenige Zentimeter Abstand', en: 'Few centimeters apart' },
{ score: 2, de: 'Deutlicher Abstand (>10cm)', en: 'Significant gap (>10cm)' },
{ score: 1, de: 'Hände kommen nicht nah ran', en: 'Hands cannot get close' },
],
},
{
id: 'neck-rotation',
bodyRegion: 'neck' as BodyRegion,
name: { de: 'Nacken-Rotation', en: 'Neck Rotation' },
instruction: {
de: 'Drehe den Kopf langsam nach rechts, dann nach links. Kann dein Kinn die Schulter erreichen?',
en: 'Slowly turn your head to the right, then left. Can your chin reach your shoulder?',
},
options: [
{ score: 5, de: 'Kinn erreicht Schulter beidseitig', en: 'Chin reaches shoulder both sides' },
{ score: 4, de: 'Fast an der Schulter beidseitig', en: 'Almost reaches shoulder both sides' },
{ score: 3, de: 'Gut, aber eine Seite eingeschränkt', en: 'Good, but one side restricted' },
{
score: 2,
de: 'Deutlich eingeschränkt beidseitig',
en: 'Significantly restricted both sides',
},
{ score: 1, de: 'Sehr eingeschränkt oder schmerzhaft', en: 'Very restricted or painful' },
],
},
{
id: 'hip-flexor',
bodyRegion: 'hips' as BodyRegion,
name: { de: 'Hüftbeuger-Test', en: 'Hip Flexor Test' },
instruction: {
de: 'Lege dich auf den Rücken, ziehe ein Knie zur Brust. Bleibt das andere Bein flach auf dem Boden?',
en: 'Lie on your back, pull one knee to chest. Does the other leg stay flat on the ground?',
},
options: [
{ score: 5, de: 'Bein bleibt komplett flach', en: 'Leg stays completely flat' },
{ score: 4, de: 'Bein hebt minimal ab', en: 'Leg lifts minimally' },
{ score: 3, de: 'Bein hebt deutlich ab', en: 'Leg lifts noticeably' },
{
score: 2,
de: 'Bein hebt stark ab, Zug im Hüftbeuger',
en: 'Leg lifts significantly, pull in hip flexor',
},
{
score: 1,
de: 'Sehr eng, kann Knie kaum zur Brust ziehen',
en: 'Very tight, can barely pull knee to chest',
},
],
},
{
id: 'pain-check',
bodyRegion: 'full_body' as BodyRegion,
name: { de: 'Schmerzabfrage', en: 'Pain Check' },
instruction: {
de: 'Tut dir gerade irgendwo etwas weh? Markiere die Bereiche und die Intensität.',
en: 'Are you experiencing pain anywhere right now? Mark the areas and intensity.',
},
options: [
{ score: 5, de: 'Keine Schmerzen', en: 'No pain' },
{ score: 4, de: 'Leichte Verspannung', en: 'Slight tension' },
{ score: 3, de: 'Mäßige Beschwerden', en: 'Moderate discomfort' },
{ score: 2, de: 'Deutliche Schmerzen', en: 'Significant pain' },
{ score: 1, de: 'Starke Schmerzen', en: 'Severe pain' },
],
},
] as const;

View file

@ -0,0 +1,21 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { setContext } from 'svelte';
import {
useAllStretchExercises,
useAllStretchRoutines,
useAllStretchSessions,
useAllStretchAssessments,
useAllStretchReminders,
} from '$lib/modules/stretch/queries';
let { children }: { children: Snippet } = $props();
setContext('stretchExercises', useAllStretchExercises());
setContext('stretchRoutines', useAllStretchRoutines());
setContext('stretchSessions', useAllStretchSessions());
setContext('stretchAssessments', useAllStretchAssessments());
setContext('stretchReminders', useAllStretchReminders());
</script>
{@render children()}

View file

@ -0,0 +1,9 @@
<script lang="ts">
import ListView from '$lib/modules/stretch/ListView.svelte';
</script>
<svelte:head>
<title>Stretch - Mana</title>
</svelte:head>
<ListView />

View file

@ -0,0 +1,498 @@
# Modul-Planung: Dehnen / Stretch
> **Ursprünglicher Prompt:** "Wir wollen ein neues Modul bauen 'dehnen' — wo es darum geht, dass der Nutzer durch verschiedene Dehnroutinen geführt wird, um flexibel zu bleiben. Feature-Ideen: Bestandsaufnahme am Anfang (gewisse Dinge vom Nutzer überprüfen: kannst du deine Zehen erreichen, tut dir ein Körperteil weh etc.), Reminder zu gewissen Uhrzeiten um zu dehnen (kurze Mini-Sessions)."
---
## 1. Namensvorschläge
### Favoriten
| Englisch | Deutsch | `appId` | Anmerkung |
|----------|---------|---------|-----------|
| **Stretch** | **Dehnen** | `stretch` | Klar, direkt, passt zum Mana-Stil (kurze Namen) |
| **Flex** | **Flex** | `flex` | Modern, universell verständlich, aber CSS-Kollision im Kopf |
| **Limber** | **Geschmeidig** | `limber` | Englisch schön, deutsch etwas lang |
### Weitere Optionen
| Englisch | Deutsch | Anmerkung |
|----------|---------|-----------|
| Mobility | Mobilität | Breiter, deckt auch Faszienrollen etc. ab |
| Supple | Gelenkig | Etwas ungewöhnlich |
| Unwind | Entfalten | Doppeldeutung: entspannen + entfalten |
| Flow | Fluss | Schon sehr besetzt (Yoga-Flow etc.), und wir haben "flow" im Mana-Kontext |
| Loosen | Lockern | Einfach, aber wenig sexy |
**Empfehlung:** `stretch` / `Dehnen` — kürzester Name, sofort verständlich, kein Namespace-Konflikt.
---
## 2. Feature-Übersicht
### 2.1 Bestandsaufnahme (Mobility Assessment)
Der Nutzer durchläuft bei erstem Start (und danach periodisch) eine geführte Selbstbewertung seiner Beweglichkeit.
**Tests (je Körperregion):**
| Test | Was wird geprüft | Bewertung |
|------|------------------|-----------|
| Zehenberührung (stehend) | Hintere Kette, unterer Rücken | Abstand FingerBoden (cm) oder Ja/Nein |
| Tiefe Hocke (Squat) | Hüfte, Sprunggelenk | Fersen am Boden? Rücken gerade? |
| Schulterreichweite hinten | Schulter-Innenrotation | Hände hinter dem Rücken — berühren sie sich? |
| Nacken-Rotation | HWS-Beweglichkeit | Kann das Kinn die Schulter erreichen? |
| Hüftbeuger (Thomas-Test) | Hüftflexoren | Oberschenkel bleibt auf der Liege? |
| Schmerzabfrage | Akute Einschränkungen | "Tut dir gerade etwas weh?" → Körperregion markieren |
**Ergebnis:**
- Flexibilitäts-Score pro Körperregion (15 Sterne oder Ampel rot/gelb/grün)
- Automatische Routine-Empfehlung basierend auf Schwachstellen
- Historischer Verlauf: Assessment alle 24 Wochen wiederholen → Fortschritt sichtbar
**UX-Flow:**
1. Willkommens-Screen: "Lass uns herausfinden, wo du stehst"
2. Pro Test: Illustration/Animation + kurze Anleitung → Selbstbewertung (Slider oder Auswahl)
3. Schmerzabfrage mit Körper-Silhouette (tap on body region)
4. Ergebnis-Dashboard mit Empfehlung
### 2.2 Geführte Dehnroutinen (Guided Routines)
Kern des Moduls: Timer-geführte Dehnsequenzen.
**Routine-Typen:**
- **Morgenroutine** (510 Min) — sanftes Aufwachen, Durchblutung
- **Schreibtisch-Pause** (35 Min) — Nacken, Schultern, Handgelenke, Hüftbeuger
- **Post-Workout** (1015 Min) — passend zu Muskelgruppen (Integration mit Body-Modul)
- **Abendroutine** (10 Min) — Entspannung, unterer Rücken, Hüften
- **Fokus-Routinen** — einzelne Körperregion (Nacken, unterer Rücken, Hüften, Schultern, Beine)
- **Custom-Routinen** — Nutzer stellt eigene Abfolge zusammen
**Session-Player:**
- Übung → Dauer (z.B. 30s) → Seitenwechsel-Hinweis → nächste Übung
- Visuell: Illustration oder kurze Beschreibung der Position
- Audio: optionaler Countdown-Ton, Sprachansage ("Seitenwechsel")
- Pause/Skip-Buttons
- Fortschrittsbalken (aktuelle Übung / Gesamt)
### 2.3 Stretch Reminders
Konfigurierbare Erinnerungen für Mini-Sessions.
**Optionen:**
- Feste Uhrzeiten (z.B. 9:00, 13:00, 17:00)
- Intervall-basiert (alle X Stunden)
- Tagesbasiert (MoFr, jeden Tag, custom)
- Reminder enthält: vorgeschlagene Mini-Routine (23 Min), One-Tap-Start
**Integration:**
- Nutzt `mana-notify` Service für Push-Benachrichtigungen
- Reminder-Konfiguration in `timeBlocks`-Tabelle (wie Habits)
- Deeplink in der Notification → öffnet direkt den Session-Player
### 2.4 Weitere Feature-Vorschläge
#### A. Übungsbibliothek (Exercise Library)
- Katalog aller Dehnübungen mit:
- Name (DE + EN)
- Zielmuskulatur / Körperregion
- Schwierigkeitsgrad (Anfänger / Mittel / Fortgeschritten)
- Illustration oder Beschreibung
- Varianten (z.B. mit Band, an der Wand)
- Dauer-Empfehlung
- Seed-Daten: 3050 Standardübungen vorinstalliert
- Nutzer kann eigene Übungen hinzufügen
#### B. Streak & Statistiken
- Tagesstreak: wie viele Tage am Stück gedehnt
- Wochenübersicht: Minuten pro Tag (Balkendiagramm)
- Monatsübersicht: Kalender-Heatmap (wie GitHub Contributions)
- Körperregion-Balance: "Du dehnst oft Beine, aber selten Schultern"
- Assessment-Fortschritt über Zeit
#### C. Schmerz-Tagebuch (Pain Journal)
- Schnelles Logging: Wo tut es weh? Wie stark (110)?
- Korrelation mit Dehnroutinen: "Nach regelmäßigem Nacken-Dehnen: Schmerzlevel gesunken"
- Anbindung an Assessment: Schmerzregionen beeinflussen Routine-Empfehlung
#### D. Body-Modul Integration
- Nach einem Workout im Body-Modul: "Passende Dehnroutine starten?"
- Muskelgruppen-Mapping: Brust-Training → Brust-Dehnungen vorschlagen
- Gemeinsame Übungsbibliothek (Referenzen, nicht Duplikate)
#### E. Aufwärm-Modus (Warm-Up)
- Dynamische Dehnungen vor dem Sport (im Gegensatz zu statischen Dehnungen)
- Sportart-spezifisch: Laufen, Klettern, Krafttraining, Radfahren
- Kürzere Timer (1015s pro Übung statt 30s)
#### F. Atemübungen (Breathing)
- Integration von Atemtechniken in Routinen
- Box Breathing, 4-7-8, Wim Hof
- Eigenständig oder als Teil einer Dehnroutine (Anfang/Ende)
#### G. Fortgeschrittene Ziele
- "In 30 Tagen zum Spagat" — strukturierter Plan
- Wöchentliche Progression (längere Haltezeiten, tiefere Positionen)
- Milestone-Tracking mit Fotos (optional)
---
## 3. Vorschlag-Varianten (Scope)
### Variante A: Minimal Viable Module (MVP)
**Zeitrahmen:** ~1 Woche
| Feature | Details |
|---------|---------|
| Übungsbibliothek | 30 Seed-Übungen, custom Übungen erstellen |
| 5 vordefinierte Routinen | Morgen, Schreibtisch, Abend, Oberkörper, Unterkörper |
| Session-Player | Timer, Seitenwechsel, Skip, Pause |
| Session-Log | Welche Routine, wann, Dauer → Streak-Tracking |
| Einfache Statistiken | Streak-Counter, Sessions diese Woche |
**Kein:** Assessment, Reminder, Schmerz-Tagebuch, Body-Integration
### Variante B: Empfohlener Umfang
**Zeitrahmen:** ~2 Wochen
Alles aus A, plus:
| Feature | Details |
|---------|---------|
| Bestandsaufnahme | 6 Tests, Flexibilitäts-Score, Routine-Empfehlung |
| Stretch Reminders | Feste Uhrzeiten, Tage-Auswahl, Quick-Start aus Notification |
| Custom Routinen | Übungen per Drag & Drop zusammenstellen |
| Streak + Heatmap | Kalender-Ansicht, Minuten pro Tag |
| Körperregion-Balance | "Du vernachlässigst X" |
### Variante C: Vollausbau
Alles aus B, plus:
| Feature | Details |
|---------|---------|
| Schmerz-Tagebuch | Logging + Korrelation mit Routinen |
| Body-Integration | Post-Workout Routine-Vorschläge |
| Aufwärm-Modus | Dynamische Dehnungen, Sportart-spezifisch |
| Atemübungen | Box Breathing, 4-7-8 als Routine-Bestandteil |
| Fortgeschrittene Ziele | 30-Tage-Pläne mit Progression |
| Fotos | Vorher/Nachher-Vergleich für Flexibilität |
---
## 4. Datenmodell (Variante B)
### Tabellen
```typescript
// Dehnübung (Bibliothek)
interface LocalStretchExercise extends BaseRecord {
name: string; // encrypted
description: string; // encrypted
bodyRegion: BodyRegion; // plaintext (enum, index)
difficulty: Difficulty; // plaintext (enum)
defaultDurationSec: number;
bilateral: boolean; // links/rechts separat?
tags: string[];
isCustom: boolean; // Seed vs. user-created
imageUrl: string | null;
order: number;
}
// Routine (Vorlage)
interface LocalStretchRoutine extends BaseRecord {
name: string; // encrypted
description: string; // encrypted
routineType: RoutineType; // plaintext (enum, index)
targetBodyRegions: BodyRegion[];
exercises: RoutineExercise[]; // encrypted (ordered list with durations)
estimatedDurationMin: number;
isCustom: boolean;
isPinned: boolean;
order: number;
}
// Einzelne Übung in einer Routine
interface RoutineExercise {
exerciseId: string;
durationSec: number;
restAfterSec: number;
notes: string;
}
// Abgeschlossene Session (Log)
interface LocalStretchSession extends BaseRecord {
routineId: string | null; // null = freie Session
routineName: string; // encrypted (Snapshot, falls Routine gelöscht)
startedAt: string;
endedAt: string | null;
totalDurationSec: number;
completedExercises: number;
totalExercises: number;
skippedExerciseIds: string[];
mood: number | null; // 1-5 nach Session
notes: string; // encrypted
}
// Bestandsaufnahme
interface LocalStretchAssessment extends BaseRecord {
assessedAt: string;
tests: AssessmentTest[]; // encrypted
overallScore: number; // plaintext (1-100, für Trend)
painRegions: PainRegion[]; // encrypted
notes: string; // encrypted
}
interface AssessmentTest {
testId: string; // z.B. 'toe-touch', 'deep-squat'
bodyRegion: BodyRegion;
score: number; // 1-5
notes: string;
}
interface PainRegion {
region: BodyRegion;
intensity: number; // 1-10
description: string;
}
// Reminder-Konfiguration
interface LocalStretchReminder extends BaseRecord {
name: string; // encrypted
routineId: string | null; // welche Routine vorschlagen
time: string; // HH:mm
days: number[]; // 0-6 (So-Sa)
isActive: boolean;
lastTriggeredAt: string | null;
}
// Enums
type BodyRegion = 'neck' | 'shoulders' | 'upper_back' | 'lower_back'
| 'chest' | 'arms' | 'wrists' | 'hips' | 'quads' | 'hamstrings'
| 'calves' | 'ankles' | 'full_body';
type Difficulty = 'beginner' | 'intermediate' | 'advanced';
type RoutineType = 'morning' | 'desk_break' | 'post_workout' | 'evening'
| 'focus_region' | 'warm_up' | 'custom';
```
### Encryption Registry
```typescript
// In crypto/registry.ts
stretchExercises: { enabled: true, fields: ['name', 'description'] },
stretchRoutines: { enabled: true, fields: ['name', 'description', 'exercises'] },
stretchSessions: { enabled: true, fields: ['routineName', 'notes'] },
stretchAssessments: { enabled: true, fields: ['tests', 'painRegions', 'notes'] },
stretchReminders: { enabled: true, fields: ['name'] },
```
### Module Config
```typescript
export const stretchModuleConfig: ModuleConfig = {
appId: 'stretch',
tables: [
{ name: 'stretchExercises' },
{ name: 'stretchRoutines' },
{ name: 'stretchSessions' },
{ name: 'stretchAssessments' },
{ name: 'stretchReminders' },
],
};
```
---
## 5. UI-Konzept
### Navigation / Seiten
```
/stretch → Dashboard (heute, Streak, Quick-Start)
/stretch/routines → Alle Routinen (vordefiniert + custom)
/stretch/routines/[id] → Routine-Detail (Übungsliste, Start-Button)
/stretch/routines/[id]/play → Session-Player (Fullscreen-Timer)
/stretch/exercises → Übungsbibliothek
/stretch/exercises/[id] → Übungs-Detail
/stretch/assessment → Bestandsaufnahme starten/Historie
/stretch/history → Session-Historie + Statistiken
/stretch/settings → Reminder-Konfiguration
```
### Dashboard (`/stretch`)
```
┌─────────────────────────────────────────┐
│ 🔥 12 Tage Streak │
│ Diese Woche: 45 Min (5/7 Tage) │
├─────────────────────────────────────────┤
│ Schnellstart │
│ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │Morgen│ │Schreib│ │Abend │ │
│ │ 5min │ │tisch │ │10min │ │
│ │ ▶ │ │ 3min │ │ ▶ │ │
│ └──────┘ │ ▶ │ └──────┘ │
│ └──────┘ │
├─────────────────────────────────────────┤
│ Empfohlen für dich │
│ "Dein letztes Assessment zeigt: │
│ Hüften & unterer Rücken verbessern" │
│ → Hüft-Routine starten (8 Min) │
├─────────────────────────────────────────┤
│ Letzte Sessions │
│ Heute 09:15 — Morgenroutine (5 Min) ✓ │
│ Gestern 17:00 — Schreibtisch (3 Min) ✓│
│ Gestern 07:30 — Morgenroutine (5 Min) ✓│
└─────────────────────────────────────────┘
```
### Session-Player (`/stretch/routines/[id]/play`)
```
┌─────────────────────────────────────────┐
│ ✕ │
│ Katze-Kuh (Cat-Cow) │
│ │
│ ┌───────────────────┐ │
│ │ │ │
│ │ [Illustration] │ │
│ │ │ │
│ └───────────────────┘ │
│ │
│ Auf allen Vieren. Beim Einatmen │
│ Rücken durchhängen lassen, │
│ beim Ausatmen Rücken runden. │
│ │
│ 00:24 │
│ ████████░░░░ 30s │
│ │
│ ◀ Zurück ⏸ Pause Weiter ▶ │
│ │
│ Übung 3 von 8 │
│ ░░░░████░░░░░░░░░░░░ │
└─────────────────────────────────────────┘
```
### Assessment-Flow
```
┌─────────────────────────────────────────┐
│ Bestandsaufnahme Schritt 1/7 │
│ ━━━░░░░░░░░░░░░░ │
│ │
│ Zehenberührung │
│ │
│ Stehe aufrecht, Beine gestreckt. │
│ Beuge dich langsam nach vorne. │
│ Wie weit kommst du? │
│ │
│ ┌───────────────────┐ │
│ │ [Illustration] │ │
│ └───────────────────┘ │
│ │
│ ○ Hände flach auf dem Boden │
│ ○ Fingerspitzen berühren Boden │
│ ○ Fingerspitzen erreichen Zehen │
│ ○ Hände erreichen Schienbein │
│ ○ Hände erreichen nur Knie │
│ │
│ [ Weiter → ] │
└─────────────────────────────────────────┘
```
---
## 6. Seed-Daten (Beispiel-Übungen)
Vordefinierte Übungen (Auszug der 30 für MVP):
| Name | Region | Dauer | Bilateral | Schwierigkeit |
|------|--------|-------|-----------|---------------|
| Nacken seitlich neigen | neck | 30s | ja | beginner |
| Nacken-Rotation | neck | 20s | ja | beginner |
| Schulter-Dehnung quer | shoulders | 30s | ja | beginner |
| Trizeps-Dehnung oben | arms | 30s | ja | beginner |
| Brustöffner an der Wand | chest | 30s | ja | beginner |
| Katze-Kuh (Cat-Cow) | upper_back | 30s | nein | beginner |
| Kindshaltung (Child's Pose) | lower_back | 45s | nein | beginner |
| Kobra (Cobra) | lower_back | 30s | nein | beginner |
| Hüftbeuger-Stretch (Ausfallschritt) | hips | 30s | ja | beginner |
| Tauben-Haltung (Pigeon Pose) | hips | 45s | ja | intermediate |
| Schmetterling (Butterfly) | hips | 30s | nein | beginner |
| Stehende Vorbeuge | hamstrings | 30s | nein | beginner |
| Quadrizeps-Dehnung stehend | quads | 30s | ja | beginner |
| Wadenstretch an der Wand | calves | 30s | ja | beginner |
| Handgelenk-Kreise | wrists | 20s | nein | beginner |
Vordefinierte Routinen:
| Routine | Typ | Übungen | Dauer |
|---------|-----|---------|-------|
| Guten Morgen | morning | 6 Übungen | ~5 Min |
| Schreibtisch-Pause | desk_break | 5 Übungen | ~3 Min |
| Feierabend-Flow | evening | 8 Übungen | ~10 Min |
| Oberkörper-Löser | focus_region | 6 Übungen | ~7 Min |
| Unterkörper-Öffner | focus_region | 6 Übungen | ~8 Min |
---
## 7. App-Registrierung
```typescript
// In packages/shared-branding/src/mana-apps.ts
{
id: 'stretch',
name: 'Stretch',
nameDe: 'Dehnen',
description: 'Guided Stretching — Stay flexible with mobility assessments, guided routines, streak tracking, and stretch reminders throughout your day',
descriptionDe: 'Geführtes Dehnen — Bleib flexibel mit Beweglichkeits-Checks, geführten Routinen, Streak-Tracking und Dehn-Erinnerungen über den Tag',
icon: APP_ICONS.stretch, // Neues Icon nötig
color: '#10b981', // Emerald/Grün — Gesundheit, Natur
status: 'development',
requiredTier: 'guest',
category: 'health',
}
```
---
## 8. Technische Besonderheiten
### Session-Player Timer
- Braucht einen robusten Timer der auch bei Tab-Wechsel weiterläuft
- `requestAnimationFrame` + `Performance.now()` statt `setInterval`
- Optional: Wake Lock API (`navigator.wakeLock`) damit der Bildschirm nicht ausgeht
- Audio: Web Audio API für Countdown-Töne (kein `<audio>` Element nötig)
### Reminder-System
- Reminder-Config in IndexedDB (synced)
- Tatsächliche Notification über `mana-notify` Service oder Service Worker Push
- Fallback: In-App Banner wenn die App offen ist
- Integration mit Habits-Modul `timeBlocks` für Kalender-Sichtbarkeit
### Offline-First
- Alle Routinen und Übungen lokal in IndexedDB → funktioniert komplett offline
- Session-Player braucht keine Netzwerkverbindung
- Sync passiert im Hintergrund wenn wieder online
### Body-Modul Integration (Variante C)
- Shared `BodyRegion` Enum in `@mana/shared-types`
- Cross-Modul Link via `manaLinks` Tabelle: Workout → suggested Stretch Routine
- Keine direkte Modul-Abhängigkeit, nur über Links/Events
---
## 9. Priorisierte Implementierungsreihenfolge
1. **Übungsbibliothek** — Types, Collections, Seed-Daten, CRUD
2. **Routinen-Verwaltung** — Vordefinierte + Custom Routinen
3. **Session-Player** — Timer-Engine, UI, Session-Logging
4. **Dashboard** — Streak, Quick-Start, letzte Sessions
5. **Statistiken** — Kalender-Heatmap, Minuten-Tracking
6. **Assessment** — Wizard-Flow, Scoring, Empfehlungen
7. **Reminders** — Konfiguration, timeBlocks-Integration, Notifications
8. **Body-Integration** — Cross-Modul Vorschläge (optional)

View file

@ -176,6 +176,16 @@ export const APP_ICONS = {
// Orange→amber gradient for a warm kitchen feel.
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="rc" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#f97316"/><stop offset="100%" style="stop-color:#d97706"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#rc)"/><ellipse cx="50" cy="56" rx="24" ry="6" fill="white" fill-opacity="0.2"/><path d="M28 50h44v18a14 14 0 0 1-14 14H42a14 14 0 0 1-14-14V50z" fill="white" fill-opacity="0.9"/><rect x="26" y="47" width="48" height="6" rx="3" fill="white"/><path d="M22 50h-4a2 2 0 0 1 0-4h4" stroke="white" stroke-width="2.5" stroke-linecap="round" fill="none"/><path d="M78 50h4a2 2 0 0 0 0-4h-4" stroke="white" stroke-width="2.5" stroke-linecap="round" fill="none"/><path d="M40 38c0-4 3-6 3-10" stroke="white" stroke-width="2.5" stroke-linecap="round" fill="none" opacity="0.7"/><path d="M50 36c0-4 3-6 3-10" stroke="white" stroke-width="2.5" stroke-linecap="round" fill="none" opacity="0.7"/><path d="M60 38c0-4 3-6 3-10" stroke="white" stroke-width="2.5" stroke-linecap="round" fill="none" opacity="0.7"/></svg>`
),
stretch: svgToDataUrl(
// Person in a stretch pose — represents guided stretching / flexibility.
// Emerald→teal gradient for the health/wellness theme.
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="st" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#10b981"/><stop offset="100%" style="stop-color:#0d9488"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#st)"/><circle cx="50" cy="26" r="7" fill="white"/><path d="M50 35v18" stroke="white" stroke-width="4" stroke-linecap="round"/><path d="M50 53l-16 18" stroke="white" stroke-width="4" stroke-linecap="round"/><path d="M50 53l16 18" stroke="white" stroke-width="4" stroke-linecap="round"/><path d="M32 40l18 5 18-5" stroke="white" stroke-width="4" stroke-linecap="round" fill="none"/><path d="M26 34l6 6" stroke="white" stroke-width="3" stroke-linecap="round"/><path d="M74 34l-6 6" stroke="white" stroke-width="3" stroke-linecap="round"/></svg>`
),
meditate: svgToDataUrl(
// Person in lotus meditation pose with radiating calm.
// Violet→indigo gradient for the mindfulness/calm theme.
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="md" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#8b5cf6"/><stop offset="100%" style="stop-color:#6366f1"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#md)"/><circle cx="50" cy="28" r="7" fill="white"/><path d="M50 36v14" stroke="white" stroke-width="4" stroke-linecap="round"/><path d="M36 44c4 2 9 3 14 3s10-1 14-3" stroke="white" stroke-width="3.5" stroke-linecap="round" fill="none"/><path d="M32 47l-6-5" stroke="white" stroke-width="3" stroke-linecap="round"/><path d="M68 47l6-5" stroke="white" stroke-width="3" stroke-linecap="round"/><path d="M38 56c-6 4-10 8-10 12 0 6 10 10 22 10s22-4 22-10c0-4-4-8-10-12" stroke="white" stroke-width="3.5" stroke-linecap="round" fill="none"/><path d="M42 56l-4 10h24l-4-10" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="white" fill-opacity="0.2"/><circle cx="50" cy="50" r="28" stroke="white" stroke-width="1.5" fill="none" opacity="0.2"/><circle cx="50" cy="50" r="36" stroke="white" stroke-width="1" fill="none" opacity="0.1"/></svg>`
),
} as const;
export type AppIconId = keyof typeof APP_ICONS;

View file

@ -323,7 +323,7 @@ export const MANA_APPS: ManaApp[] = [
icon: APP_ICONS.mail,
color: '#6366f1',
comingSoon: false,
status: 'planning',
status: 'development',
requiredTier: 'guest',
},
{
@ -785,6 +785,23 @@ export const MANA_APPS: ManaApp[] = [
status: 'development',
requiredTier: 'guest',
},
{
id: 'stretch',
name: 'Stretch',
description: {
de: 'Geführtes Dehnen',
en: 'Guided Stretching',
},
longDescription: {
de: 'Bleib flexibel mit Beweglichkeits-Checks, geführten Dehnroutinen, Streak-Tracking und Erinnerungen über den Tag. Morgenroutine, Schreibtisch-Pause oder Abendroutine — alles mit Timer.',
en: 'Stay flexible with mobility assessments, guided stretch routines, streak tracking, and reminders throughout your day. Morning routine, desk break, or evening flow — all with a timer.',
},
icon: APP_ICONS.stretch,
color: '#10b981',
comingSoon: false,
status: 'development',
requiredTier: 'guest',
},
{
id: 'who',
name: 'Who',