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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-13 21:19:52 +02:00
parent 66dd684bba
commit 1e992d3c92
21 changed files with 2737 additions and 2 deletions

View file

@ -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') },
},
});

View file

@ -448,6 +448,17 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
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: [] },
};
/**

View file

@ -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

View file

@ -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 ──────────────────────────────────────────

View file

@ -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

View file

@ -0,0 +1,574 @@
<!--
Sleep — ListView (Dashboard)
Last night summary, week bars, sleep goal, debt, stats, hygiene.
-->
<script lang="ts">
import { getContext } from 'svelte';
import type { Observable } from 'dexie';
import type { SleepEntry, SleepHygieneLog, SleepHygieneCheck, SleepSettings } from './types';
import {
getLastNight,
hasLoggedToday,
getAvgDuration,
getAvgQuality,
getWeekSleepDebt,
getConsistencyScore,
getCurrentStreak,
getWeekData,
getQualityHeatmap,
getHygieneCorrelation,
getEffectiveSettings,
formatDuration,
formatTime,
} from './queries';
import { QUALITY_LABELS } from './types';
import MorningLog from './components/MorningLog.svelte';
import HygieneChecklist from './components/HygieneChecklist.svelte';
const entries$: Observable<SleepEntry[]> = getContext('sleepEntries');
const hygieneLogs$: Observable<SleepHygieneLog[]> = getContext('sleepHygieneLogs');
const hygieneChecks$: Observable<SleepHygieneCheck[]> = getContext('sleepHygieneChecks');
const settings$: Observable<SleepSettings | null> = getContext('sleepSettings');
let entries = $state<SleepEntry[]>([]);
let hygieneLogs = $state<SleepHygieneLog[]>([]);
let hygieneChecks = $state<SleepHygieneCheck[]>([]);
let settingsRaw = $state<SleepSettings | null>(null);
$effect(() => {
const sub = entries$.subscribe((v) => (entries = v));
return () => sub.unsubscribe();
});
$effect(() => {
const sub = hygieneLogs$.subscribe((v) => (hygieneLogs = v));
return () => sub.unsubscribe();
});
$effect(() => {
const sub = hygieneChecks$.subscribe((v) => (hygieneChecks = v));
return () => sub.unsubscribe();
});
$effect(() => {
const sub = settings$.subscribe((v) => (settingsRaw = v));
return () => sub.unsubscribe();
});
let settings = $derived(getEffectiveSettings(settingsRaw));
let lastNight = $derived(getLastNight(entries));
let logged = $derived(hasLoggedToday(entries));
let avgDuration7 = $derived(getAvgDuration(entries, 7));
let avgQuality7 = $derived(getAvgQuality(entries, 7));
let sleepDebt = $derived(getWeekSleepDebt(entries, settings.goalMin));
let consistency = $derived(getConsistencyScore(entries, 14));
let streak = $derived(getCurrentStreak(entries));
let weekData = $derived(getWeekData(entries));
let heatmap = $derived(getQualityHeatmap(entries, 30));
let hygieneCorr = $derived(getHygieneCorrelation(entries, hygieneLogs));
// UI state
let showMorningLog = $state(false);
let showHygiene = $state(false);
function goalProgress(durationMin: number): number {
return Math.min(durationMin / settings.goalMin, 1);
}
function qualityColor(q: number): string {
if (q >= 4) return '#22c55e';
if (q >= 3) return '#f59e0b';
if (q >= 1) return '#ef4444';
return 'transparent';
}
// Max bar height for week chart
let maxWeekMin = $derived(Math.max(...weekData.map((d) => d.durationMin), settings.goalMin));
</script>
{#if showMorningLog}
<MorningLog
defaultBedtime={settings.targetBedtime}
onComplete={() => (showMorningLog = false)}
onCancel={() => (showMorningLog = false)}
/>
{:else if showHygiene}
<HygieneChecklist
checks={hygieneChecks}
onComplete={() => (showHygiene = false)}
onCancel={() => (showHygiene = false)}
/>
{:else}
<div class="sleep-view">
<!-- Log CTA if not logged -->
{#if !logged}
<button class="log-cta" onclick={() => (showMorningLog = true)}>
<span class="cta-icon">🌙</span>
<span class="cta-text">Wie hast du geschlafen?</span>
<span class="cta-sub">Jetzt loggen</span>
</button>
{/if}
<!-- Last Night -->
{#if lastNight}
<div class="last-night">
<div class="ln-header">
<span class="ln-label">Letzte Nacht</span>
{#if logged}
<button class="edit-btn" onclick={() => (showMorningLog = true)}>Bearbeiten</button>
{/if}
</div>
<div class="ln-bar-container">
<span class="ln-time">{formatTime(lastNight.bedtime)}</span>
<div class="ln-bar">
<div
class="ln-bar-fill"
style:width="{goalProgress(lastNight.durationMin) * 100}%"
></div>
</div>
<span class="ln-time">{formatTime(lastNight.wakeTime)}</span>
</div>
<div class="ln-stats">
<span class="ln-duration">{formatDuration(lastNight.durationMin)}</span>
<span class="ln-quality">
{#each [1, 2, 3, 4, 5] as val}
<span class="mini-star" class:filled={lastNight.quality >= val}>★</span>
{/each}
</span>
{#if lastNight.interruptions > 0}
<span class="ln-interruptions">{lastNight.interruptions}× aufgewacht</span>
{/if}
</div>
<div class="ln-goal">
{formatDuration(lastNight.durationMin)} / {formatDuration(settings.goalMin)}
<span class="goal-pct">{Math.round(goalProgress(lastNight.durationMin) * 100)}%</span>
</div>
</div>
{/if}
<!-- Week Chart -->
<div class="week-section">
<span class="section-label">Diese Woche</span>
<div class="week-chart">
{#each weekData as day}
<div class="week-col">
<div class="bar-wrapper">
{#if day.durationMin > 0}
<div
class="bar"
style:height="{(day.durationMin / maxWeekMin) * 100}%"
style:background={qualityColor(day.quality)}
></div>
{/if}
<!-- Goal line -->
<div class="goal-line" style:bottom="{(settings.goalMin / maxWeekMin) * 100}%"></div>
</div>
<span class="week-label">{day.dayLabel}</span>
{#if day.durationMin > 0}
<span class="week-dur">{Math.floor(day.durationMin / 60)}h</span>
{/if}
</div>
{/each}
</div>
</div>
<!-- Stats Grid -->
<div class="stats-grid">
<div class="stat">
<span class="stat-val">{formatDuration(avgDuration7)}</span>
<span class="stat-lbl">Ø Dauer (7T)</span>
</div>
<div class="stat">
<span class="stat-val">{avgQuality7}</span>
<span class="stat-lbl">Ø Qualität</span>
</div>
<div class="stat">
<span class="stat-val" class:debt={sleepDebt > 0}
>{sleepDebt > 0 ? '-' : '+'}{formatDuration(Math.abs(sleepDebt))}</span
>
<span class="stat-lbl">Schlafschuld</span>
</div>
<div class="stat">
<span class="stat-val">{consistency}%</span>
<span class="stat-lbl">Konsistenz</span>
</div>
<div class="stat">
<span class="stat-val">{streak}</span>
<span class="stat-lbl">Streak</span>
</div>
</div>
<!-- Quality Heatmap -->
<div class="heatmap-section">
<span class="section-label">Qualität (30 Tage)</span>
<div class="heatmap-grid">
{#each heatmap as day}
<div
class="heat-cell"
style:background={day.quality > 0 ? qualityColor(day.quality) : ''}
title="{day.date}: {QUALITY_LABELS[day.quality]?.de ?? '—'}"
></div>
{/each}
</div>
</div>
<!-- Hygiene Correlation -->
{#if hygieneCorr}
<div class="correlation-card">
<span class="section-label">Schlafhygiene-Effekt</span>
<div class="corr-row">
<span class="corr-label">Mit Hygiene (≥70%):</span>
<span class="corr-val good">{hygieneCorr.withHygiene}</span>
</div>
<div class="corr-row">
<span class="corr-label">Ohne:</span>
<span class="corr-val">{hygieneCorr.withoutHygiene}</span>
</div>
</div>
{/if}
<!-- Actions -->
<div class="actions-row">
<button class="action-btn" onclick={() => (showMorningLog = true)}> Schlaf loggen </button>
<button class="action-btn secondary" onclick={() => (showHygiene = true)}>
Hygiene-Check
</button>
</div>
</div>
{/if}
<style>
.sleep-view {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.5rem;
}
.section-label {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: hsl(var(--color-muted-foreground));
}
/* ── Log CTA ─────────────────────────────────── */
.log-cta {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
padding: 1rem;
border-radius: 0.75rem;
background: linear-gradient(135deg, hsl(245 60% 96%), hsl(260 50% 94%));
border: 1px solid hsl(245 40% 88%);
cursor: pointer;
transition: transform 0.15s;
color: hsl(var(--color-foreground));
}
:global(.dark) .log-cta {
background: linear-gradient(135deg, hsl(245 30% 14%), hsl(260 25% 16%));
border-color: hsl(245 30% 22%);
}
.log-cta:hover {
transform: scale(1.02);
}
.cta-icon {
font-size: 1.5rem;
}
.cta-text {
font-size: 0.875rem;
font-weight: 600;
}
.cta-sub {
font-size: 0.6875rem;
color: #6366f1;
font-weight: 500;
}
/* ── Last Night ──────────────────────────────── */
.last-night {
display: flex;
flex-direction: column;
gap: 0.375rem;
padding: 0.75rem;
border-radius: 0.75rem;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
}
.ln-header {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.ln-label {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: hsl(var(--color-muted-foreground));
}
.edit-btn {
font-size: 0.6875rem;
color: #6366f1;
background: none;
border: none;
cursor: pointer;
text-decoration: underline;
}
.ln-bar-container {
display: flex;
align-items: center;
gap: 0.5rem;
}
.ln-time {
font-size: 0.75rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: hsl(var(--color-foreground));
flex-shrink: 0;
}
.ln-bar {
flex: 1;
height: 8px;
border-radius: 4px;
background: hsl(var(--color-border));
overflow: hidden;
}
.ln-bar-fill {
height: 100%;
border-radius: 4px;
background: #6366f1;
transition: width 0.4s ease-out;
}
.ln-stats {
display: flex;
align-items: center;
gap: 0.75rem;
}
.ln-duration {
font-size: 1.125rem;
font-weight: 700;
color: hsl(var(--color-foreground));
}
.ln-quality {
display: flex;
gap: 0.0625rem;
}
.mini-star {
font-size: 0.75rem;
color: hsl(var(--color-border));
}
.mini-star.filled {
color: #f59e0b;
}
.ln-interruptions {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
}
.ln-goal {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
font-variant-numeric: tabular-nums;
}
.goal-pct {
font-weight: 600;
color: #6366f1;
margin-left: 0.25rem;
}
/* ── Week Chart ──────────────────────────────── */
.week-section {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.week-chart {
display: flex;
gap: 0.375rem;
height: 100px;
align-items: flex-end;
}
.week-col {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.125rem;
}
.bar-wrapper {
flex: 1;
width: 100%;
position: relative;
display: flex;
align-items: flex-end;
}
.bar {
width: 100%;
border-radius: 3px 3px 0 0;
min-height: 2px;
transition: height 0.3s ease;
}
.goal-line {
position: absolute;
left: -2px;
right: -2px;
height: 1px;
background: hsl(var(--color-muted-foreground));
opacity: 0.4;
}
.week-label {
font-size: 0.5625rem;
color: hsl(var(--color-muted-foreground));
}
.week-dur {
font-size: 0.5625rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: hsl(var(--color-foreground));
}
/* ── Stats Grid ──────────────────────────────── */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 0.375rem;
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.375rem;
border-radius: 0.5rem;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
}
.stat-val {
font-size: 0.875rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: hsl(var(--color-foreground));
}
.stat-val.debt {
color: #ef4444;
}
.stat-lbl {
font-size: 0.5rem;
color: hsl(var(--color-muted-foreground));
text-transform: uppercase;
letter-spacing: 0.05em;
text-align: center;
}
/* ── Heatmap ──────────────────────────────────── */
.heatmap-section {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.heatmap-grid {
display: grid;
grid-template-columns: repeat(10, 1fr);
gap: 3px;
}
.heat-cell {
aspect-ratio: 1;
border-radius: 3px;
background: hsl(var(--color-border));
}
/* ── Correlation ──────────────────────────────── */
.correlation-card {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.625rem 0.75rem;
border-radius: 0.5rem;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
}
.corr-row {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
}
.corr-label {
color: hsl(var(--color-muted-foreground));
}
.corr-val {
font-weight: 600;
color: hsl(var(--color-foreground));
}
.corr-val.good {
color: #22c55e;
}
/* ── Actions ──────────────────────────────────── */
.actions-row {
display: flex;
gap: 0.5rem;
}
.action-btn {
flex: 1;
padding: 0.625rem 0.75rem;
border-radius: 0.75rem;
background: #6366f1;
color: white;
border: none;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
transition: filter 0.15s;
}
.action-btn:hover {
filter: brightness(1.1);
}
.action-btn.secondary {
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
border: 1px solid hsl(var(--color-border));
}
.action-btn.secondary:hover {
background: hsl(var(--color-border));
filter: none;
}
</style>

View file

@ -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<LocalSleepEntry>('sleepEntries');
export const sleepHygieneLogTable = db.table<LocalSleepHygieneLog>('sleepHygieneLogs');
export const sleepHygieneCheckTable = db.table<LocalSleepHygieneCheck>('sleepHygieneChecks');
export const sleepSettingsTable = db.table<LocalSleepSettings>('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 (1618°C)',
description:
'Die ideale Schlaftemperatur liegt bei 1618°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[],
};

View file

@ -0,0 +1,294 @@
<!--
HygieneChecklist — Evening sleep hygiene check-off.
-->
<script lang="ts">
import type { SleepHygieneCheck } from '../types';
import { HYGIENE_CATEGORY_LABELS } from '../types';
import { sleepStore } from '../stores/sleep.svelte';
import { todayDateStr } from '../queries';
interface Props {
checks: SleepHygieneCheck[];
onComplete: () => void;
onCancel: () => void;
}
let { checks, onComplete, onCancel }: Props = $props();
let completedIds = $state<Set<string>>(new Set());
let showAddCheck = $state(false);
let newCheckName = $state('');
let activeChecks = $derived(checks.filter((c) => c.isActive));
let score = $derived(
activeChecks.length > 0 ? Math.round((completedIds.size / activeChecks.length) * 100) : 0
);
function toggleCheck(id: string) {
const next = new Set(completedIds);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
completedIds = next;
}
async function handleSave() {
await sleepStore.logHygiene({
date: todayDateStr(),
completedCheckIds: [...completedIds],
totalActiveChecks: activeChecks.length,
});
onComplete();
}
async function handleAddCheck() {
if (!newCheckName.trim()) return;
await sleepStore.createCheck({ name: newCheckName.trim() });
newCheckName = '';
showAddCheck = false;
}
</script>
<div class="hygiene-overlay">
<div class="hygiene-header">
<button class="close-btn" onclick={onCancel}>×</button>
<span class="header-title">Schlafhygiene-Check</span>
<span class="score-badge" class:good={score >= 70}>{score}%</span>
</div>
<div class="hygiene-body">
{#each activeChecks as check (check.id)}
<button class="check-item" onclick={() => toggleCheck(check.id)}>
<span class="check-box" class:checked={completedIds.has(check.id)}>
{completedIds.has(check.id) ? '✓' : ''}
</span>
<div class="check-text">
<span class="check-name">{check.name}</span>
<span class="check-cat"
>{HYGIENE_CATEGORY_LABELS[check.category]?.de ?? check.category}</span
>
</div>
</button>
{/each}
{#if showAddCheck}
<div class="add-form" role="group">
<!-- svelte-ignore a11y_autofocus -->
<input
onkeydown={(e) => {
if (e.key === 'Enter') handleAddCheck();
if (e.key === 'Escape') {
showAddCheck = false;
}
}}
class="add-input"
type="text"
placeholder="Neuer Check..."
bind:value={newCheckName}
autofocus
/>
<button class="add-save" onclick={handleAddCheck}>+</button>
</div>
{:else}
<button class="add-btn" onclick={() => (showAddCheck = true)}
>+ Eigenen Check hinzufügen</button
>
{/if}
<button class="save-btn" onclick={handleSave}>
Speichern ({completedIds.size}/{activeChecks.length})
</button>
</div>
</div>
<style>
.hygiene-overlay {
position: fixed;
inset: 0;
z-index: 100;
background: hsl(var(--color-background));
display: flex;
flex-direction: column;
overflow-y: auto;
}
.hygiene-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid hsl(var(--color-border));
}
.close-btn {
width: 2rem;
height: 2rem;
border-radius: 50%;
background: hsl(var(--color-muted));
border: none;
font-size: 1.125rem;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.header-title {
flex: 1;
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.score-badge {
padding: 0.25rem 0.5rem;
border-radius: 1rem;
font-size: 0.6875rem;
font-weight: 700;
background: hsl(var(--color-muted));
color: hsl(var(--color-muted-foreground));
}
.score-badge.good {
background: hsl(160 60% 92%);
color: #059669;
}
:global(.dark) .score-badge.good {
background: hsl(160 30% 15%);
}
.hygiene-body {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.check-item {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.625rem 0.75rem;
border-radius: 0.75rem;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
cursor: pointer;
text-align: left;
color: hsl(var(--color-foreground));
transition: transform 0.1s;
}
.check-item:active {
transform: scale(0.98);
}
.check-box {
width: 1.5rem;
height: 1.5rem;
border-radius: 0.375rem;
border: 2px solid hsl(var(--color-border));
display: flex;
align-items: center;
justify-content: center;
font-size: 0.875rem;
font-weight: 700;
flex-shrink: 0;
transition:
background 0.15s,
border-color 0.15s;
}
.check-box.checked {
background: #6366f1;
border-color: #6366f1;
color: white;
}
.check-text {
display: flex;
flex-direction: column;
gap: 0.0625rem;
}
.check-name {
font-size: 0.8125rem;
font-weight: 500;
}
.check-cat {
font-size: 0.5625rem;
color: hsl(var(--color-muted-foreground));
text-transform: uppercase;
letter-spacing: 0.05em;
}
.add-form {
display: flex;
gap: 0.375rem;
align-items: center;
}
.add-input {
flex: 1;
padding: 0.5rem 0.625rem;
border-radius: 0.5rem;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
}
.add-input:focus {
outline: none;
border-color: #6366f1;
}
.add-save {
width: 2rem;
height: 2rem;
border-radius: 50%;
background: #6366f1;
color: white;
border: none;
font-size: 1rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.add-btn {
padding: 0.5rem;
border-radius: 0.5rem;
border: 2px dashed hsl(var(--color-border));
background: transparent;
color: hsl(var(--color-muted-foreground));
font-size: 0.75rem;
cursor: pointer;
}
.add-btn:hover {
border-color: #6366f1;
color: #6366f1;
}
.save-btn {
margin-top: 0.5rem;
padding: 0.75rem;
border-radius: 0.75rem;
background: #6366f1;
color: white;
border: none;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
}
.save-btn:hover {
filter: brightness(1.1);
}
</style>

View file

@ -0,0 +1,412 @@
<!--
MorningLog — Quick entry form for last night's sleep.
Bedtime, wake time, quality stars, interruptions, dream link, notes.
-->
<script lang="ts">
import { sleepStore } from '../stores/sleep.svelte';
import { yesterdayDateStr, calcDurationMin, formatDuration } from '../queries';
import { QUALITY_LABELS, SLEEP_TAG_PRESETS } from '../types';
interface Props {
onComplete: () => void;
onCancel: () => void;
defaultBedtime?: string;
}
let { onComplete, onCancel, defaultBedtime }: Props = $props();
// Smart defaults
const yesterday = yesterdayDateStr();
const now = new Date();
const nowTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
let bedtimeTime = $state(defaultBedtime ?? '23:00');
let wakeTimeTime = $state(nowTime);
let quality = $state(0);
let interruptions = $state(0);
let notes = $state('');
let selectedTags = $state<string[]>([]);
// Build full ISO datetimes
let bedtimeISO = $derived(`${yesterday}T${bedtimeTime}:00`);
let wakeTimeISO = $derived(`${now.toISOString().split('T')[0]}T${wakeTimeTime}:00`);
let durationMin = $derived(calcDurationMin(bedtimeISO, wakeTimeISO));
function toggleTag(tag: string) {
if (selectedTags.includes(tag)) {
selectedTags = selectedTags.filter((t) => t !== tag);
} else {
selectedTags = [...selectedTags, tag];
}
}
async function handleSave() {
if (quality === 0) return;
await sleepStore.logSleep({
date: yesterday,
bedtime: bedtimeISO,
wakeTime: wakeTimeISO,
quality,
interruptions,
notes,
tags: selectedTags,
});
onComplete();
}
</script>
<div class="log-overlay">
<div class="log-header">
<button class="close-btn" onclick={onCancel}>×</button>
<span class="header-title">Wie hast du geschlafen?</span>
</div>
<div class="log-body">
<!-- Times -->
<div class="times-section">
<div class="time-field">
<label class="time-label" for="sleep-bedtime">Eingeschlafen</label>
<input id="sleep-bedtime" class="time-input" type="time" bind:value={bedtimeTime} />
<span class="time-hint">gestern</span>
</div>
<div class="duration-display">
{#if durationMin > 0}
{formatDuration(durationMin)}
{:else}
{/if}
</div>
<div class="time-field">
<label class="time-label" for="sleep-wake">Aufgewacht</label>
<input id="sleep-wake" class="time-input" type="time" bind:value={wakeTimeTime} />
<span class="time-hint">heute</span>
</div>
</div>
<!-- Quality -->
<div class="quality-section">
<span class="section-label">Schlafqualität</span>
<div class="stars-row">
{#each [1, 2, 3, 4, 5] as val}
<button
class="star-btn"
class:filled={quality >= val}
onclick={() => (quality = quality === val ? 0 : val)}
>
</button>
{/each}
</div>
{#if quality > 0}
<span class="quality-text">{QUALITY_LABELS[quality]?.de ?? ''}</span>
{/if}
</div>
<!-- Interruptions -->
<div class="interruptions-section">
<span class="section-label">Aufwacher in der Nacht</span>
<div class="counter-row">
<button
class="counter-btn"
onclick={() => (interruptions = Math.max(0, interruptions - 1))}
disabled={interruptions === 0}></button
>
<span class="counter-value">{interruptions}</span>
<button class="counter-btn" onclick={() => interruptions++}>+</button>
</div>
</div>
<!-- Tags -->
<div class="tags-section">
<span class="section-label">Tags (optional)</span>
<div class="tags-row">
{#each SLEEP_TAG_PRESETS as tag}
<button
class="tag-chip"
class:active={selectedTags.includes(tag)}
onclick={() => toggleTag(tag)}>{tag}</button
>
{/each}
</div>
</div>
<!-- Notes -->
<div class="notes-section">
<textarea class="notes-input" placeholder="Notizen (optional)..." bind:value={notes} rows="2"
></textarea>
</div>
<!-- Save -->
<button class="save-btn" onclick={handleSave} disabled={quality === 0}> Speichern </button>
</div>
</div>
<style>
.log-overlay {
position: fixed;
inset: 0;
z-index: 100;
background: hsl(var(--color-background));
display: flex;
flex-direction: column;
overflow-y: auto;
}
.log-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid hsl(var(--color-border));
}
.close-btn {
width: 2rem;
height: 2rem;
border-radius: 50%;
background: hsl(var(--color-muted));
border: none;
font-size: 1.125rem;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.header-title {
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.log-body {
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.section-label {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: hsl(var(--color-muted-foreground));
}
/* ── Times ────────────────────────────────────── */
.times-section {
display: flex;
align-items: center;
gap: 0.75rem;
}
.time-field {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
}
.time-label {
font-size: 0.6875rem;
font-weight: 600;
color: hsl(var(--color-muted-foreground));
}
.time-input {
width: 100%;
padding: 0.5rem;
border-radius: 0.5rem;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
color: hsl(var(--color-foreground));
font-size: 1.125rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
text-align: center;
}
.time-input:focus {
outline: none;
border-color: #6366f1;
}
.time-hint {
font-size: 0.5625rem;
color: hsl(var(--color-muted-foreground));
}
.duration-display {
font-size: 1rem;
font-weight: 700;
color: #6366f1;
white-space: nowrap;
min-width: 4rem;
text-align: center;
}
/* ── Quality Stars ────────────────────────────── */
.quality-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.375rem;
}
.stars-row {
display: flex;
gap: 0.5rem;
}
.star-btn {
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
background: hsl(var(--color-muted));
border: 2px solid transparent;
font-size: 1.25rem;
color: hsl(var(--color-border));
cursor: pointer;
transition:
transform 0.15s,
color 0.15s;
display: flex;
align-items: center;
justify-content: center;
}
.star-btn.filled {
color: #f59e0b;
}
.star-btn:hover {
transform: scale(1.15);
}
.quality-text {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
/* ── Interruptions ────────────────────────────── */
.interruptions-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.375rem;
}
.counter-row {
display: flex;
align-items: center;
gap: 0.75rem;
}
.counter-btn {
width: 2rem;
height: 2rem;
border-radius: 50%;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
font-size: 1rem;
font-weight: 600;
color: hsl(var(--color-foreground));
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.counter-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.counter-value {
font-size: 1.25rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: hsl(var(--color-foreground));
min-width: 1.5rem;
text-align: center;
}
/* ── Tags ─────────────────────────────────────── */
.tags-section {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.tags-row {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
}
.tag-chip {
padding: 0.25rem 0.5rem;
border-radius: 1rem;
font-size: 0.625rem;
font-weight: 500;
border: 1px solid hsl(var(--color-border));
background: transparent;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
transition:
background 0.15s,
color 0.15s;
}
.tag-chip.active {
background: #6366f1;
color: white;
border-color: #6366f1;
}
/* ── Notes ────────────────────────────────────── */
.notes-input {
width: 100%;
padding: 0.5rem 0.625rem;
border-radius: 0.5rem;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
resize: vertical;
}
.notes-input:focus {
outline: none;
border-color: #6366f1;
}
.notes-input::placeholder {
color: hsl(var(--color-muted-foreground));
}
/* ── Save ─────────────────────────────────────── */
.save-btn {
padding: 0.75rem;
border-radius: 0.75rem;
background: #6366f1;
color: white;
border: none;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: filter 0.15s;
}
.save-btn:hover:not(:disabled) {
filter: brightness(1.1);
}
.save-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>

View file

@ -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<SleepEntry[]>('sleepEntries');
export const sleepHygieneLogsCtx = createModuleContext<SleepHygieneLog[]>('sleepHygieneLogs');
export const sleepHygieneChecksCtx = createModuleContext<SleepHygieneCheck[]>('sleepHygieneChecks');
export const sleepSettingsCtx = createModuleContext<SleepSettings | null>('sleepSettings');

View file

@ -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';

View file

@ -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' },
],
};

View file

@ -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<LocalSleepEntry>('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<LocalSleepHygieneLog>('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<LocalSleepHygieneCheck>('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<LocalSleepSettings>('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 (MonSun). 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 0100.
* 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 (MonSun) 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,
};
}

View file

@ -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<LocalSleepEntry> = {
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<string, unknown> = { ...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<Pick<LocalSleepHygieneCheck, 'name' | 'description' | 'isActive' | 'order'>>
) {
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);
},
};

View file

@ -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 15. */
quality: number;
/** Woke up rested? 15 (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 0100 (% 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<HygieneCategory, { de: string; en: string }> = {
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<number, { de: string; en: string }> = {
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<LocalSleepSettings, keyof BaseRecord> = {
goalMin: 480,
targetBedtime: '23:00',
targetWakeTime: '07:00',
bedtimeReminderMin: 30,
morningReminderEnabled: true,
morningReminderTime: '08:00',
};

View file

@ -0,0 +1,19 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { setContext } from 'svelte';
import {
useAllSleepEntries,
useAllSleepHygieneLogs,
useAllSleepHygieneChecks,
useSleepSettings,
} from '$lib/modules/sleep/queries';
let { children }: { children: Snippet } = $props();
setContext('sleepEntries', useAllSleepEntries());
setContext('sleepHygieneLogs', useAllSleepHygieneLogs());
setContext('sleepHygieneChecks', useAllSleepHygieneChecks());
setContext('sleepSettings', useSleepSettings());
</script>
{@render children()}

View file

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

View file

@ -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)

View file

@ -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** — 15 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 (1618°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` (15) 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 15 */
quality: number;
/** Aufgewacht ausgeruht? 15 */
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 0100 (% 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 (1618°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 0100. 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

View file

@ -186,6 +186,11 @@ export const APP_ICONS = {
// Violet→indigo gradient for the mindfulness/calm theme.
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="md" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#8b5cf6"/><stop offset="100%" style="stop-color:#6366f1"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#md)"/><circle cx="50" cy="28" r="7" fill="white"/><path d="M50 36v14" stroke="white" stroke-width="4" stroke-linecap="round"/><path d="M36 44c4 2 9 3 14 3s10-1 14-3" stroke="white" stroke-width="3.5" stroke-linecap="round" fill="none"/><path d="M32 47l-6-5" stroke="white" stroke-width="3" stroke-linecap="round"/><path d="M68 47l6-5" stroke="white" stroke-width="3" stroke-linecap="round"/><path d="M38 56c-6 4-10 8-10 12 0 6 10 10 22 10s22-4 22-10c0-4-4-8-10-12" stroke="white" stroke-width="3.5" stroke-linecap="round" fill="none"/><path d="M42 56l-4 10h24l-4-10" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="white" fill-opacity="0.2"/><circle cx="50" cy="50" r="28" stroke="white" stroke-width="1.5" fill="none" opacity="0.2"/><circle cx="50" cy="50" r="36" stroke="white" stroke-width="1" fill="none" opacity="0.1"/></svg>`
),
sleep: svgToDataUrl(
// Moon with stars — represents sleep / night time.
// Indigo→purple gradient for the nighttime/rest theme.
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="sl" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#6366f1"/><stop offset="100%" style="stop-color:#7c3aed"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#sl)"/><path d="M62 24c-18 2-32 17-32 35 0 19 16 35 35 35 12 0 22-6 28-14-4 2-9 3-14 3-19 0-35-16-35-35 0-10 4-18 10-24z" fill="white" fill-opacity="0.9"/><circle cx="68" cy="28" r="2.5" fill="white" fill-opacity="0.7"/><circle cx="78" cy="38" r="1.5" fill="white" fill-opacity="0.5"/><circle cx="58" cy="18" r="1.5" fill="white" fill-opacity="0.5"/><circle cx="82" cy="24" r="2" fill="white" fill-opacity="0.6"/></svg>`
),
} as const;
export type AppIconId = keyof typeof APP_ICONS;

View file

@ -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',
},
];
/**