From aabf13048063af0a6d0bc48aa9d2c9747660399b Mon Sep 17 00:00:00 2001 From: Till JS Date: Mon, 13 Apr 2026 20:29:06 +0200 Subject: [PATCH] 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) --- .../apps/web/src/lib/app-registry/apps.ts | 33 + .../apps/web/src/lib/data/crypto/registry.ts | 30 + apps/mana/apps/web/src/lib/data/database.ts | 15 + .../apps/web/src/lib/data/module-registry.ts | 6 + .../apps/web/src/lib/data/seed-registry.ts | 4 + .../src/lib/modules/stretch/ListView.svelte | 734 ++++++++++++++++++ .../src/lib/modules/stretch/collections.ts | 481 ++++++++++++ .../components/AssessmentWizard.svelte | 544 +++++++++++++ .../stretch/components/ReminderManager.svelte | 375 +++++++++ .../stretch/components/RoutineCreator.svelte | 430 ++++++++++ .../stretch/components/SessionHistory.svelte | 349 +++++++++ .../stretch/components/SessionPlayer.svelte | 668 ++++++++++++++++ .../web/src/lib/modules/stretch/context.ts | 23 + .../apps/web/src/lib/modules/stretch/index.ts | 79 ++ .../src/lib/modules/stretch/module.config.ts | 12 + .../web/src/lib/modules/stretch/queries.ts | 312 ++++++++ .../modules/stretch/stores/stretch.svelte.ts | 318 ++++++++ .../apps/web/src/lib/modules/stretch/types.ts | 372 +++++++++ .../src/routes/(app)/stretch/+layout.svelte | 21 + .../web/src/routes/(app)/stretch/+page.svelte | 9 + docs/modules/STRETCH_MODULE_PLAN.md | 498 ++++++++++++ packages/shared-branding/src/app-icons.ts | 10 + packages/shared-branding/src/mana-apps.ts | 19 +- 23 files changed, 5341 insertions(+), 1 deletion(-) create mode 100644 apps/mana/apps/web/src/lib/modules/stretch/ListView.svelte create mode 100644 apps/mana/apps/web/src/lib/modules/stretch/collections.ts create mode 100644 apps/mana/apps/web/src/lib/modules/stretch/components/AssessmentWizard.svelte create mode 100644 apps/mana/apps/web/src/lib/modules/stretch/components/ReminderManager.svelte create mode 100644 apps/mana/apps/web/src/lib/modules/stretch/components/RoutineCreator.svelte create mode 100644 apps/mana/apps/web/src/lib/modules/stretch/components/SessionHistory.svelte create mode 100644 apps/mana/apps/web/src/lib/modules/stretch/components/SessionPlayer.svelte create mode 100644 apps/mana/apps/web/src/lib/modules/stretch/context.ts create mode 100644 apps/mana/apps/web/src/lib/modules/stretch/index.ts create mode 100644 apps/mana/apps/web/src/lib/modules/stretch/module.config.ts create mode 100644 apps/mana/apps/web/src/lib/modules/stretch/queries.ts create mode 100644 apps/mana/apps/web/src/lib/modules/stretch/stores/stretch.svelte.ts create mode 100644 apps/mana/apps/web/src/lib/modules/stretch/types.ts create mode 100644 apps/mana/apps/web/src/routes/(app)/stretch/+layout.svelte create mode 100644 apps/mana/apps/web/src/routes/(app)/stretch/+page.svelte create mode 100644 docs/modules/STRETCH_MODULE_PLAN.md diff --git a/apps/mana/apps/web/src/lib/app-registry/apps.ts b/apps/mana/apps/web/src/lib/app-registry/apps.ts index b401bf070..9c104f22e 100644 --- a/apps/mana/apps/web/src/lib/app-registry/apps.ts +++ b/apps/mana/apps/web/src/lib/app-registry/apps.ts @@ -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' } }) + ), + }, + ], +}); diff --git a/apps/mana/apps/web/src/lib/data/crypto/registry.ts b/apps/mana/apps/web/src/lib/data/crypto/registry.ts index 78f52ce97..c24194878 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/registry.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/registry.ts @@ -418,6 +418,36 @@ export const ENCRYPTION_REGISTRY: Record = { // 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: [] }, }; /** diff --git a/apps/mana/apps/web/src/lib/data/database.ts b/apps/mana/apps/web/src/lib/data/database.ts index 9480315c4..6b647a779 100644 --- a/apps/mana/apps/web/src/lib/data/database.ts +++ b/apps/mana/apps/web/src/lib/data/database.ts @@ -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 diff --git a/apps/mana/apps/web/src/lib/data/module-registry.ts b/apps/mana/apps/web/src/lib/data/module-registry.ts index e961ee232..52aae313c 100644 --- a/apps/mana/apps/web/src/lib/data/module-registry.ts +++ b/apps/mana/apps/web/src/lib/data/module-registry.ts @@ -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 ────────────────────────────────────────── diff --git a/apps/mana/apps/web/src/lib/data/seed-registry.ts b/apps/mana/apps/web/src/lib/data/seed-registry.ts index 227bee082..408ec6f97 100644 --- a/apps/mana/apps/web/src/lib/data/seed-registry.ts +++ b/apps/mana/apps/web/src/lib/data/seed-registry.ts @@ -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 diff --git a/apps/mana/apps/web/src/lib/modules/stretch/ListView.svelte b/apps/mana/apps/web/src/lib/modules/stretch/ListView.svelte new file mode 100644 index 000000000..c90b17950 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/stretch/ListView.svelte @@ -0,0 +1,734 @@ + + + + +{#if activeRoutineId} + {@const routine = routines.find((r) => r.id === activeRoutineId)} + {#if routine} + (activeRoutineId = null)} + /> + {/if} +{:else if showAssessment} + (showAssessment = false)} + onCancel={() => (showAssessment = false)} + /> +{:else if showCreateRoutine} + (showCreateRoutine = false)} + onCancel={() => (showCreateRoutine = false)} + /> +{:else if showReminders} + (showReminders = false)} /> +{:else if showHistory} + (showHistory = false)} /> +{:else} +
+ +
+ + + +
+ + {#if activeTab === 'dashboard'} + +
+
+ {streak} + Tage Streak +
+
+ {todayMinutes} + Min heute +
+
+ {weekCount} + Diese Woche +
+
+ + +
+ {#each last7Days as day} +
+
0} class:multi={day.count > 1}>
+ {new Date(day.date + 'T00:00').toLocaleDateString('de', { weekday: 'narrow' })} +
+ {/each} +
+ + +
+
+ Schnellstart +
+
+ {#each pinnedRoutines as routine (routine.id)} + + {/each} +
+
+ + + {#if recommended} +
+
+ {#if weakAreas.length > 0} + Empfohlen für dich + + Schwachstellen: {weakAreas.map((r) => BODY_REGION_LABELS[r]?.de ?? r).join(', ')} + + {:else} + Vorschlag + {/if} +
+ +
+ {/if} + + + + + + {#if todaySessions.length > 0} +
+
+ Heute + +
+ {#each todaySessions.slice(0, 3) as session (session.id)} +
+ {session.routineName} + {Math.round(session.totalDurationSec / 60)} Min + {session.startedAt.split('T')[1]?.slice(0, 5)} +
+ {/each} +
+ {/if} + + +
+ + +
+ {:else if activeTab === 'routines'} + +
+ {#each routines as routine (routine.id)} + + {/each} + +
+ {:else if activeTab === 'exercises'} + +
+ {#each exercises.filter((e) => !e.isArchived) as ex (ex.id)} +
+
+ {ex.name} + {ex.description} +
+
+ {BODY_REGION_LABELS[ex.bodyRegion]?.de ?? ex.bodyRegion} + {ex.defaultDurationSec}s{ex.bilateral ? ' /Seite' : ''} +
+
+ {/each} +
+ {/if} +
+{/if} + + diff --git a/apps/mana/apps/web/src/lib/modules/stretch/collections.ts b/apps/mana/apps/web/src/lib/modules/stretch/collections.ts new file mode 100644 index 000000000..aa325a90e --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/stretch/collections.ts @@ -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('stretchExercises'); +export const stretchRoutineTable = db.table('stretchRoutines'); +export const stretchSessionTable = db.table('stretchSessions'); +export const stretchAssessmentTable = db.table('stretchAssessments'); +export const stretchReminderTable = db.table('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[], +}; diff --git a/apps/mana/apps/web/src/lib/modules/stretch/components/AssessmentWizard.svelte b/apps/mana/apps/web/src/lib/modules/stretch/components/AssessmentWizard.svelte new file mode 100644 index 000000000..0f1569d6b --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/stretch/components/AssessmentWizard.svelte @@ -0,0 +1,544 @@ + + + +
+
+ + + {showResults ? 'Ergebnis' : `Schritt ${currentStep + 1} von ${totalSteps}`} + +
+ + +
+
+
+ + {#if showResults} + +
+
+ {overallScore()}% + Beweglichkeit +
+ +
+ {#each tests as test} + {@const score = scores[test.id] ?? 0} +
+ {test.name.de} +
+ {#each [1, 2, 3, 4, 5] as val} + + {/each} +
+
+ {/each} +
+ + {#if weakAreas().length > 0} +
+ Verbesserungsbedarf: + + {weakAreas() + .map((r) => BODY_REGION_LABELS[r]?.de ?? r) + .join(', ')} + +
+ {/if} + + +
+ Schmerzbereiche (optional) +
+ + + {painIntensity} + +
+ {#each painRegions as pr} +
+ {BODY_REGION_LABELS[pr.region]?.de ?? pr.region} ({pr.intensity}/10) + +
+ {/each} +
+ + +
+ {:else if currentTest} + +
+

{currentTest.name.de}

+

{currentTest.instruction.de}

+ +
+ {#each currentTest.options as option} + + {/each} +
+
+ {/if} +
+ + diff --git a/apps/mana/apps/web/src/lib/modules/stretch/components/ReminderManager.svelte b/apps/mana/apps/web/src/lib/modules/stretch/components/ReminderManager.svelte new file mode 100644 index 000000000..e820cbd4c --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/stretch/components/ReminderManager.svelte @@ -0,0 +1,375 @@ + + + +
+
+ + Erinnerungen +
+ +
+ {#each reminders as reminder (reminder.id)} +
+
+ {reminder.name} + +
+
+ {reminder.time} + + {reminder.days.map((d) => DAY_LABELS[d]).join(', ')} + +
+ {#if reminder.routineId} + {@const linked = routines.find((r) => r.id === reminder.routineId)} + {#if linked} + {linked.name} + {/if} + {/if} + +
+ {/each} + + {#if showCreate} +
+ + +
+ {#each [0, 1, 2, 3, 4, 5, 6] as day} + + {/each} +
+ +
+ + +
+
+ {:else} + + {/if} + + {#if reminders.length === 0 && !showCreate} +

Noch keine Erinnerungen eingerichtet.

+ {/if} +
+
+ + diff --git a/apps/mana/apps/web/src/lib/modules/stretch/components/RoutineCreator.svelte b/apps/mana/apps/web/src/lib/modules/stretch/components/RoutineCreator.svelte new file mode 100644 index 000000000..d623030d3 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/stretch/components/RoutineCreator.svelte @@ -0,0 +1,430 @@ + + + +
+
+ + Neue Routine + +
+ +
+ +
+ + + +
+ + + {#if selectedExercises.length > 0} +
+ + {#each selectedExercises as slot, i (slot.exerciseId)} + {@const ex = exercises.find((e) => e.id === slot.exerciseId)} +
+ {i + 1} + {ex?.name ?? '?'} + {slot.durationSec}s + + + +
+ {/each} +
+ {/if} + + +
+ + +
+ + {#each ['neck', 'shoulders', 'upper_back', 'lower_back', 'hips', 'hamstrings', 'quads', 'full_body'] as region} + + {/each} +
+ +
+ {#each filteredExercises as ex (ex.id)} + {@const alreadyAdded = selectedExercises.some((s) => s.exerciseId === ex.id)} + + {/each} +
+
+
+
+ + diff --git a/apps/mana/apps/web/src/lib/modules/stretch/components/SessionHistory.svelte b/apps/mana/apps/web/src/lib/modules/stretch/components/SessionHistory.svelte new file mode 100644 index 000000000..a37790d52 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/stretch/components/SessionHistory.svelte @@ -0,0 +1,349 @@ + + + +
+
+ + Verlauf +
+ +
+ +
+
+ {totalSessions} + Sessions +
+
+ {totalMinutes} + Minuten +
+
+ {streak} + Streak +
+
+ + +
+ +
+ {#each last30 as day} +
= 3} + title="{day.date}: {day.count} Sessions, {day.minutes} Min" + >
+ {/each} +
+
+ + + {#if regionBalance.length > 0} +
+ + {#each regionBalance.slice(0, 6) as rb} + {@const maxCount = regionBalance[0]?.count ?? 1} +
+ {BODY_REGION_LABELS[rb.region]?.de ?? rb.region} +
+
+
+ {rb.count} +
+ {/each} +
+ {/if} + + +
+ + {#each sessions.slice(0, 50) as session (session.id)} +
+
+ {session.routineName} + + {Math.round(session.totalDurationSec / 60)} Min · + {session.completedExercises}/{session.totalExercises} Übungen + {#if session.mood} + · {['😫', '😕', '😐', '😊', '🤩'][session.mood - 1]} + {/if} + +
+
+ {relativeDays(session.startedAt)} + +
+
+ {/each} +
+
+
+ + diff --git a/apps/mana/apps/web/src/lib/modules/stretch/components/SessionPlayer.svelte b/apps/mana/apps/web/src/lib/modules/stretch/components/SessionPlayer.svelte new file mode 100644 index 000000000..2782e3724 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/stretch/components/SessionPlayer.svelte @@ -0,0 +1,668 @@ + + + +
+ {#if phase === 'ready'} + +
+ +
+

{routine.name}

+

{routine.description}

+

+ {totalSlots} Übungen · ~{routine.estimatedDurationMin} Min +

+ +
+
+ {:else if phase === 'finished'} + +
+
+
+

Geschafft!

+

+ {completedCount} von {totalSlots} Übungen · + {Math.round((Date.now() - sessionStartTime) / 60000)} Min +

+ {#if skippedIds.length > 0} +

{skippedIds.length} übersprungen

+ {/if} +
+

Wie fühlst du dich?

+
+ {#each [1, 2, 3, 4, 5] as val} + + {/each} +
+
+ +
+
+ {:else} + +
+
+ + {currentIndex + 1} / {totalSlots} + {#if isPaused} + Pause + {/if} +
+ +
+ {#if currentExercise} +

{currentExercise.name}

+ {#if currentSide} + {currentSide === 'left' ? 'Linke Seite' : 'Rechte Seite'} + {/if} + {BODY_REGION_LABELS[currentExercise.bodyRegion]?.de ?? ''} +

{currentExercise.description}

+ {/if} + + {#if phase === 'side_switch'} +
Seitenwechsel...
+ {:else if phase === 'rest'} +
Pause
+ {/if} +
+ + +
+
{formatTime(Math.ceil(timeRemaining))}
+
+
+
+
+ + +
+ + + +
+ + +
+
+
+
+ {/if} +
+ + diff --git a/apps/mana/apps/web/src/lib/modules/stretch/context.ts b/apps/mana/apps/web/src/lib/modules/stretch/context.ts new file mode 100644 index 000000000..487b86383 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/stretch/context.ts @@ -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('stretchExercises'); +export const stretchRoutinesCtx = createModuleContext('stretchRoutines'); +export const stretchSessionsCtx = createModuleContext('stretchSessions'); +export const stretchAssessmentsCtx = createModuleContext('stretchAssessments'); +export const stretchRemindersCtx = createModuleContext('stretchReminders'); diff --git a/apps/mana/apps/web/src/lib/modules/stretch/index.ts b/apps/mana/apps/web/src/lib/modules/stretch/index.ts new file mode 100644 index 000000000..28b3c75ee --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/stretch/index.ts @@ -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'; diff --git a/apps/mana/apps/web/src/lib/modules/stretch/module.config.ts b/apps/mana/apps/web/src/lib/modules/stretch/module.config.ts new file mode 100644 index 000000000..4e2a04428 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/stretch/module.config.ts @@ -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' }, + ], +}; diff --git a/apps/mana/apps/web/src/lib/modules/stretch/queries.ts b/apps/mana/apps/web/src/lib/modules/stretch/queries.ts new file mode 100644 index 000000000..18ccd497a --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/stretch/queries.ts @@ -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('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('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('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('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('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(); + + 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`; +} diff --git a/apps/mana/apps/web/src/lib/modules/stretch/stores/stretch.svelte.ts b/apps/mana/apps/web/src/lib/modules/stretch/stores/stretch.svelte.ts new file mode 100644 index 000000000..7ae18727b --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/stretch/stores/stretch.svelte.ts @@ -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 = { + 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> + ) { + 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(), + }); + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/stretch/types.ts b/apps/mana/apps/web/src/lib/modules/stretch/types.ts new file mode 100644 index 000000000..f6edc77b9 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/stretch/types.ts @@ -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 = { + 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 = { + beginner: { de: 'Anfänger', en: 'Beginner' }, + intermediate: { de: 'Mittel', en: 'Intermediate' }, + advanced: { de: 'Fortgeschritten', en: 'Advanced' }, +}; + +export const ROUTINE_TYPE_LABELS: Record = { + 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; diff --git a/apps/mana/apps/web/src/routes/(app)/stretch/+layout.svelte b/apps/mana/apps/web/src/routes/(app)/stretch/+layout.svelte new file mode 100644 index 000000000..0832a667f --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/stretch/+layout.svelte @@ -0,0 +1,21 @@ + + +{@render children()} diff --git a/apps/mana/apps/web/src/routes/(app)/stretch/+page.svelte b/apps/mana/apps/web/src/routes/(app)/stretch/+page.svelte new file mode 100644 index 000000000..2146d1f79 --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/stretch/+page.svelte @@ -0,0 +1,9 @@ + + + + Stretch - Mana + + + diff --git a/docs/modules/STRETCH_MODULE_PLAN.md b/docs/modules/STRETCH_MODULE_PLAN.md new file mode 100644 index 000000000..031443255 --- /dev/null +++ b/docs/modules/STRETCH_MODULE_PLAN.md @@ -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 `