mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
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:
parent
e927c1f10f
commit
aabf130480
23 changed files with 5341 additions and 1 deletions
|
|
@ -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' } })
|
||||
),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: [] },
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
734
apps/mana/apps/web/src/lib/modules/stretch/ListView.svelte
Normal file
734
apps/mana/apps/web/src/lib/modules/stretch/ListView.svelte
Normal 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>
|
||||
481
apps/mana/apps/web/src/lib/modules/stretch/collections.ts
Normal file
481
apps/mana/apps/web/src/lib/modules/stretch/collections.ts
Normal 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[],
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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]); // Mon–Fri
|
||||
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>
|
||||
|
|
@ -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 · ~{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>
|
||||
|
|
@ -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 ·
|
||||
{session.completedExercises}/{session.totalExercises} Übungen
|
||||
{#if session.mood}
|
||||
· {['😫', '😕', '😐', '😊', '🤩'][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>
|
||||
|
|
@ -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 · ~{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">✓</div>
|
||||
<h2 class="finish-title">Geschafft!</h2>
|
||||
<p class="finish-stats">
|
||||
{completedCount} von {totalSlots} Übungen ·
|
||||
{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}>
|
||||
◀ Zurück
|
||||
</button>
|
||||
<button class="ctrl-btn pause-btn" onclick={togglePause}>
|
||||
{isPaused ? '▶ Weiter' : '❚❚ Pause'}
|
||||
</button>
|
||||
<button class="ctrl-btn" onclick={skipExercise}> Weiter ▶ </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>
|
||||
23
apps/mana/apps/web/src/lib/modules/stretch/context.ts
Normal file
23
apps/mana/apps/web/src/lib/modules/stretch/context.ts
Normal 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');
|
||||
79
apps/mana/apps/web/src/lib/modules/stretch/index.ts
Normal file
79
apps/mana/apps/web/src/lib/modules/stretch/index.ts
Normal 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';
|
||||
12
apps/mana/apps/web/src/lib/modules/stretch/module.config.ts
Normal file
12
apps/mana/apps/web/src/lib/modules/stretch/module.config.ts
Normal 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' },
|
||||
],
|
||||
};
|
||||
312
apps/mana/apps/web/src/lib/modules/stretch/queries.ts
Normal file
312
apps/mana/apps/web/src/lib/modules/stretch/queries.ts
Normal 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 (Mon–Sun). */
|
||||
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`;
|
||||
}
|
||||
|
|
@ -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 0–100
|
||||
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(),
|
||||
});
|
||||
},
|
||||
};
|
||||
372
apps/mana/apps/web/src/lib/modules/stretch/types.ts
Normal file
372
apps/mana/apps/web/src/lib/modules/stretch/types.ts
Normal 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; // 1–5
|
||||
notes: string;
|
||||
}
|
||||
|
||||
/** Pain point marked during an assessment. */
|
||||
export interface PainRegion {
|
||||
region: BodyRegion;
|
||||
intensity: number; // 1–10
|
||||
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 1–5. */
|
||||
mood: number | null;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface LocalStretchAssessment extends BaseRecord {
|
||||
assessedAt: string;
|
||||
tests: AssessmentTest[];
|
||||
/** Aggregate score 0–100 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;
|
||||
21
apps/mana/apps/web/src/routes/(app)/stretch/+layout.svelte
Normal file
21
apps/mana/apps/web/src/routes/(app)/stretch/+layout.svelte
Normal 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()}
|
||||
9
apps/mana/apps/web/src/routes/(app)/stretch/+page.svelte
Normal file
9
apps/mana/apps/web/src/routes/(app)/stretch/+page.svelte
Normal 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 />
|
||||
498
docs/modules/STRETCH_MODULE_PLAN.md
Normal file
498
docs/modules/STRETCH_MODULE_PLAN.md
Normal 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 Finger–Boden (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 (1–5 Sterne oder Ampel rot/gelb/grün)
|
||||
- Automatische Routine-Empfehlung basierend auf Schwachstellen
|
||||
- Historischer Verlauf: Assessment alle 2–4 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** (5–10 Min) — sanftes Aufwachen, Durchblutung
|
||||
- **Schreibtisch-Pause** (3–5 Min) — Nacken, Schultern, Handgelenke, Hüftbeuger
|
||||
- **Post-Workout** (10–15 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 (Mo–Fr, jeden Tag, custom)
|
||||
- Reminder enthält: vorgeschlagene Mini-Routine (2–3 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: 30–50 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 (1–10)?
|
||||
- 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 (10–15s 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)
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue