mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
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:
parent
66dd684bba
commit
1e992d3c92
21 changed files with 2737 additions and 2 deletions
|
|
@ -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') },
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: [] },
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
574
apps/mana/apps/web/src/lib/modules/sleep/ListView.svelte
Normal file
574
apps/mana/apps/web/src/lib/modules/sleep/ListView.svelte
Normal 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>
|
||||
127
apps/mana/apps/web/src/lib/modules/sleep/collections.ts
Normal file
127
apps/mana/apps/web/src/lib/modules/sleep/collections.ts
Normal 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 (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[],
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
11
apps/mana/apps/web/src/lib/modules/sleep/context.ts
Normal file
11
apps/mana/apps/web/src/lib/modules/sleep/context.ts
Normal 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');
|
||||
64
apps/mana/apps/web/src/lib/modules/sleep/index.ts
Normal file
64
apps/mana/apps/web/src/lib/modules/sleep/index.ts
Normal 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';
|
||||
11
apps/mana/apps/web/src/lib/modules/sleep/module.config.ts
Normal file
11
apps/mana/apps/web/src/lib/modules/sleep/module.config.ts
Normal 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' },
|
||||
],
|
||||
};
|
||||
352
apps/mana/apps/web/src/lib/modules/sleep/queries.ts
Normal file
352
apps/mana/apps/web/src/lib/modules/sleep/queries.ts
Normal 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 (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,
|
||||
};
|
||||
}
|
||||
245
apps/mana/apps/web/src/lib/modules/sleep/stores/sleep.svelte.ts
Normal file
245
apps/mana/apps/web/src/lib/modules/sleep/stores/sleep.svelte.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
183
apps/mana/apps/web/src/lib/modules/sleep/types.ts
Normal file
183
apps/mana/apps/web/src/lib/modules/sleep/types.ts
Normal 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 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<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',
|
||||
};
|
||||
19
apps/mana/apps/web/src/routes/(app)/sleep/+layout.svelte
Normal file
19
apps/mana/apps/web/src/routes/(app)/sleep/+layout.svelte
Normal 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()}
|
||||
9
apps/mana/apps/web/src/routes/(app)/sleep/+page.svelte
Normal file
9
apps/mana/apps/web/src/routes/(app)/sleep/+page.svelte
Normal 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 />
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
353
docs/modules/SLEEP_MODULE_PLAN.md
Normal file
353
docs/modules/SLEEP_MODULE_PLAN.md
Normal 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** — 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
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue