From 1e992d3c92155eec2d14e97331119bceab82fba8 Mon Sep 17 00:00:00 2001 From: Till JS Date: Mon, 13 Apr 2026 21:19:52 +0200 Subject: [PATCH] feat(sleep): add sleep module with tracking, hygiene checklists, and stats New "Sleep/Schlaf" module for daily sleep tracking with morning quick-log, quality ratings, sleep hygiene evening checklists, and comprehensive stats. Includes: 10 preset hygiene checks, upsert-by-date entries, week bar chart with goal line, sleep debt calculation, consistency score (stddev-based), streak tracking, 30-day quality heatmap, and hygiene-quality correlation. Dashboard shows last night summary, week overview, stats grid, and hygiene impact. Morning log has smart defaults, star rating, interruption counter, tag chips. Hygiene checklist supports custom user-created checks. Registered in module-registry, encryption registry (4 tables), database v13, seed-registry, app-icons (moon icon, indigo), mana-apps, and workbench. Also updates MODULE_IDEAS.md with stretch (built), posture, skin, eyes entries. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../apps/web/src/lib/app-registry/apps.ts | 21 + .../apps/web/src/lib/data/crypto/registry.ts | 11 + apps/mana/apps/web/src/lib/data/database.ts | 21 +- .../apps/web/src/lib/data/module-registry.ts | 2 + .../apps/web/src/lib/data/seed-registry.ts | 2 + .../web/src/lib/modules/sleep/ListView.svelte | 574 ++++++++++++++++++ .../web/src/lib/modules/sleep/collections.ts | 127 ++++ .../sleep/components/HygieneChecklist.svelte | 294 +++++++++ .../sleep/components/MorningLog.svelte | 412 +++++++++++++ .../apps/web/src/lib/modules/sleep/context.ts | 11 + .../apps/web/src/lib/modules/sleep/index.ts | 64 ++ .../src/lib/modules/sleep/module.config.ts | 11 + .../apps/web/src/lib/modules/sleep/queries.ts | 352 +++++++++++ .../lib/modules/sleep/stores/sleep.svelte.ts | 245 ++++++++ .../apps/web/src/lib/modules/sleep/types.ts | 183 ++++++ .../web/src/routes/(app)/sleep/+layout.svelte | 19 + .../web/src/routes/(app)/sleep/+page.svelte | 9 + docs/future/MODULE_IDEAS.md | 6 +- docs/modules/SLEEP_MODULE_PLAN.md | 353 +++++++++++ packages/shared-branding/src/app-icons.ts | 5 + packages/shared-branding/src/mana-apps.ts | 17 + 21 files changed, 2737 insertions(+), 2 deletions(-) create mode 100644 apps/mana/apps/web/src/lib/modules/sleep/ListView.svelte create mode 100644 apps/mana/apps/web/src/lib/modules/sleep/collections.ts create mode 100644 apps/mana/apps/web/src/lib/modules/sleep/components/HygieneChecklist.svelte create mode 100644 apps/mana/apps/web/src/lib/modules/sleep/components/MorningLog.svelte create mode 100644 apps/mana/apps/web/src/lib/modules/sleep/context.ts create mode 100644 apps/mana/apps/web/src/lib/modules/sleep/index.ts create mode 100644 apps/mana/apps/web/src/lib/modules/sleep/module.config.ts create mode 100644 apps/mana/apps/web/src/lib/modules/sleep/queries.ts create mode 100644 apps/mana/apps/web/src/lib/modules/sleep/stores/sleep.svelte.ts create mode 100644 apps/mana/apps/web/src/lib/modules/sleep/types.ts create mode 100644 apps/mana/apps/web/src/routes/(app)/sleep/+layout.svelte create mode 100644 apps/mana/apps/web/src/routes/(app)/sleep/+page.svelte create mode 100644 docs/modules/SLEEP_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 9c104f22e..5b057dad6 100644 --- a/apps/mana/apps/web/src/lib/app-registry/apps.ts +++ b/apps/mana/apps/web/src/lib/app-registry/apps.ts @@ -51,6 +51,7 @@ import { CookingPot, PersonSimpleTaiChi, Envelope, + Flower, } from '@mana/shared-icons'; // ── Apps with entity capabilities ─────────────────────────── @@ -899,3 +900,23 @@ registerApp({ }, ], }); + +registerApp({ + id: 'meditate', + name: 'Meditate', + color: '#8b5cf6', + icon: Flower, + views: { + list: { load: () => import('$lib/modules/meditate/ListView.svelte') }, + }, +}); + +registerApp({ + id: 'sleep', + name: 'Sleep', + color: '#6366f1', + icon: Moon, + views: { + list: { load: () => import('$lib/modules/sleep/ListView.svelte') }, + }, +}); 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 c24194878..8a1578b49 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/registry.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/registry.ts @@ -448,6 +448,17 @@ export const ENCRYPTION_REGISTRY: Record = { meditatePresets: { enabled: true, fields: ['name', 'description', 'bodyScanSteps'] }, meditateSessions: { enabled: true, fields: ['notes'] }, meditateSettings: { enabled: false, fields: [] }, + + // ─── Sleep ─────────────────────────────────────────────── + // Health data — GDPR Art. 9 sensitive. Only user-typed text fields + // (notes) are encrypted on sleep entries. Quality/duration/interruptions + // stay plaintext for stats aggregation. Hygiene check names/descriptions + // are encrypted (user-created ones contain personal context). Hygiene + // logs and settings are structural only. + sleepEntries: { enabled: true, fields: ['notes'] }, + sleepHygieneLogs: { enabled: false, fields: [] }, + sleepHygieneChecks: { enabled: true, fields: ['name', 'description'] }, + sleepSettings: { 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 c0599fc03..9ecf7e44f 100644 --- a/apps/mana/apps/web/src/lib/data/database.ts +++ b/apps/mana/apps/web/src/lib/data/database.ts @@ -426,10 +426,12 @@ db.version(9).stores({ db.version(10).stores({ _events: '++seq, type, meta.appId, meta.timestamp, meta.recordId, [meta.appId+meta.timestamp], [type+meta.timestamp]', - // Companion Brain: Goals, Memory, Feedback + // Companion Brain: Goals, Memory, Feedback, Chat companionGoals: 'id, moduleId, status, [moduleId+status]', _memory: 'id, category, confidence, lastConfirmed, [category+confidence]', _nudgeOutcomes: '++id, nudgeId, nudgeType, outcome, timestamp, [nudgeType+outcome]', + companionConversations: 'id, createdAt', + companionMessages: 'id, conversationId, role, createdAt, [conversationId+createdAt]', }); // Schema version 11 — adds the Mail module (local draft cache). @@ -447,6 +449,23 @@ db.version(12).stores({ meditateSettings: 'id', }); +// Schema version 13 — adds the Sleep module (sleep tracking with hygiene +// checklists). Additive only; no prior tables touched. +// +// Index strategy: +// - sleepEntries indexes date for the daily lookup + quality for the +// heatmap view (range scan on date descending). +// - sleepHygieneLogs indexes date for the daily upsert. +// - sleepHygieneChecks indexes order for the checklist sort, isActive +// for filtering active checks. +// - sleepSettings is a singleton (id-only index). +db.version(13).stores({ + sleepEntries: 'id, date, quality, [date+quality]', + sleepHygieneLogs: 'id, date', + sleepHygieneChecks: 'id, category, isActive, isPreset, order', + sleepSettings: '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 52aae313c..80aac2308 100644 --- a/apps/mana/apps/web/src/lib/data/module-registry.ts +++ b/apps/mana/apps/web/src/lib/data/module-registry.ts @@ -93,6 +93,7 @@ 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'; +import { sleepModuleConfig } from '$lib/modules/sleep/module.config'; export const MODULE_CONFIGS: readonly ModuleConfig[] = [ manaCoreConfig, @@ -141,6 +142,7 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [ stretchModuleConfig, mailModuleConfig, meditateModuleConfig, + sleepModuleConfig, ]; // ─── 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 408ec6f97..9566a08aa 100644 --- a/apps/mana/apps/web/src/lib/data/seed-registry.ts +++ b/apps/mana/apps/web/src/lib/data/seed-registry.ts @@ -32,6 +32,7 @@ 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'; +import { SLEEP_GUEST_SEED } from '$lib/modules/sleep/collections'; /** * Flat list of { tableName, rows } entries. Only modules with non-empty @@ -68,6 +69,7 @@ register(DRINK_GUEST_SEED); register(RECIPES_GUEST_SEED); register(STRETCH_GUEST_SEED); register(MEDITATE_GUEST_SEED); +register(SLEEP_GUEST_SEED); /** * Seed all module guest data into empty tables. Idempotent: tables diff --git a/apps/mana/apps/web/src/lib/modules/sleep/ListView.svelte b/apps/mana/apps/web/src/lib/modules/sleep/ListView.svelte new file mode 100644 index 000000000..972de7d8c --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/sleep/ListView.svelte @@ -0,0 +1,574 @@ + + + +{#if showMorningLog} + (showMorningLog = false)} + onCancel={() => (showMorningLog = false)} + /> +{:else if showHygiene} + (showHygiene = false)} + onCancel={() => (showHygiene = false)} + /> +{:else} +
+ + {#if !logged} + + {/if} + + + {#if lastNight} +
+
+ Letzte Nacht + {#if logged} + + {/if} +
+
+ {formatTime(lastNight.bedtime)} +
+
+
+ {formatTime(lastNight.wakeTime)} +
+
+ {formatDuration(lastNight.durationMin)} + + {#each [1, 2, 3, 4, 5] as val} + = val}>★ + {/each} + + {#if lastNight.interruptions > 0} + {lastNight.interruptions}× aufgewacht + {/if} +
+
+ {formatDuration(lastNight.durationMin)} / {formatDuration(settings.goalMin)} + {Math.round(goalProgress(lastNight.durationMin) * 100)}% +
+
+ {/if} + + +
+ +
+ {#each weekData as day} +
+
+ {#if day.durationMin > 0} +
+ {/if} + +
+
+ {day.dayLabel} + {#if day.durationMin > 0} + {Math.floor(day.durationMin / 60)}h + {/if} +
+ {/each} +
+
+ + +
+
+ {formatDuration(avgDuration7)} + Ø Dauer (7T) +
+
+ {avgQuality7} + Ø Qualität +
+
+ 0} + >{sleepDebt > 0 ? '-' : '+'}{formatDuration(Math.abs(sleepDebt))} + Schlafschuld +
+
+ {consistency}% + Konsistenz +
+
+ {streak} + Streak +
+
+ + +
+ +
+ {#each heatmap as day} +
0 ? qualityColor(day.quality) : ''} + title="{day.date}: {QUALITY_LABELS[day.quality]?.de ?? '—'}" + >
+ {/each} +
+
+ + + {#if hygieneCorr} +
+ +
+ Mit Hygiene (≥70%): + {hygieneCorr.withHygiene} ★ +
+
+ Ohne: + {hygieneCorr.withoutHygiene} ★ +
+
+ {/if} + + +
+ + +
+
+{/if} + + diff --git a/apps/mana/apps/web/src/lib/modules/sleep/collections.ts b/apps/mana/apps/web/src/lib/modules/sleep/collections.ts new file mode 100644 index 000000000..b34aa495b --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/sleep/collections.ts @@ -0,0 +1,127 @@ +/** + * Sleep module — collection accessors and guest seed data. + */ + +import { db } from '$lib/data/database'; +import type { + LocalSleepEntry, + LocalSleepHygieneLog, + LocalSleepHygieneCheck, + LocalSleepSettings, +} from './types'; + +// ─── Collection Accessors ─────────────────────────────────── + +export const sleepEntryTable = db.table('sleepEntries'); +export const sleepHygieneLogTable = db.table('sleepHygieneLogs'); +export const sleepHygieneCheckTable = db.table('sleepHygieneChecks'); +export const sleepSettingsTable = db.table('sleepSettings'); + +// ─── Guest Seed ───────────────────────────────────────────── + +export const SLEEP_GUEST_SEED = { + sleepHygieneChecks: [ + { + id: 'hygiene-no-caffeine', + name: 'Kein Koffein nach 14:00', + description: + 'Koffein hat eine Halbwertszeit von ~6 Stunden und kann den Schlaf auch spät abends noch stören.', + category: 'nutrition', + isActive: true, + isPreset: true, + order: 0, + }, + { + id: 'hygiene-no-alcohol', + name: 'Kein Alkohol 3h vor Schlaf', + description: + 'Alkohol verkürzt die REM-Phase und verschlechtert die Schlafqualität trotz schnellerem Einschlafen.', + category: 'nutrition', + isActive: true, + isPreset: true, + order: 1, + }, + { + id: 'hygiene-no-heavy-meal', + name: 'Keine schwere Mahlzeit 2h vor Schlaf', + description: + 'Schwere Mahlzeiten belasten die Verdauung und können zu unruhigem Schlaf führen.', + category: 'nutrition', + isActive: true, + isPreset: true, + order: 2, + }, + { + id: 'hygiene-screens-off', + name: 'Bildschirme aus 1h vor Schlaf', + description: + 'Blaues Licht unterdrückt die Melatonin-Produktion und verzögert das Einschlafen.', + category: 'digital', + isActive: true, + isPreset: true, + order: 3, + }, + { + id: 'hygiene-no-phone-bed', + name: 'Kein Handy im Bett', + description: + 'Das Bett sollte nur mit Schlafen assoziiert werden — Doom-Scrolling ist der Feind.', + category: 'digital', + isActive: true, + isPreset: true, + order: 4, + }, + { + id: 'hygiene-cool-room', + name: 'Schlafzimmer kühl (16–18°C)', + description: + 'Die ideale Schlaftemperatur liegt bei 16–18°C. Zu warm stört das Durchschlafen.', + category: 'environment', + isActive: true, + isPreset: true, + order: 5, + }, + { + id: 'hygiene-dark-room', + name: 'Schlafzimmer dunkel', + description: + 'Dunkelheit fördert die Melatonin-Produktion. Verdunkelungsvorhänge oder Schlafmaske nutzen.', + category: 'environment', + isActive: true, + isPreset: true, + order: 6, + }, + { + id: 'hygiene-quiet', + name: 'Ruhige Umgebung', + description: 'Lärm stört den Tiefschlaf. Ohrstöpsel oder White Noise nutzen wenn nötig.', + category: 'environment', + isActive: true, + isPreset: true, + order: 7, + }, + { + id: 'hygiene-wind-down', + name: 'Entspannungsroutine gemacht', + description: + 'Dehnen, Lesen, Meditation oder Atemübungen — ein Signal an den Körper dass es Zeit ist.', + category: 'routine', + isActive: true, + isPreset: true, + order: 8, + }, + { + id: 'hygiene-consistent-time', + name: 'Gleiche Schlafenszeit ±30min', + description: 'Regelmäßige Schlafenszeiten stabilisieren den zirkadianen Rhythmus.', + category: 'consistency', + isActive: true, + isPreset: true, + order: 9, + }, + ] satisfies LocalSleepHygieneCheck[], + + sleepEntries: [] satisfies LocalSleepEntry[], + sleepHygieneLogs: [] satisfies LocalSleepHygieneLog[], + sleepSettings: [] satisfies LocalSleepSettings[], +}; diff --git a/apps/mana/apps/web/src/lib/modules/sleep/components/HygieneChecklist.svelte b/apps/mana/apps/web/src/lib/modules/sleep/components/HygieneChecklist.svelte new file mode 100644 index 000000000..c3173a9ca --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/sleep/components/HygieneChecklist.svelte @@ -0,0 +1,294 @@ + + + +
+
+ + Schlafhygiene-Check + = 70}>{score}% +
+ +
+ {#each activeChecks as check (check.id)} + + {/each} + + {#if showAddCheck} +
+ + { + if (e.key === 'Enter') handleAddCheck(); + if (e.key === 'Escape') { + showAddCheck = false; + } + }} + class="add-input" + type="text" + placeholder="Neuer Check..." + bind:value={newCheckName} + autofocus + /> + +
+ {:else} + + {/if} + + +
+
+ + diff --git a/apps/mana/apps/web/src/lib/modules/sleep/components/MorningLog.svelte b/apps/mana/apps/web/src/lib/modules/sleep/components/MorningLog.svelte new file mode 100644 index 000000000..efd07afde --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/sleep/components/MorningLog.svelte @@ -0,0 +1,412 @@ + + + +
+
+ + Wie hast du geschlafen? +
+ +
+ +
+
+ + + gestern +
+
+ {#if durationMin > 0} + {formatDuration(durationMin)} + {:else} + — + {/if} +
+
+ + + heute +
+
+ + +
+ +
+ {#each [1, 2, 3, 4, 5] as val} + + {/each} +
+ {#if quality > 0} + {QUALITY_LABELS[quality]?.de ?? ''} + {/if} +
+ + +
+ +
+ + {interruptions} + +
+
+ + +
+ +
+ {#each SLEEP_TAG_PRESETS as tag} + + {/each} +
+
+ + +
+ +
+ + + +
+
+ + diff --git a/apps/mana/apps/web/src/lib/modules/sleep/context.ts b/apps/mana/apps/web/src/lib/modules/sleep/context.ts new file mode 100644 index 000000000..757b87066 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/sleep/context.ts @@ -0,0 +1,11 @@ +/** + * Sleep module typed contexts. + */ + +import { createModuleContext } from '$lib/data/module-context'; +import type { SleepEntry, SleepHygieneLog, SleepHygieneCheck, SleepSettings } from './types'; + +export const sleepEntriesCtx = createModuleContext('sleepEntries'); +export const sleepHygieneLogsCtx = createModuleContext('sleepHygieneLogs'); +export const sleepHygieneChecksCtx = createModuleContext('sleepHygieneChecks'); +export const sleepSettingsCtx = createModuleContext('sleepSettings'); diff --git a/apps/mana/apps/web/src/lib/modules/sleep/index.ts b/apps/mana/apps/web/src/lib/modules/sleep/index.ts new file mode 100644 index 000000000..d36ff9c68 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/sleep/index.ts @@ -0,0 +1,64 @@ +/** + * Sleep module — barrel exports. + */ + +// ─── Stores ────────────────────────────────────────────── +export { sleepStore } from './stores/sleep.svelte'; + +// ─── Queries ───────────────────────────────────────────── +export { + useAllSleepEntries, + useAllSleepHygieneLogs, + useAllSleepHygieneChecks, + useSleepSettings, + toSleepEntry, + toSleepHygieneLog, + toSleepHygieneCheck, + toSleepSettings, + todayDateStr, + yesterdayDateStr, + calcDurationMin, + formatDuration, + formatTime, + getLastNight, + getEntryForDate, + hasLoggedToday, + getAvgDuration, + getAvgQuality, + getWeekSleepDebt, + getConsistencyScore, + getCurrentStreak, + getWeekData, + getQualityHeatmap, + getHygieneCorrelation, + getEffectiveSettings, +} from './queries'; + +// ─── Collections ───────────────────────────────────────── +export { + sleepEntryTable, + sleepHygieneLogTable, + sleepHygieneCheckTable, + sleepSettingsTable, + SLEEP_GUEST_SEED, +} from './collections'; + +// ─── Types ─────────────────────────────────────────────── +export { + HYGIENE_CATEGORIES, + HYGIENE_CATEGORY_LABELS, + QUALITY_LABELS, + SLEEP_TAG_PRESETS, + DEFAULT_SLEEP_SETTINGS, +} from './types'; +export type { + HygieneCategory, + LocalSleepEntry, + LocalSleepHygieneLog, + LocalSleepHygieneCheck, + LocalSleepSettings, + SleepEntry, + SleepHygieneLog, + SleepHygieneCheck, + SleepSettings, +} from './types'; diff --git a/apps/mana/apps/web/src/lib/modules/sleep/module.config.ts b/apps/mana/apps/web/src/lib/modules/sleep/module.config.ts new file mode 100644 index 000000000..43fec2593 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/sleep/module.config.ts @@ -0,0 +1,11 @@ +import type { ModuleConfig } from '$lib/data/module-registry'; + +export const sleepModuleConfig: ModuleConfig = { + appId: 'sleep', + tables: [ + { name: 'sleepEntries' }, + { name: 'sleepHygieneLogs' }, + { name: 'sleepHygieneChecks' }, + { name: 'sleepSettings' }, + ], +}; diff --git a/apps/mana/apps/web/src/lib/modules/sleep/queries.ts b/apps/mana/apps/web/src/lib/modules/sleep/queries.ts new file mode 100644 index 000000000..8dd0dbf1e --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/sleep/queries.ts @@ -0,0 +1,352 @@ +/** + * Reactive Queries & Pure Helpers for the Sleep module. + * + * Read-side only — mutations live in stores/sleep.svelte.ts. + */ + +import { useLiveQueryWithDefault } from '@mana/local-store/svelte'; +import { decryptRecords } from '$lib/data/crypto'; +import { db } from '$lib/data/database'; +import type { + LocalSleepEntry, + LocalSleepHygieneLog, + LocalSleepHygieneCheck, + LocalSleepSettings, + SleepEntry, + SleepHygieneLog, + SleepHygieneCheck, + SleepSettings, +} from './types'; +import { DEFAULT_SLEEP_SETTINGS } from './types'; + +// ─── Type Converters ──────────────────────────────────────── + +export function toSleepEntry(local: LocalSleepEntry): SleepEntry { + const now = new Date().toISOString(); + return { + id: local.id, + date: local.date, + bedtime: local.bedtime, + wakeTime: local.wakeTime, + durationMin: local.durationMin, + sleepLatencyMin: local.sleepLatencyMin ?? null, + interruptions: local.interruptions ?? 0, + interruptionDurationMin: local.interruptionDurationMin ?? 0, + quality: local.quality, + restedness: local.restedness ?? null, + notes: local.notes ?? '', + tags: local.tags ?? [], + dreamIds: local.dreamIds ?? [], + createdAt: local.createdAt ?? now, + updatedAt: local.updatedAt ?? now, + }; +} + +export function toSleepHygieneLog(local: LocalSleepHygieneLog): SleepHygieneLog { + return { + id: local.id, + date: local.date, + completedCheckIds: local.completedCheckIds ?? [], + score: local.score, + createdAt: local.createdAt ?? new Date().toISOString(), + }; +} + +export function toSleepHygieneCheck(local: LocalSleepHygieneCheck): SleepHygieneCheck { + const now = new Date().toISOString(); + return { + id: local.id, + name: local.name, + description: local.description ?? '', + category: local.category, + isActive: local.isActive, + isPreset: local.isPreset, + order: local.order, + createdAt: local.createdAt ?? now, + updatedAt: local.updatedAt ?? now, + }; +} + +export function toSleepSettings(local: LocalSleepSettings): SleepSettings { + return { + id: local.id, + goalMin: local.goalMin ?? DEFAULT_SLEEP_SETTINGS.goalMin, + targetBedtime: local.targetBedtime ?? DEFAULT_SLEEP_SETTINGS.targetBedtime, + targetWakeTime: local.targetWakeTime ?? DEFAULT_SLEEP_SETTINGS.targetWakeTime, + bedtimeReminderMin: local.bedtimeReminderMin ?? DEFAULT_SLEEP_SETTINGS.bedtimeReminderMin, + morningReminderEnabled: + local.morningReminderEnabled ?? DEFAULT_SLEEP_SETTINGS.morningReminderEnabled, + morningReminderTime: local.morningReminderTime ?? DEFAULT_SLEEP_SETTINGS.morningReminderTime, + }; +} + +// ─── Live Queries ─────────────────────────────────────────── + +export function useAllSleepEntries() { + return useLiveQueryWithDefault(async () => { + const locals = await db.table('sleepEntries').toArray(); + const visible = locals.filter((e) => !e.deletedAt); + const decrypted = await decryptRecords('sleepEntries', visible); + return decrypted.map(toSleepEntry).sort((a, b) => b.date.localeCompare(a.date)); + }, [] as SleepEntry[]); +} + +export function useAllSleepHygieneLogs() { + return useLiveQueryWithDefault(async () => { + const locals = await db.table('sleepHygieneLogs').toArray(); + const visible = locals.filter((l) => !l.deletedAt); + return visible.map(toSleepHygieneLog).sort((a, b) => b.date.localeCompare(a.date)); + }, [] as SleepHygieneLog[]); +} + +export function useAllSleepHygieneChecks() { + return useLiveQueryWithDefault(async () => { + const locals = await db.table('sleepHygieneChecks').toArray(); + const visible = locals.filter((c) => !c.deletedAt); + const decrypted = await decryptRecords('sleepHygieneChecks', visible); + return decrypted.map(toSleepHygieneCheck).sort((a, b) => a.order - b.order); + }, [] as SleepHygieneCheck[]); +} + +export function useSleepSettings() { + return useLiveQueryWithDefault( + async () => { + const locals = await db.table('sleepSettings').toArray(); + const row = locals.find((s) => !s.deletedAt); + return row ? toSleepSettings(row) : null; + }, + null as SleepSettings | null + ); +} + +// ─── Pure Helpers ─────────────────────────────────────────── + +/** Today as YYYY-MM-DD. */ +export function todayDateStr(): string { + return new Date().toISOString().split('T')[0]; +} + +/** Yesterday as YYYY-MM-DD. */ +export function yesterdayDateStr(): string { + const d = new Date(); + d.setDate(d.getDate() - 1); + return d.toISOString().split('T')[0]; +} + +/** Calculate sleep duration in minutes from bedtime to wake time (handles midnight crossing). */ +export function calcDurationMin(bedtime: string, wakeTime: string): number { + const bed = new Date(bedtime).getTime(); + const wake = new Date(wakeTime).getTime(); + if (wake <= bed) return 0; + return Math.round((wake - bed) / 60000); +} + +/** Format minutes as "Xh Ymin". */ +export function formatDuration(min: number): string { + const h = Math.floor(min / 60); + const m = min % 60; + if (h === 0) return `${m} Min`; + if (m === 0) return `${h}h`; + return `${h}h ${m}min`; +} + +/** Format HH:mm from ISO datetime. */ +export function formatTime(iso: string): string { + return iso.split('T')[1]?.slice(0, 5) ?? ''; +} + +/** Last night's entry (date = yesterday or today depending on when logged). */ +export function getLastNight(entries: SleepEntry[]): SleepEntry | null { + const today = todayDateStr(); + const yesterday = yesterdayDateStr(); + return entries.find((e) => e.date === today || e.date === yesterday) ?? entries[0] ?? null; +} + +/** Entry for a specific date. */ +export function getEntryForDate(entries: SleepEntry[], date: string): SleepEntry | null { + return entries.find((e) => e.date === date) ?? null; +} + +/** Has the user logged last night's sleep? */ +export function hasLoggedToday(entries: SleepEntry[]): boolean { + const today = todayDateStr(); + const yesterday = yesterdayDateStr(); + return entries.some((e) => e.date === today || e.date === yesterday); +} + +/** Average sleep duration over the last N entries. */ +export function getAvgDuration(entries: SleepEntry[], n: number): number { + const slice = entries.slice(0, n); + if (slice.length === 0) return 0; + return Math.round(slice.reduce((sum, e) => sum + e.durationMin, 0) / slice.length); +} + +/** Average quality over the last N entries. */ +export function getAvgQuality(entries: SleepEntry[], n: number): number { + const slice = entries.slice(0, n); + if (slice.length === 0) return 0; + return +(slice.reduce((sum, e) => sum + e.quality, 0) / slice.length).toFixed(1); +} + +/** Sleep debt for current week (Mon–Sun). Positive = deficit, negative = surplus. */ +export function getWeekSleepDebt(entries: SleepEntry[], goalMin: number): number { + const now = new Date(); + const dayOfWeek = now.getDay(); + 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]; + + let debt = 0; + const d = new Date(monday); + const todayStr = todayDateStr(); + + while (d.toISOString().split('T')[0] <= todayStr) { + const dateStr = d.toISOString().split('T')[0]; + const entry = entries.find((e) => e.date === dateStr); + debt += goalMin - (entry?.durationMin ?? 0); + d.setDate(d.getDate() + 1); + } + + return debt; +} + +/** + * Consistency score 0–100. + * Based on standard deviation of bedtime/wake time across last N entries. + * Lower deviation = higher score. + */ +export function getConsistencyScore(entries: SleepEntry[], n: number): number { + const slice = entries.slice(0, n); + if (slice.length < 3) return 0; + + // Extract bedtime minutes-from-midnight (with midnight crossing: 23:00 = -60, 01:00 = 60) + const bedMinutes = slice.map((e) => { + const d = new Date(e.bedtime); + let mins = d.getHours() * 60 + d.getMinutes(); + if (mins > 720) mins -= 1440; // Normalize past-midnight bedtimes + return mins; + }); + + const wakeMinutes = slice.map((e) => { + const d = new Date(e.wakeTime); + return d.getHours() * 60 + d.getMinutes(); + }); + + const stddev = (arr: number[]): number => { + const mean = arr.reduce((a, b) => a + b, 0) / arr.length; + const variance = arr.reduce((sum, v) => sum + (v - mean) ** 2, 0) / arr.length; + return Math.sqrt(variance); + }; + + const bedStd = stddev(bedMinutes); + const wakeStd = stddev(wakeMinutes); + + // Score: 100 at 0 deviation, drops ~50pts per 30min stddev + const score = Math.max(0, Math.min(100, 100 - (bedStd / 30) * 25 - (wakeStd / 30) * 25)); + return Math.round(score); +} + +/** Current streak: consecutive days with a sleep entry. */ +export function getCurrentStreak(entries: SleepEntry[]): number { + if (entries.length === 0) return 0; + + const entryDays = new Set(entries.map((e) => e.date)); + let streak = 0; + const d = new Date(); + + const todayStr = d.toISOString().split('T')[0]; + if (!entryDays.has(todayStr)) { + d.setDate(d.getDate() - 1); + } + + while (true) { + const dayStr = d.toISOString().split('T')[0]; + if (!entryDays.has(dayStr)) break; + streak++; + d.setDate(d.getDate() - 1); + } + + return streak; +} + +/** Week data: one entry per day (Mon–Sun) with duration + quality. */ +export function getWeekData( + entries: SleepEntry[] +): { date: string; dayLabel: string; durationMin: number; quality: number }[] { + const now = new Date(); + const dayOfWeek = now.getDay(); + const monday = new Date(now); + monday.setDate(now.getDate() - ((dayOfWeek + 6) % 7)); + + const result: { date: string; dayLabel: string; durationMin: number; quality: number }[] = []; + const dayLabels = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']; + + for (let i = 0; i < 7; i++) { + const d = new Date(monday); + d.setDate(monday.getDate() + i); + const dateStr = d.toISOString().split('T')[0]; + const entry = entries.find((e) => e.date === dateStr); + result.push({ + date: dateStr, + dayLabel: dayLabels[i], + durationMin: entry?.durationMin ?? 0, + quality: entry?.quality ?? 0, + }); + } + + return result; +} + +/** Quality data for the last 30 days (for heatmap). */ +export function getQualityHeatmap( + entries: SleepEntry[], + days: number +): { date: string; quality: number }[] { + const result: { date: string; quality: number }[] = []; + const d = new Date(); + + for (let i = 0; i < days; i++) { + const dateStr = d.toISOString().split('T')[0]; + const entry = entries.find((e) => e.date === dateStr); + result.unshift({ date: dateStr, quality: entry?.quality ?? 0 }); + d.setDate(d.getDate() - 1); + } + + return result; +} + +/** Correlation between hygiene score and sleep quality (simple average comparison). */ +export function getHygieneCorrelation( + entries: SleepEntry[], + hygieneLogs: SleepHygieneLog[] +): { withHygiene: number; withoutHygiene: number } | null { + const logsMap = new Map(hygieneLogs.map((l) => [l.date, l])); + const withH: number[] = []; + const withoutH: number[] = []; + + for (const entry of entries) { + const log = logsMap.get(entry.date); + if (log && log.score >= 70) { + withH.push(entry.quality); + } else { + withoutH.push(entry.quality); + } + } + + if (withH.length < 3 || withoutH.length < 3) return null; + + return { + withHygiene: +(withH.reduce((a, b) => a + b, 0) / withH.length).toFixed(1), + withoutHygiene: +(withoutH.reduce((a, b) => a + b, 0) / withoutH.length).toFixed(1), + }; +} + +/** Effective settings (DB row or defaults). */ +export function getEffectiveSettings(settings: SleepSettings | null): SleepSettings { + if (settings) return settings; + return { + id: 'default', + ...DEFAULT_SLEEP_SETTINGS, + }; +} diff --git a/apps/mana/apps/web/src/lib/modules/sleep/stores/sleep.svelte.ts b/apps/mana/apps/web/src/lib/modules/sleep/stores/sleep.svelte.ts new file mode 100644 index 000000000..e5210011e --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/sleep/stores/sleep.svelte.ts @@ -0,0 +1,245 @@ +/** + * Sleep Store — mutation-only service for the sleep module. + * + * All reads happen via liveQuery hooks in queries.ts. + */ + +import { encryptRecord } from '$lib/data/crypto'; +import { + sleepEntryTable, + sleepHygieneLogTable, + sleepHygieneCheckTable, + sleepSettingsTable, +} from '../collections'; +import { toSleepEntry, toSleepHygieneLog, toSleepHygieneCheck, calcDurationMin } from '../queries'; +import type { + LocalSleepEntry, + LocalSleepHygieneLog, + LocalSleepHygieneCheck, + LocalSleepSettings, + HygieneCategory, +} from '../types'; +import { DEFAULT_SLEEP_SETTINGS } from '../types'; + +export const sleepStore = { + // ─── Sleep Entries ────────────────────────────── + + async logSleep(input: { + date: string; + bedtime: string; + wakeTime: string; + quality: number; + sleepLatencyMin?: number | null; + interruptions?: number; + interruptionDurationMin?: number; + restedness?: number | null; + notes?: string; + tags?: string[]; + dreamIds?: string[]; + }) { + const durationMin = calcDurationMin(input.bedtime, input.wakeTime); + + // Upsert: if an entry for this date already exists, update it + const existing = (await sleepEntryTable.toArray()).find( + (e) => !e.deletedAt && e.date === input.date + ); + + if (existing) { + const patch: Partial = { + bedtime: input.bedtime, + wakeTime: input.wakeTime, + durationMin, + quality: input.quality, + sleepLatencyMin: input.sleepLatencyMin ?? existing.sleepLatencyMin, + interruptions: input.interruptions ?? existing.interruptions, + interruptionDurationMin: input.interruptionDurationMin ?? existing.interruptionDurationMin, + restedness: input.restedness ?? existing.restedness, + notes: input.notes ?? existing.notes, + tags: input.tags ?? existing.tags, + dreamIds: input.dreamIds ?? existing.dreamIds, + }; + const wrapped = await encryptRecord('sleepEntries', { ...patch }); + await sleepEntryTable.update(existing.id, { + ...wrapped, + updatedAt: new Date().toISOString(), + }); + return toSleepEntry({ ...existing, ...patch }); + } + + const newLocal: LocalSleepEntry = { + id: crypto.randomUUID(), + date: input.date, + bedtime: input.bedtime, + wakeTime: input.wakeTime, + durationMin, + sleepLatencyMin: input.sleepLatencyMin ?? null, + interruptions: input.interruptions ?? 0, + interruptionDurationMin: input.interruptionDurationMin ?? 0, + quality: input.quality, + restedness: input.restedness ?? null, + notes: input.notes ?? '', + tags: input.tags ?? [], + dreamIds: input.dreamIds ?? [], + }; + const snapshot = toSleepEntry({ ...newLocal }); + await encryptRecord('sleepEntries', newLocal); + await sleepEntryTable.add(newLocal); + return snapshot; + }, + + async updateEntry( + id: string, + patch: Partial< + Pick< + LocalSleepEntry, + | 'bedtime' + | 'wakeTime' + | 'quality' + | 'sleepLatencyMin' + | 'interruptions' + | 'interruptionDurationMin' + | 'restedness' + | 'notes' + | 'tags' + | 'dreamIds' + > + > + ) { + // Recalculate duration if times changed + const update: Record = { ...patch }; + if (patch.bedtime || patch.wakeTime) { + const entry = await sleepEntryTable.get(id); + if (entry) { + const bedtime = patch.bedtime ?? entry.bedtime; + const wakeTime = patch.wakeTime ?? entry.wakeTime; + update.durationMin = calcDurationMin(bedtime, wakeTime); + } + } + const wrapped = await encryptRecord('sleepEntries', update); + await sleepEntryTable.update(id, { + ...wrapped, + updatedAt: new Date().toISOString(), + }); + }, + + async deleteEntry(id: string) { + await sleepEntryTable.update(id, { deletedAt: new Date().toISOString() }); + }, + + // ─── Hygiene Logs ─────────────────────────────── + + async logHygiene(input: { + date: string; + completedCheckIds: string[]; + totalActiveChecks: number; + }) { + const score = + input.totalActiveChecks > 0 + ? Math.round((input.completedCheckIds.length / input.totalActiveChecks) * 100) + : 0; + + // Upsert by date + const existing = (await sleepHygieneLogTable.toArray()).find( + (l) => !l.deletedAt && l.date === input.date + ); + + if (existing) { + await sleepHygieneLogTable.update(existing.id, { + completedCheckIds: input.completedCheckIds, + score, + updatedAt: new Date().toISOString(), + }); + return toSleepHygieneLog({ ...existing, completedCheckIds: input.completedCheckIds, score }); + } + + const newLocal: LocalSleepHygieneLog = { + id: crypto.randomUUID(), + date: input.date, + completedCheckIds: input.completedCheckIds, + score, + }; + const snapshot = toSleepHygieneLog({ ...newLocal }); + await sleepHygieneLogTable.add(newLocal); + return snapshot; + }, + + // ─── Hygiene Checks ───────────────────────────── + + async createCheck(input: { name: string; description?: string; category?: HygieneCategory }) { + const existing = await sleepHygieneCheckTable.toArray(); + const order = existing.filter((c) => !c.deletedAt).length; + + const newLocal: LocalSleepHygieneCheck = { + id: crypto.randomUUID(), + name: input.name, + description: input.description ?? '', + category: input.category ?? 'custom', + isActive: true, + isPreset: false, + order, + }; + const snapshot = toSleepHygieneCheck({ ...newLocal }); + await encryptRecord('sleepHygieneChecks', newLocal); + await sleepHygieneCheckTable.add(newLocal); + return snapshot; + }, + + async updateCheck( + id: string, + patch: Partial> + ) { + const wrapped = await encryptRecord('sleepHygieneChecks', { ...patch }); + await sleepHygieneCheckTable.update(id, { + ...wrapped, + updatedAt: new Date().toISOString(), + }); + }, + + async toggleCheck(id: string) { + const check = await sleepHygieneCheckTable.get(id); + if (!check) return; + await sleepHygieneCheckTable.update(id, { + isActive: !check.isActive, + updatedAt: new Date().toISOString(), + }); + }, + + async deleteCheck(id: string) { + const check = await sleepHygieneCheckTable.get(id); + if (!check || check.isPreset) return; + await sleepHygieneCheckTable.update(id, { deletedAt: new Date().toISOString() }); + }, + + // ─── Settings ─────────────────────────────────── + + async updateSettings( + patch: Partial< + Pick< + LocalSleepSettings, + | 'goalMin' + | 'targetBedtime' + | 'targetWakeTime' + | 'bedtimeReminderMin' + | 'morningReminderEnabled' + | 'morningReminderTime' + > + > + ) { + const existing = (await sleepSettingsTable.toArray()).find((s) => !s.deletedAt); + + if (existing) { + await sleepSettingsTable.update(existing.id, { + ...patch, + updatedAt: new Date().toISOString(), + }); + return; + } + + const newLocal: LocalSleepSettings = { + id: crypto.randomUUID(), + ...DEFAULT_SLEEP_SETTINGS, + ...patch, + }; + await sleepSettingsTable.add(newLocal); + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/sleep/types.ts b/apps/mana/apps/web/src/lib/modules/sleep/types.ts new file mode 100644 index 000000000..e88dbe68b --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/sleep/types.ts @@ -0,0 +1,183 @@ +/** + * Sleep module types — sleep tracking with hygiene checklists. + * + * Tables: + * sleepEntries — one row per night (bedtime → wake) + * sleepHygieneLogs — evening hygiene checklist completion + * sleepHygieneChecks — configurable hygiene check definitions + * sleepSettings — singleton user preferences (goal, reminders) + */ + +import type { BaseRecord } from '@mana/local-store'; + +// ─── Enums / unions ───────────────────────────────────────── + +export type HygieneCategory = + | 'nutrition' + | 'digital' + | 'environment' + | 'routine' + | 'consistency' + | 'custom'; + +// ─── Local Record Types (Dexie) ───────────────────────────── + +export interface LocalSleepEntry extends BaseRecord { + /** YYYY-MM-DD of the night (= date of falling asleep). */ + date: string; + /** ISO datetime — when went to bed. */ + bedtime: string; + /** ISO datetime — when woke up. */ + wakeTime: string; + /** Calculated sleep duration in minutes. */ + durationMin: number; + /** Minutes to fall asleep (optional). */ + sleepLatencyMin: number | null; + /** Number of night-time awakenings. */ + interruptions: number; + /** Total duration of interruptions in minutes. */ + interruptionDurationMin: number; + /** Sleep quality 1–5. */ + quality: number; + /** Woke up rested? 1–5 (optional). */ + restedness: number | null; + /** Free-text notes. */ + notes: string; + /** Tags (e.g. "nightmare", "jetlag", "medication"). */ + tags: string[]; + /** Links to Dreams module entries. */ + dreamIds: string[]; +} + +export interface LocalSleepHygieneLog extends BaseRecord { + /** YYYY-MM-DD */ + date: string; + /** IDs of completed checks. */ + completedCheckIds: string[]; + /** Score 0–100 (% of active checks completed). */ + score: number; +} + +export interface LocalSleepHygieneCheck extends BaseRecord { + name: string; + description: string; + category: HygieneCategory; + isActive: boolean; + isPreset: boolean; + order: number; +} + +export interface LocalSleepSettings extends BaseRecord { + /** Sleep goal in minutes (default: 480 = 8h). */ + goalMin: number; + /** Target bedtime HH:mm. */ + targetBedtime: string; + /** Target wake time HH:mm. */ + targetWakeTime: string; + /** Reminder: minutes before bedtime (0 = off). */ + bedtimeReminderMin: number; + /** Morning log reminder active. */ + morningReminderEnabled: boolean; + /** Morning log reminder time HH:mm. */ + morningReminderTime: string; +} + +// ─── Domain Types (UI-facing) ─────────────────────────────── + +export interface SleepEntry { + id: string; + date: string; + bedtime: string; + wakeTime: string; + durationMin: number; + sleepLatencyMin: number | null; + interruptions: number; + interruptionDurationMin: number; + quality: number; + restedness: number | null; + notes: string; + tags: string[]; + dreamIds: string[]; + createdAt: string; + updatedAt: string; +} + +export interface SleepHygieneLog { + id: string; + date: string; + completedCheckIds: string[]; + score: number; + createdAt: string; +} + +export interface SleepHygieneCheck { + id: string; + name: string; + description: string; + category: HygieneCategory; + isActive: boolean; + isPreset: boolean; + order: number; + createdAt: string; + updatedAt: string; +} + +export interface SleepSettings { + id: string; + goalMin: number; + targetBedtime: string; + targetWakeTime: string; + bedtimeReminderMin: number; + morningReminderEnabled: boolean; + morningReminderTime: string; +} + +// ─── Constants ────────────────────────────────────────────── + +export const HYGIENE_CATEGORIES: readonly HygieneCategory[] = [ + 'nutrition', + 'digital', + 'environment', + 'routine', + 'consistency', + 'custom', +] as const; + +export const HYGIENE_CATEGORY_LABELS: Record = { + nutrition: { de: 'Ernährung', en: 'Nutrition' }, + digital: { de: 'Digital', en: 'Digital' }, + environment: { de: 'Umgebung', en: 'Environment' }, + routine: { de: 'Routine', en: 'Routine' }, + consistency: { de: 'Konsistenz', en: 'Consistency' }, + custom: { de: 'Eigene', en: 'Custom' }, +}; + +export const QUALITY_LABELS: Record = { + 1: { de: 'Sehr schlecht', en: 'Very poor' }, + 2: { de: 'Schlecht', en: 'Poor' }, + 3: { de: 'Okay', en: 'Okay' }, + 4: { de: 'Gut', en: 'Good' }, + 5: { de: 'Sehr gut', en: 'Very good' }, +}; + +export const SLEEP_TAG_PRESETS = [ + 'Alptraum', + 'Klartraum', + 'Jetlag', + 'Schichtarbeit', + 'Mittagsschlaf', + 'Medikament', + 'Krank', + 'Stress', + 'Sport abends', + 'Alkohol', +] as const; + +export const DEFAULT_SLEEP_SETTINGS: Omit = { + goalMin: 480, + targetBedtime: '23:00', + targetWakeTime: '07:00', + bedtimeReminderMin: 30, + morningReminderEnabled: true, + morningReminderTime: '08:00', +}; diff --git a/apps/mana/apps/web/src/routes/(app)/sleep/+layout.svelte b/apps/mana/apps/web/src/routes/(app)/sleep/+layout.svelte new file mode 100644 index 000000000..07384789a --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/sleep/+layout.svelte @@ -0,0 +1,19 @@ + + +{@render children()} diff --git a/apps/mana/apps/web/src/routes/(app)/sleep/+page.svelte b/apps/mana/apps/web/src/routes/(app)/sleep/+page.svelte new file mode 100644 index 000000000..1a61db2db --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/sleep/+page.svelte @@ -0,0 +1,9 @@ + + + + Sleep - Mana + + + diff --git a/docs/future/MODULE_IDEAS.md b/docs/future/MODULE_IDEAS.md index d96515427..ee575d3c8 100644 --- a/docs/future/MODULE_IDEAS.md +++ b/docs/future/MODULE_IDEAS.md @@ -109,8 +109,12 @@ recommendation. ## Health & Body (additional) - **drink** — ✅ **Built.** Getränke-Tracker für alle Getränke (Wasser, Kaffee, Tee, Saft, Alkohol etc.). Tages-/Wochenziele, Favoriten, Verlauf. Verknüpfung mit `nutriphi` und `body`. +- **stretch** — ✅ **Built.** Geführtes Dehnen mit Timer-Player, Bestandsaufnahme, Routinen, Streaks, Erinnerungen. 22 Seed-Übungen, 5 Preset-Routinen. - **breathe** — Atemübungen & Meditation-Timer mit geführten Mustern (Box Breathing, 4-7-8). Sessions-Log verknüpft mit `moodlit`. -- **fasting** — Intervallfasten-Timer (16:8, 5:2 etc.), verknüpft mit `nutriphi` und `body`. +- **fasting** — Intervallfasten-Timer (16:8, 18:6, OMAD, custom). Essensfenster visualisieren, Fasten-Streak. Synergie: `nutriphi` (Mahlzeiten im Essensfenster), `drink` (Wasser während Fastenphase). +- **posture** — Haltungs-Checks zu konfigurierbaren Zeiten ("Sitzt du gerade?"). Foto-basiertes Tracking (Seitenansicht-Selfie → Vorher/Nachher). Übungsbibliothek für Haltungskorrektur. Arbeitsplatz-Ergonomie-Checkliste. Synergie: `stretch` (Routine-Empfehlung), `body` (Kraftübungen für Haltung). +- **skin** — Hautpflege-Routinen (morgens/abends: Produkte + Reihenfolge). Hautzustand-Logging (Foto + Bewertung: Unreinheiten, Trockenheit, Rötung). Produkt-Bibliothek mit Inhaltsstoffen. Trigger-Tracking (Ernährung, Stress, Schlaf → Hautveränderung). +- **eyes** — 20-20-20 Regel Reminder (alle 20 Min, 20 Sek, 20 Fuß entfernt schauen). Bildschirmzeit-Logging. Augenübungen (Fokus nah/fern, Kreise, Palming). Synergie: `stretch` (Desk-Break könnte Augenübung enthalten). ## Knowledge & Productivity (additional) diff --git a/docs/modules/SLEEP_MODULE_PLAN.md b/docs/modules/SLEEP_MODULE_PLAN.md new file mode 100644 index 000000000..900056916 --- /dev/null +++ b/docs/modules/SLEEP_MODULE_PLAN.md @@ -0,0 +1,353 @@ +# Modul-Planung: Sleep / Schlaf + +> **Kontext:** Neues Health-Modul im Mana-Ökosystem. Schlaf ist der #1 Health-Multiplier — beeinflusst Training, Stimmung, Kognition. Starke Synergien mit Body (DailyCheck), Dreams, Drink (Koffein), Stretch (Abendroutine), Meditate. + +--- + +## 1. Namensvorschläge + +| Englisch | Deutsch | `appId` | Anmerkung | +|----------|---------|---------|-----------| +| **Sleep** | **Schlaf** | `sleep` | Klar, kurz, kein Konflikt | +| Slumber | Schlummer | `slumber` | Poetisch, aber etwas lang | +| Rest | Ruhe | `rest` | Doppeldeutig (REST API) | +| Nite | Nacht | `nite` | Modern, aber unklar | + +**Empfehlung:** `sleep` / `Schlaf` + +--- + +## 2. Feature-Übersicht + +### 2.1 Schlaf-Logging + +Kern des Moduls: tägliches Erfassen von Schlafzeiten und -qualität. + +**Erfassung:** +- **Einschlafzeit** (Bedtime) — wann ins Bett gegangen +- **Aufwachzeit** (Wake time) — wann aufgestanden +- **Schlafdauer** — automatisch berechnet +- **Einschlafdauer** — wie lange zum Einschlafen gebraucht (optional) +- **Unterbrechungen** — Anzahl und Gesamtdauer nächtlicher Aufwacher +- **Schlafqualität** — 1–5 Sterne Gesamtbewertung + +**Quick-Log UX:** +- Morgens: "Wie hast du geschlafen?" → Aufwachzeit (default: jetzt), Einschlafzeit (gestern), Qualität +- 3-Tap-Minimum: Einschlafzeit → Aufwachzeit → Qualität → Fertig +- Smart Defaults: letzte Woche Durchschnitt als Vorschlag + +### 2.2 Schlafziel & Fortschritt + +- Tägliches Schlafziel konfigurierbar (Default: 8h) +- Tagesanzeige: "7h 23min von 8h" mit Fortschrittsbalken +- Wochenziel: "Diese Woche: 52h von 56h" +- Konsistenz-Score: wie regelmäßig sind Ein-/Aufschlafzeiten? + +### 2.3 Schlafhygiene-Checkliste + +Abendliche Checkliste für besseren Schlaf: + +| Check | Kategorie | +|-------|-----------| +| Kein Koffein nach 14:00 | Ernährung | +| Kein Alkohol 3h vor Schlaf | Ernährung | +| Bildschirme aus 1h vor Schlaf | Digital | +| Schlafzimmer kühl (16–18°C) | Umgebung | +| Schlafzimmer dunkel | Umgebung | +| Keine schwere Mahlzeit 2h vor Schlaf | Ernährung | +| Entspannungsroutine gemacht | Routine | +| Gleiche Schlafenszeit ±30min | Konsistenz | + +- Nutzer kann Checks an/aus schalten und eigene hinzufügen +- Tägliche Abfrage (optional, abends via Reminder) +- Korrelation: Checklisten-Score vs. Schlafqualität über Zeit + +### 2.4 Statistiken & Trends + +- **Wochen-Übersicht:** Balkendiagramm Schlafdauer pro Nacht +- **Schlafenszeit-Trend:** Linie wann eingeschlafen/aufgewacht (Konsistenz sichtbar) +- **Qualitäts-Heatmap:** 30-Tage Kalender farbcodiert (rot → grün) +- **Durchschnitte:** Ø Schlafdauer, Ø Qualität, Ø Einschlafzeit letzte 7/30 Tage +- **Schlechteste/Beste Nacht:** Highlights der letzten 30 Tage +- **Schlafschuld:** Kumuliertes Defizit (Ziel − tatsächlich) über die Woche + +### 2.5 Schlaf-Reminder + +- **Schlafenszeit-Erinnerung:** "In 30 Min ist Schlafenszeit" (konfigurierbarer Vorlauf) +- **Wind-Down Routine:** Optional: Stretch-Abendroutine oder Meditate-Session vorschlagen +- **Morgen-Log Reminder:** "Wie hast du geschlafen?" (wenn morgens nicht geloggt) + +### 2.6 Cross-Modul Synergien + +| Modul | Integration | +|-------|-------------| +| **Body** | `bodyChecks.sleep` (1–5) wird durch Sleep-Qualitätswert ersetzt/gespiegelt. Korrelation: Schlafdauer vs. Trainingsleistung | +| **Dreams** | "Traum gehabt?" Button im Morgen-Log → öffnet Dreams-Modul mit verknüpfter Nacht | +| **Drink** | Koffein-Warnung: "Du hattest um 16:30 einen Kaffee — das kann den Schlaf beeinflussen" | +| **Stretch** | Abendroutine als Wind-Down vorschlagen wenn Schlafenszeit naht | +| **Meditate** | Einschlaf-Meditation vorschlagen | +| **Mood** (zukünftig) | Korrelation Stimmung ↔ Schlafqualität | +| **Habits** | "Kein Bildschirm ab 22:00" als Habit tracken, in Schlafhygiene-Score einfließen | + +--- + +## 3. Datenmodell + +### Tabellen + +```typescript +// Schlaf-Eintrag (eine Nacht) +interface LocalSleepEntry extends BaseRecord { + /** YYYY-MM-DD der Nacht (= Datum des Einschlafens) */ + date: string; + /** ISO datetime — wann ins Bett */ + bedtime: string; + /** ISO datetime — wann aufgewacht */ + wakeTime: string; + /** Berechnete Schlafdauer in Minuten */ + durationMin: number; + /** Minuten zum Einschlafen (optional) */ + sleepLatencyMin: number | null; + /** Anzahl nächtlicher Aufwacher */ + interruptions: number; + /** Gesamtdauer der Unterbrechungen in Minuten */ + interruptionDurationMin: number; + /** Schlafqualität 1–5 */ + quality: number; + /** Aufgewacht ausgeruht? 1–5 */ + restedness: number | null; + /** Freitext-Notizen */ + notes: string; + /** Tags (z.B. "Alptraum", "Jetlag", "Medikament") */ + tags: string[]; + /** Verknüpfung zu Dreams-Modul */ + dreamIds: string[]; +} + +// Schlafhygiene-Check (abendlich, optional) +interface LocalSleepHygieneLog extends BaseRecord { + /** YYYY-MM-DD */ + date: string; + /** IDs der erfüllten Checks */ + completedCheckIds: string[]; + /** Score 0–100 (% der aktiven Checks erfüllt) */ + score: number; +} + +// Schlafhygiene-Check Definition (konfigurierbar) +interface LocalSleepHygieneCheck extends BaseRecord { + name: string; + description: string; + category: HygieneCategory; + isActive: boolean; + isPreset: boolean; + order: number; +} + +// Schlaf-Einstellungen (Singleton) +interface LocalSleepSettings extends BaseRecord { + /** Schlafziel in Minuten (Default: 480 = 8h) */ + goalMin: number; + /** Ziel-Einschlafzeit HH:mm */ + targetBedtime: string; + /** Ziel-Aufwachzeit HH:mm */ + targetWakeTime: string; + /** Reminder: Minuten vor Schlafenszeit (0 = aus) */ + bedtimeReminderMin: number; + /** Morgen-Log Reminder aktiv */ + morningReminderEnabled: boolean; + /** Morgen-Log Reminder Zeit HH:mm */ + morningReminderTime: string; +} + +// Enums +type HygieneCategory = 'nutrition' | 'digital' | 'environment' | 'routine' | 'consistency' | 'custom'; +``` + +### Encryption Registry + +```typescript +sleepEntries: { enabled: true, fields: ['notes'] }, +sleepHygieneLogs: { enabled: false, fields: [] }, +sleepHygieneChecks: { enabled: true, fields: ['name', 'description'] }, +sleepSettings: { enabled: false, fields: [] }, +``` + +### Module Config + +```typescript +export const sleepModuleConfig: ModuleConfig = { + appId: 'sleep', + tables: [ + { name: 'sleepEntries' }, + { name: 'sleepHygieneLogs' }, + { name: 'sleepHygieneChecks' }, + { name: 'sleepSettings' }, + ], +}; +``` + +--- + +## 4. UI-Konzept + +### Dashboard (`/sleep`) + +``` +┌─────────────────────────────────────────┐ +│ Letzte Nacht │ +│ ┌─────────────────────────────────┐ │ +│ │ 23:15 ━━━━━━━━━━━━━━━━ 06:42 │ │ +│ │ 7h 27min ★★★★☆ │ │ +│ └─────────────────────────────────┘ │ +│ 7h 27min / 8h Ziel ████████░░ 93% │ +├─────────────────────────────────────────┤ +│ Diese Woche Ø 7h 12min │ +│ Mo ██████░ 6:45 │ +│ Di ███████ 7:30 │ +│ Mi ██████░ 6:50 │ +│ Do ████████ 8:10 │ +│ Fr ███████ 7:23 │ +│ Sa ░░░░░░░ — │ +│ So ░░░░░░░ — │ +├─────────────────────────────────────────┤ +│ Schlafschuld: -48 Min diese Woche │ +├─────────────────────────────────────────┤ +│ Trends (30 Tage) │ +│ Ø Qualität: 3.8 ★ │ Ø Dauer: 7:15 │ +│ Konsistenz: 82% │ Streak: 14 Tage│ +├─────────────────────────────────────────┤ +│ [ Schlaf loggen ] [ Hygiene-Check ] │ +└─────────────────────────────────────────┘ +``` + +### Morgen-Log Flow + +``` +┌─────────────────────────────────────────┐ +│ Guten Morgen! Wie hast du geschlafen? │ +│ │ +│ Eingeschlafen [ 23:15 ] ← gestern │ +│ Aufgewacht [ 06:42 ] ← heute │ +│ │ +│ ═══════════ 7h 27min ═══════════ │ +│ │ +│ Qualität │ +│ ☆ ☆ ☆ ☆ ☆ │ +│ (tap to rate) │ +│ │ +│ Aufwacher in der Nacht? [ 0 ] │ +│ │ +│ Traum gehabt? [ Ja → Dreams ] │ +│ │ +│ Notizen (optional) │ +│ ┌─────────────────────────────────┐ │ +│ │ │ │ +│ └─────────────────────────────────┘ │ +│ │ +│ [ Speichern ] │ +└─────────────────────────────────────────┘ +``` + +### Schlafenszeit-Balken + +Kompakte Visualisierung einer Nacht: + +``` + 23:00 00:00 01:00 02:00 03:00 04:00 05:00 06:00 07:00 + │▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓│ + ↑ Einschlaf Aufwach ↑ +``` + +Für die Wochenansicht gestapelt — zeigt auf einen Blick wie konsistent die Schlafzeiten sind. + +--- + +## 5. Seed-Daten + +### Schlafhygiene-Checks (Presets) + +```typescript +const HYGIENE_PRESETS = [ + { id: 'hygiene-no-caffeine', name: 'Kein Koffein nach 14:00', category: 'nutrition', order: 0 }, + { id: 'hygiene-no-alcohol', name: 'Kein Alkohol 3h vor Schlaf', category: 'nutrition', order: 1 }, + { id: 'hygiene-no-heavy-meal', name: 'Keine schwere Mahlzeit 2h vor Schlaf', category: 'nutrition', order: 2 }, + { id: 'hygiene-screens-off', name: 'Bildschirme aus 1h vor Schlaf', category: 'digital', order: 3 }, + { id: 'hygiene-no-phone-bed', name: 'Kein Handy im Bett', category: 'digital', order: 4 }, + { id: 'hygiene-cool-room', name: 'Schlafzimmer kühl (16–18°C)', category: 'environment', order: 5 }, + { id: 'hygiene-dark-room', name: 'Schlafzimmer dunkel', category: 'environment', order: 6 }, + { id: 'hygiene-quiet', name: 'Ruhige Umgebung / Ohrstöpsel', category: 'environment', order: 7 }, + { id: 'hygiene-wind-down', name: 'Entspannungsroutine gemacht', category: 'routine', order: 8 }, + { id: 'hygiene-consistent-time', name: 'Gleiche Schlafenszeit ±30min', category: 'consistency', order: 9 }, +]; +``` + +### Default Settings + +```typescript +const DEFAULT_SETTINGS = { + goalMin: 480, // 8h + targetBedtime: '23:00', + targetWakeTime: '07:00', + bedtimeReminderMin: 30, // 30min vorher + morningReminderEnabled: true, + morningReminderTime: '08:00', +}; +``` + +--- + +## 6. App-Registrierung + +```typescript +{ + id: 'sleep', + name: 'Sleep', + nameDe: 'Schlaf', + description: { + de: 'Schlaf-Tracking', + en: 'Sleep Tracking', + }, + longDescription: { + de: 'Tracke deinen Schlaf mit Zeiten, Qualität und Schlafhygiene. Wochen-Trends, Schlafschuld, Konsistenz-Score und Verknüpfung mit Träumen.', + en: 'Track your sleep with times, quality, and sleep hygiene. Weekly trends, sleep debt, consistency score, and dream linking.', + }, + icon: APP_ICONS.sleep, + color: '#6366f1', // Indigo — Nacht/Ruhe + status: 'development', + requiredTier: 'guest', +} +``` + +--- + +## 7. Technische Besonderheiten + +### Nacht-Überlappung +- Schlaf überlappt Mitternacht: Einschlafzeit gehört zum Vortag +- `date` Feld = Datum des Einschlafens (nicht Aufwachens) +- Dauer-Berechnung muss über Mitternacht funktionieren + +### Konsistenz-Score +``` +score = 100 - (stddev_bedtime_minutes / 30 * 50) - (stddev_waketime_minutes / 30 * 50) +``` +Capped auf 0–100. Je geringer die Abweichung der Ein-/Aufschlafzeiten, desto höher. + +### Schlafschuld +``` +debt_week = sum(goalMin - actualMin) for each day +``` +Positiv = Defizit, Negativ = Überschuss. Resets wöchentlich (Montag). + +--- + +## 8. Implementierungsreihenfolge + +1. **Datenmodell + Store** — Types, Config, Collections, Queries, Store +2. **Morgen-Log** — Quick-Entry Formular (Kernfunktion) +3. **Dashboard** — Letzte Nacht, Wochenübersicht, Schlafziel-Fortschritt +4. **Statistiken** — Trends, Durchschnitte, Konsistenz-Score, Schlafschuld +5. **Schlafhygiene** — Check-Konfiguration, Abend-Checklist, Korrelation +6. **Reminders** — Schlafenszeit-Erinnerung, Morgen-Log Reminder +7. **Cross-Modul** — Dreams-Verlinkung, Body-Check Integration, Drink-Warnung diff --git a/packages/shared-branding/src/app-icons.ts b/packages/shared-branding/src/app-icons.ts index 68572c139..aa0956936 100644 --- a/packages/shared-branding/src/app-icons.ts +++ b/packages/shared-branding/src/app-icons.ts @@ -186,6 +186,11 @@ export const APP_ICONS = { // Violet→indigo gradient for the mindfulness/calm theme. `` ), + sleep: svgToDataUrl( + // Moon with stars — represents sleep / night time. + // Indigo→purple gradient for the nighttime/rest theme. + `` + ), } as const; export type AppIconId = keyof typeof APP_ICONS; diff --git a/packages/shared-branding/src/mana-apps.ts b/packages/shared-branding/src/mana-apps.ts index 400074905..8adb659ec 100644 --- a/packages/shared-branding/src/mana-apps.ts +++ b/packages/shared-branding/src/mana-apps.ts @@ -841,6 +841,23 @@ export const MANA_APPS: ManaApp[] = [ status: 'development', requiredTier: 'guest', }, + { + id: 'sleep', + name: 'Sleep', + description: { + de: 'Schlaf-Tracking', + en: 'Sleep Tracking', + }, + longDescription: { + de: 'Tracke deinen Schlaf mit Zeiten, Qualität und Schlafhygiene. Wochen-Trends, Schlafschuld, Konsistenz-Score und Verknüpfung mit Träumen.', + en: 'Track your sleep with times, quality, and sleep hygiene. Weekly trends, sleep debt, consistency score, and dream linking.', + }, + icon: APP_ICONS.sleep, + color: '#6366f1', + comingSoon: false, + status: 'development', + requiredTier: 'guest', + }, ]; /**