feat(brain): add meditate+sleep, parallelize DaySnapshot, deprecate _activity

Final optimization pass for the Companion Brain.

New modules (31 total):
- Meditate: MeditationCompleted event + log_meditation tool
- Sleep: SleepLogged event + log_sleep tool

Performance: DaySnapshot buildSnapshot() now runs all 6 Dexie
queries + 4 decryption passes in parallel via Promise.all instead
of sequentially. Estimated 3-5x speedup on first render.

Cleanup: trackActivity() in database.ts is now a no-op — the
_activity table is no longer written to. getRecentActivity() in
activity.ts delegates to queryEvents() from the Domain Event Store,
converting domain events to the legacy ActivityEntry shape.

Totals: 69 event types, 49 tools across 31 modules.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-13 23:37:33 +02:00
parent 6d7e4d0fb1
commit d6d50e4d94
26 changed files with 1941 additions and 94 deletions

View file

@ -56,6 +56,7 @@ import {
Pulse,
Robot,
Target,
Smiley,
} from '@mana/shared-icons';
// ── Apps with entity capabilities ───────────────────────────
@ -915,6 +916,16 @@ registerApp({
},
});
registerApp({
id: 'mood',
name: 'Mood',
color: '#f59e0b',
icon: Smiley,
views: {
list: { load: () => import('$lib/modules/mood/ListView.svelte') },
},
});
registerApp({
id: 'sleep',
name: 'Sleep',

View file

@ -1,22 +1,18 @@
/**
* Local activity log capped append-only feed of every write to a
* sync-tracked table.
* Local activity log legacy read API.
*
* Powers a future "What changed recently?" UI and a per-record history
* view without ever shipping these entries to the backend (the table is
* deliberately NOT in SYNC_APP_MAP). Each row is intentionally tiny no
* field diffs, no payloads so the disk footprint stays bounded even on
* power-user accounts.
* @deprecated The `_activity` table is no longer written to (replaced by
* the Domain Event Store in `data/events/`). This module now delegates
* `getRecentActivity()` to `queryEvents()` from the event store,
* converting the richer domain events back to the old ActivityEntry shape
* for backward compatibility with any remaining consumers.
*
* Population is automatic: the Dexie creating/updating hooks in
* `database.ts` call `recordActivity()` after every successful write.
* Soft deletes (`deletedAt` set on an update) are recorded as `op:
* 'delete'`. Server-applied changes (apply lock active for the table) are
* skipped so the feed reflects local user intent, not sync echo.
* New code should use `queryEvents()` directly.
*/
import { db } from './database';
import { getEffectiveUserId } from './current-user';
import { queryEvents } from './events/event-store';
export type ActivityOp = 'insert' | 'update' | 'delete';
@ -56,49 +52,42 @@ export interface ActivityQueryOptions {
}
/**
* Reads recent activity entries newest-first. The reverse-order walk
* over the indexed `createdAt` BTree short-circuits as soon as the
* limit is reached, so the cost is bounded by `limit` rather than the
* total log size.
* Reads recent activity entries newest-first.
*
* Delegates to the `_events` Domain Event Store and converts to the
* legacy ActivityEntry shape. The old `_activity` table is no longer
* written to.
*/
export async function getRecentActivity(
options: ActivityQueryOptions = {}
): Promise<ActivityEntry[]> {
const limit = Math.min(options.limit ?? 50, 500);
const userId = getEffectiveUserId();
// Single-record history takes the most-specific compound index.
if (options.collection && options.recordId) {
return db
.table<ActivityEntry>('_activity')
.where('[collection+recordId]')
.equals([options.collection, options.recordId])
.reverse()
.limit(limit)
.toArray();
}
const events = await queryEvents({
appId: options.appId,
limit,
});
// Per-app feed uses the [appId+createdAt] compound index.
if (options.appId) {
const collection = db
.table<ActivityEntry>('_activity')
.where('[appId+createdAt]')
.between([options.appId, ''], [options.appId, '\uffff'])
.reverse();
return collection
.filter((a) => a.userId === userId)
.limit(limit)
.toArray();
}
return events.map((e) => ({
createdAt: e.meta.timestamp,
appId: e.meta.appId,
collection: e.meta.collection,
recordId: e.meta.recordId,
op: eventTypeToOp(e.type),
userId: e.meta.userId,
}));
}
// Global feed: walk createdAt BTree backwards, filter to current user.
return db
.table<ActivityEntry>('_activity')
.orderBy('createdAt')
.reverse()
.filter((a) => a.userId === userId)
.limit(limit)
.toArray();
function eventTypeToOp(type: string): ActivityOp {
if (type.includes('Deleted') || type.includes('Undone')) return 'delete';
if (
type.includes('Created') ||
type.includes('Logged') ||
type.includes('Started') ||
type.includes('Added')
)
return 'insert';
return 'update';
}
// ─── Cleanup ─────────────────────────────────────────────────

View file

@ -459,6 +459,13 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
sleepHygieneLogs: { enabled: false, fields: [] },
sleepHygieneChecks: { enabled: true, fields: ['name', 'description'] },
sleepSettings: { enabled: false, fields: [] },
// ─── Mood ────────────────────────────────────────────────
// User-typed content: withWhom (free text about people) and notes are
// encrypted. Emotion/level/activity/tags stay plaintext for aggregation
// and pattern detection. Settings are structural only.
moodEntries: { enabled: true, fields: ['withWhom', 'notes'] },
moodSettings: { enabled: false, fields: [] },
};
/**

View file

@ -442,6 +442,18 @@ db.version(14).stores({
ritualLogs: '++id, ritualId, date, [ritualId+date]',
});
// Schema version 15 — adds the Mood module (multi-daily mood tracking with
// emotions, context, and pattern detection). Additive only.
//
// Index strategy:
// - moodEntries indexes date + emotion for the daily view and emotion
// distribution queries. [date+time] for chronological sort within a day.
// - moodSettings is a singleton (id-only).
db.version(15).stores({
moodEntries: 'id, date, emotion, level, activity, [date+time]',
moodSettings: 'id',
});
// Schema version 11 — adds the Mail module (local draft cache).
// Mail content lives server-side in Stalwart (JMAP). Only drafts are local-first.
db.version(11).stores({
@ -586,27 +598,20 @@ function trackPendingChange(table: string, change: Record<string, unknown>): voi
* for the real write, once for the activity row) would just spam the
* user via the quota toast.
*/
/**
* @deprecated Replaced by the Domain Event Store (`_events` table).
* Module stores now emit semantic events via `emitDomainEvent()`.
* This function is a no-op kept to avoid removing call sites in
* the hooks below. The `_activity` table is no longer written to;
* use `queryEvents()` from `data/events/event-store.ts` instead.
*/
function trackActivity(
appId: string,
collection: string,
recordId: string,
op: 'insert' | 'update' | 'delete'
_appId: string,
_collection: string,
_recordId: string,
_op: 'insert' | 'update' | 'delete'
): void {
const row = {
appId,
collection,
recordId,
op,
createdAt: new Date().toISOString(),
userId: getEffectiveUserId(),
};
setTimeout(() => {
db.table('_activity')
.add(row)
.catch(() => {
/* best-effort, see jsdoc */
});
}, 0);
// No-op: replaced by Domain Event Store (see data/events/)
}
/**

View file

@ -523,6 +523,26 @@ export interface QuestionAskedPayload {
}
export type QuestionsEventType = 'QuestionAsked';
// ── Meditate ────────────────────────────────────────
export interface MeditationCompletedPayload {
sessionId: string;
category: string;
durationMinutes: number;
completed: boolean;
}
export type MeditateEventType = 'MeditationCompleted';
// ── Sleep ───────────────────────────────────────────
export interface SleepLoggedPayload {
entryId: string;
date: string;
durationMin: number;
quality: number;
}
export type SleepEventType = 'SleepLogged';
// ── Body ────────────────────────────────────────────
export interface WorkoutStartedPayload {
@ -615,6 +635,8 @@ export type ManaEventType =
| NewsEventType
| RecipesEventType
| QuestionsEventType
| MeditateEventType
| SleepEventType
| SocialEventsEventType
| BodyEventType
| SystemEventType;
@ -716,6 +738,10 @@ export type ManaEvent =
| DomainEvent<'RecipeDeleted', RecipeDeletedPayload>
// Questions
| DomainEvent<'QuestionAsked', QuestionAskedPayload>
// Meditate
| DomainEvent<'MeditationCompleted', MeditationCompletedPayload>
// Sleep
| DomainEvent<'SleepLogged', SleepLoggedPayload>
// Social Events
| DomainEvent<'SocialEventCreated', SocialEventCreatedPayload>
| DomainEvent<'SocialEventDeleted', SocialEventDeletedPayload>

View file

@ -94,6 +94,7 @@ 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';
import { moodModuleConfig } from '$lib/modules/mood/module.config';
export const MODULE_CONFIGS: readonly ModuleConfig[] = [
manaCoreConfig,
@ -143,6 +144,7 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
mailModuleConfig,
meditateModuleConfig,
sleepModuleConfig,
moodModuleConfig,
];
// ─── Derived Maps ──────────────────────────────────────────

View file

@ -50,12 +50,39 @@ function emptySnapshot(date: string): DaySnapshot {
async function buildSnapshot(): Promise<DaySnapshot> {
const today = todayStr();
const now = new Date().toISOString();
const todayStart = `${today}T00:00:00`;
const todayEnd = `${today}T23:59:59`;
// ── Parallel queries — all 5 modules at once ────
const [allTasks, blocks, allDrinks, allMeals, nutriGoals, allPlaces] = await Promise.all([
db.table<LocalTask>('tasks').toArray(),
db
.table<LocalTimeBlock>('timeBlocks')
.where('startDate')
.between(todayStart, todayEnd + '\uffff')
.toArray(),
db.table<LocalDrinkEntry>('drinkEntries').toArray(),
db.table<LocalMeal>('meals').toArray(),
db.table<NutriGoal>('goals').toArray(),
db.table<LocalPlace>('places').toArray(),
]);
// ── Parallel decryption ─────────────────────────
const activeTasks = allTasks.filter((t) => !t.deletedAt);
const eventBlocks = blocks.filter(
(b) => !b.deletedAt && b.type === 'event' && b.sourceModule === 'calendar'
);
const todayDrinks = allDrinks.filter((d) => !d.deletedAt && d.date === today);
const todayMeals = allMeals.filter((m) => !m.deletedAt && m.date === today);
const [decryptedTasks, decryptedBlocks, decryptedDrinks, decryptedMeals] = await Promise.all([
decryptRecords<LocalTask>('tasks', activeTasks),
decryptRecords<LocalTimeBlock>('timeBlocks', eventBlocks),
decryptRecords<LocalDrinkEntry>('drinkEntries', todayDrinks),
decryptRecords<LocalMeal>('meals', todayMeals),
]);
// ── Tasks ───────────────────────────────────────
const allTasks = await db.table<LocalTask>('tasks').toArray();
const activeTasks = allTasks.filter((t) => !t.deletedAt);
const decryptedTasks = await decryptRecords<LocalTask>('tasks', activeTasks);
const completedCount = decryptedTasks.filter((t) => t.isCompleted).length;
const overdue = decryptedTasks.filter(
(t) => !t.isCompleted && t.dueDate != null && (t.dueDate as string) < today
@ -69,18 +96,6 @@ async function buildSnapshot(): Promise<DaySnapshot> {
}));
// ── Calendar Events ─────────────────────────────
const todayStart = `${today}T00:00:00`;
const todayEnd = `${today}T23:59:59`;
const blocks = await db
.table<LocalTimeBlock>('timeBlocks')
.where('startDate')
.between(todayStart, todayEnd + '\uffff')
.toArray();
const eventBlocks = blocks.filter(
(b) => !b.deletedAt && b.type === 'event' && b.sourceModule === 'calendar'
);
const decryptedBlocks = await decryptRecords<LocalTimeBlock>('timeBlocks', eventBlocks);
const eventSummaries: EventSummary[] = decryptedBlocks
.sort((a, b) => (a.startDate as string).localeCompare(b.startDate as string))
.map((b) => ({
@ -91,15 +106,10 @@ async function buildSnapshot(): Promise<DaySnapshot> {
isAllDay: b.allDay ?? false,
calendarId: '',
}));
const upcomingEvents = eventSummaries.filter((e) => e.startTime >= now).slice(0, 5);
const nextEvent = upcomingEvents[0] ?? null;
// ── Drinks ──────────────────────────────────────
const allDrinks = await db.table<LocalDrinkEntry>('drinkEntries').toArray();
const todayDrinks = allDrinks.filter((d) => !d.deletedAt && d.date === today);
const decryptedDrinks = await decryptRecords<LocalDrinkEntry>('drinkEntries', todayDrinks);
let waterMl = 0;
let coffeeMl = 0;
let coffeeCount = 0;
@ -117,10 +127,6 @@ async function buildSnapshot(): Promise<DaySnapshot> {
}
// ── Nutrition ───────────────────────────────────
const allMeals = await db.table<LocalMeal>('meals').toArray();
const todayMeals = allMeals.filter((m) => !m.deletedAt && m.date === today);
const decryptedMeals = await decryptRecords<LocalMeal>('meals', todayMeals);
let totalCalories = 0;
let totalProtein = 0;
for (const m of decryptedMeals) {
@ -130,14 +136,11 @@ async function buildSnapshot(): Promise<DaySnapshot> {
totalProtein += n.protein ?? 0;
}
}
const nutriGoals = await db.table<NutriGoal>('goals').toArray();
const activeGoal = nutriGoals.find((g) => !g.deletedAt);
const calorieGoal = activeGoal?.dailyCalories ?? DEFAULT_DAILY_VALUES.calories;
const proteinGoal = activeGoal?.dailyProtein;
// ── Places ──────────────────────────────────────
const allPlaces = await db.table<LocalPlace>('places').toArray();
const visitedToday = allPlaces.filter(
(p) => !p.deletedAt && p.lastVisitedAt && (p.lastVisitedAt as string).startsWith(today)
).length;

View file

@ -33,6 +33,7 @@ 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';
import { MOOD_GUEST_SEED } from '$lib/modules/mood/collections';
/**
* Flat list of { tableName, rows } entries. Only modules with non-empty
@ -70,6 +71,7 @@ register(RECIPES_GUEST_SEED);
register(STRETCH_GUEST_SEED);
register(MEDITATE_GUEST_SEED);
register(SLEEP_GUEST_SEED);
register(MOOD_GUEST_SEED);
/**
* Seed all module guest data into empty tables. Idempotent: tables

View file

@ -32,6 +32,8 @@ import { plantsTools } from '$lib/modules/plants/tools';
import { newsTools } from '$lib/modules/news/tools';
import { recipesTools } from '$lib/modules/recipes/tools';
import { questionsTools } from '$lib/modules/questions/tools';
import { meditateTools } from '$lib/modules/meditate/tools';
import { sleepTools } from '$lib/modules/sleep/tools';
let initialized = false;
@ -65,5 +67,7 @@ export function initTools(): void {
registerTools(newsTools);
registerTools(recipesTools);
registerTools(questionsTools);
registerTools(meditateTools);
registerTools(sleepTools);
initialized = true;
}

View file

@ -0,0 +1,165 @@
/**
* Meditate Store mutation-only service for the meditate module.
*
* All reads happen via liveQuery hooks in queries.ts. This file only writes:
* preset CRUD, session logging, and settings updates.
*/
import { encryptRecord } from '$lib/data/crypto';
import { emitDomainEvent } from '$lib/data/events';
import { meditatePresetTable, meditateSessionTable, meditateSettingsTable } from '../collections';
import { toMeditatePreset, toMeditateSession, toMeditateSettings } from '../queries';
import type {
LocalMeditatePreset,
LocalMeditateSession,
LocalMeditateSettings,
MeditateCategory,
BreathPattern,
BellSound,
BackgroundTheme,
} from '../types';
import { DEFAULT_SETTINGS } from '../types';
export const meditateStore = {
// ─── Presets ─────────────────────────────────────────────
async createPreset(input: {
name: string;
description?: string;
category: MeditateCategory;
breathPattern?: BreathPattern | null;
bodyScanSteps?: string[] | null;
defaultDurationSec?: number;
}) {
const existing = await meditatePresetTable.toArray();
const order = existing.filter((p) => !p.deletedAt).length;
const newLocal: LocalMeditatePreset = {
id: crypto.randomUUID(),
name: input.name,
description: input.description ?? '',
category: input.category,
breathPattern: input.breathPattern ?? null,
bodyScanSteps: input.bodyScanSteps ?? null,
defaultDurationSec: input.defaultDurationSec ?? 300,
isPreset: false,
isArchived: false,
order,
};
const snapshot = toMeditatePreset({ ...newLocal });
await encryptRecord('meditatePresets', newLocal);
await meditatePresetTable.add(newLocal);
return snapshot;
},
async updatePreset(
id: string,
patch: Partial<
Pick<
LocalMeditatePreset,
| 'name'
| 'description'
| 'category'
| 'breathPattern'
| 'bodyScanSteps'
| 'defaultDurationSec'
| 'isArchived'
| 'order'
>
>
) {
const wrapped = { ...patch } as Record<string, unknown>;
await encryptRecord('meditatePresets', wrapped);
await meditatePresetTable.update(id, {
...wrapped,
updatedAt: new Date().toISOString(),
});
},
async deletePreset(id: string) {
await meditatePresetTable.update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
},
// ─── Sessions ───────────────────────────────────────────
async logSession(input: {
presetId: string | null;
category: MeditateCategory;
startedAt: string;
durationSec: number;
completed: boolean;
moodBefore?: number | null;
moodAfter?: number | null;
notes?: string | null;
}) {
const newLocal: LocalMeditateSession = {
id: crypto.randomUUID(),
presetId: input.presetId,
category: input.category,
startedAt: input.startedAt,
durationSec: input.durationSec,
completed: input.completed,
moodBefore: input.moodBefore ?? null,
moodAfter: input.moodAfter ?? null,
notes: input.notes ?? null,
};
const snapshot = toMeditateSession({ ...newLocal });
await encryptRecord('meditateSessions', newLocal);
await meditateSessionTable.add(newLocal);
emitDomainEvent('MeditationCompleted', 'meditate', 'meditateSessions', newLocal.id, {
sessionId: newLocal.id,
category: input.category,
durationMinutes: Math.round(input.durationSec / 60),
completed: input.completed,
});
return snapshot;
},
async updateSession(
id: string,
patch: Partial<Pick<LocalMeditateSession, 'moodAfter' | 'notes'>>
) {
const wrapped = { ...patch } as Record<string, unknown>;
await encryptRecord('meditateSessions', wrapped);
await meditateSessionTable.update(id, {
...wrapped,
updatedAt: new Date().toISOString(),
});
},
async deleteSession(id: string) {
await meditateSessionTable.update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
},
// ─── Settings ───────────────────────────────────────────
async updateSettings(
patch: Partial<
Pick<
LocalMeditateSettings,
'bellSound' | 'intervalBell' | 'intervalSeconds' | 'showBreathGuide' | 'backgroundTheme'
>
>
) {
const existing = await meditateSettingsTable.get('settings');
if (existing) {
await meditateSettingsTable.update('settings', {
...patch,
updatedAt: new Date().toISOString(),
});
} else {
const newSettings: LocalMeditateSettings = {
id: 'settings',
...DEFAULT_SETTINGS,
...patch,
};
await meditateSettingsTable.add(newSettings);
}
},
};

View file

@ -0,0 +1,35 @@
import type { ModuleTool } from '$lib/data/tools/types';
import type { MeditateCategory } from './types';
export const meditateTools: ModuleTool[] = [
{
name: 'log_meditation',
module: 'meditate',
description: 'Loggt eine Meditations-Session (Stille, Atemuebung oder Body Scan)',
parameters: [
{ name: 'durationMinutes', type: 'number', description: 'Dauer in Minuten', required: true },
{
name: 'category',
type: 'string',
description: 'Art',
required: false,
enum: ['silence', 'breathing', 'bodyscan'],
},
],
async execute(params) {
const { meditateStore } = await import('./stores/meditate.svelte');
const session = await meditateStore.logSession({
presetId: null,
category: ((params.category as string) ?? 'silence') as MeditateCategory,
startedAt: new Date(Date.now() - (params.durationMinutes as number) * 60000).toISOString(),
durationSec: (params.durationMinutes as number) * 60,
completed: true,
});
return {
success: true,
data: session,
message: `${params.durationMinutes} min Meditation geloggt`,
};
},
},
];

View file

@ -0,0 +1,498 @@
<!--
Mood — ListView (Dashboard)
Today's check-ins, week trend, emotion distribution, patterns, insights.
-->
<script lang="ts">
import { getContext } from 'svelte';
import type { Observable } from 'dexie';
import type { MoodEntry, MoodSettings } from './types';
import {
getTodayEntries,
getAvgLevel,
getTopEmotion,
getEmotionDistribution,
getValenceRatio,
getActivityInsights,
getWeekdayPattern,
getWeekMoodData,
getCurrentStreak,
getEffectiveSettings,
} from './queries';
import { EMOTION_META, ACTIVITY_LABELS } from './types';
import QuickLog from './components/QuickLog.svelte';
const entries$: Observable<MoodEntry[]> = getContext('moodEntries');
const settings$: Observable<MoodSettings | null> = getContext('moodSettings');
let entries = $state<MoodEntry[]>([]);
let settingsRaw = $state<MoodSettings | null>(null);
$effect(() => { const sub = entries$.subscribe((v) => (entries = v)); return () => sub.unsubscribe(); });
$effect(() => { const sub = settings$.subscribe((v) => (settingsRaw = v)); return () => sub.unsubscribe(); });
let settings = $derived(getEffectiveSettings(settingsRaw));
let todayEntries = $derived(getTodayEntries(entries));
let avgLevel7 = $derived(getAvgLevel(entries, 7));
let avgLevel30 = $derived(getAvgLevel(entries, 30));
let topEmotion = $derived(getTopEmotion(entries, 30));
let distribution = $derived(getEmotionDistribution(entries.slice(0, 100)));
let valence = $derived(getValenceRatio(entries.slice(0, 100)));
let activityInsights = $derived(getActivityInsights(entries.slice(0, 100)));
let weekdayPattern = $derived(getWeekdayPattern(entries.slice(0, 200)));
let weekData = $derived(getWeekMoodData(entries));
let streak = $derived(getCurrentStreak(entries));
let showQuickLog = $state(false);
let showInsights = $state(false);
function levelColor(val: number): string {
if (val >= 8) return '#22c55e';
if (val >= 6) return '#84cc16';
if (val >= 4) return '#f59e0b';
if (val >= 2) return '#f97316';
return '#ef4444';
}
</script>
{#if showQuickLog}
<QuickLog
onComplete={() => (showQuickLog = false)}
onCancel={() => (showQuickLog = false)}
/>
{:else}
<div class="mood-view">
<!-- Log CTA -->
<button class="log-cta" onclick={() => (showQuickLog = true)}>
<span class="cta-emoji">
{#if topEmotion}
{EMOTION_META[topEmotion].emoji}
{:else}
😊
{/if}
</span>
<span class="cta-text">Wie geht es dir?</span>
<span class="cta-sub">
{todayEntries.length}/{settings.dailyTarget} Check-ins heute
</span>
</button>
<!-- Today's Entries -->
{#if todayEntries.length > 0}
<div class="today-section">
<span class="section-label">Heute</span>
<div class="today-entries">
{#each todayEntries as entry (entry.id)}
<div class="entry-pill">
<span class="ep-emoji">{EMOTION_META[entry.emotion]?.emoji ?? '😐'}</span>
<span class="ep-level" style:color={levelColor(entry.level)}>{entry.level}</span>
<span class="ep-time">{entry.time}</span>
{#if entry.activity}
<span class="ep-activity">{ACTIVITY_LABELS[entry.activity]?.emoji ?? ''}</span>
{/if}
</div>
{/each}
</div>
</div>
{/if}
<!-- Stats Row -->
<div class="stats-row">
<div class="stat">
<span class="stat-val" style:color={levelColor(avgLevel7)}>{avgLevel7 || '—'}</span>
<span class="stat-lbl">Ø 7 Tage</span>
</div>
<div class="stat">
<span class="stat-val" style:color={levelColor(avgLevel30)}>{avgLevel30 || '—'}</span>
<span class="stat-lbl">Ø 30 Tage</span>
</div>
<div class="stat">
<span class="stat-val">{streak}</span>
<span class="stat-lbl">Streak</span>
</div>
</div>
<!-- Week Mood Chart -->
{#if weekData.some((d) => d.avgLevel > 0)}
<div class="week-section">
<span class="section-label">Diese Woche</span>
<div class="week-chart">
{#each weekData as day}
<div class="week-col">
{#if day.avgLevel > 0}
<div class="week-dot" style:background={levelColor(day.avgLevel)} title="{String(day.avgLevel)}">
{day.avgLevel}
</div>
{:else}
<div class="week-dot empty"></div>
{/if}
<span class="week-label">{day.dayLabel}</span>
{#if day.count > 0}
<span class="week-count">{day.count}×</span>
{/if}
</div>
{/each}
</div>
</div>
{/if}
<!-- Valence Bar -->
{#if entries.length >= 5}
<div class="valence-section">
<span class="section-label">Stimmungsbilanz</span>
<div class="valence-bar">
<div class="v-pos" style:width="{valence.positive}%"></div>
<div class="v-neu" style:width="{valence.neutral}%"></div>
<div class="v-neg" style:width="{valence.negative}%"></div>
</div>
<div class="valence-labels">
<span class="vl-pos">{valence.positive}% positiv</span>
<span class="vl-neg">{valence.negative}% negativ</span>
</div>
</div>
{/if}
<!-- Top Emotions -->
{#if distribution.length > 0}
<div class="dist-section">
<span class="section-label">Häufigste Emotionen</span>
<div class="dist-list">
{#each distribution.slice(0, 5) as item}
<div class="dist-row">
<span class="dist-emoji">{EMOTION_META[item.emotion]?.emoji ?? '😐'}</span>
<span class="dist-name">{EMOTION_META[item.emotion]?.de ?? item.emotion}</span>
<div class="dist-bar-track">
<div
class="dist-bar-fill"
style:width="{item.pct}%"
style:background={EMOTION_META[item.emotion]?.color ?? '#6b7280'}
></div>
</div>
<span class="dist-pct">{item.pct}%</span>
</div>
{/each}
</div>
</div>
{/if}
<!-- Weekday Pattern -->
{#if weekdayPattern.some((d) => d.avgLevel > 0)}
<div class="pattern-section">
<span class="section-label">Wochentag-Muster</span>
<div class="pattern-row">
{#each weekdayPattern as day}
<div class="pattern-col">
<div
class="pattern-dot"
class:empty={day.avgLevel === 0}
style:background={day.avgLevel > 0 ? levelColor(day.avgLevel) : ''}
>
{day.avgLevel > 0 ? day.avgLevel : ''}
</div>
<span class="pattern-label">{day.label}</span>
</div>
{/each}
</div>
</div>
{/if}
<!-- Activity Insights -->
{#if activityInsights.length >= 2}
<div class="insights-section">
<span class="section-label">Aktivitäten & Stimmung</span>
{#each activityInsights.slice(0, 4) as insight}
<div class="insight-row">
<span class="ins-emoji">{ACTIVITY_LABELS[insight.activity]?.emoji ?? ''}</span>
<span class="ins-name">{ACTIVITY_LABELS[insight.activity]?.de ?? insight.activity}</span>
<span class="ins-val" style:color={levelColor(insight.avgLevel)}>Ø {insight.avgLevel}</span>
<span class="ins-count">({insight.count}×)</span>
</div>
{/each}
</div>
{/if}
</div>
{/if}
<style>
.mood-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(40 80% 96%), hsl(350 60% 96%));
border: 1px solid hsl(40 60% 88%);
cursor: pointer;
transition: transform 0.15s;
color: hsl(var(--color-foreground));
}
:global(.dark) .log-cta {
background: linear-gradient(135deg, hsl(40 30% 12%), hsl(350 25% 14%));
border-color: hsl(40 30% 20%);
}
.log-cta:hover { transform: scale(1.02); }
.cta-emoji { font-size: 1.75rem; }
.cta-text { font-size: 0.875rem; font-weight: 600; }
.cta-sub { font-size: 0.6875rem; color: #f59e0b; font-weight: 500; }
/* ── Today ────────────────────────────────────── */
.today-section {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.today-entries {
display: flex;
gap: 0.375rem;
flex-wrap: wrap;
}
.entry-pill {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border-radius: 1rem;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
font-size: 0.75rem;
}
.ep-emoji { font-size: 0.875rem; }
.ep-level { font-weight: 700; font-variant-numeric: tabular-nums; }
.ep-time { color: hsl(var(--color-muted-foreground)); font-variant-numeric: tabular-nums; }
.ep-activity { font-size: 0.75rem; }
/* ── Stats ────────────────────────────────────── */
.stats-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.375rem;
border-radius: 0.5rem;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
}
.stat-val {
font-size: 1.125rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.stat-lbl {
font-size: 0.5rem;
color: hsl(var(--color-muted-foreground));
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* ── Week Chart ──────────────────────────────── */
.week-section {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.week-chart {
display: flex;
gap: 0.375rem;
justify-content: space-between;
}
.week-col {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.125rem;
}
.week-dot {
width: 2rem;
height: 2rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.625rem;
font-weight: 700;
color: white;
}
.week-dot.empty {
background: hsl(var(--color-border));
}
.week-label {
font-size: 0.5625rem;
color: hsl(var(--color-muted-foreground));
}
.week-count {
font-size: 0.5rem;
color: hsl(var(--color-muted-foreground));
font-variant-numeric: tabular-nums;
}
/* ── Valence ──────────────────────────────────── */
.valence-section {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.valence-bar {
display: flex;
height: 8px;
border-radius: 4px;
overflow: hidden;
}
.v-pos { background: #22c55e; }
.v-neu { background: #9ca3af; }
.v-neg { background: #ef4444; }
.valence-labels {
display: flex;
justify-content: space-between;
font-size: 0.5625rem;
color: hsl(var(--color-muted-foreground));
}
.vl-pos { color: #22c55e; }
.vl-neg { color: #ef4444; }
/* ── Distribution ─────────────────────────────── */
.dist-section {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.dist-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.dist-row {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
}
.dist-emoji { font-size: 0.875rem; flex-shrink: 0; }
.dist-name { width: 5rem; flex-shrink: 0; color: hsl(var(--color-foreground)); }
.dist-bar-track {
flex: 1;
height: 6px;
border-radius: 3px;
background: hsl(var(--color-border));
overflow: hidden;
}
.dist-bar-fill {
height: 100%;
border-radius: 3px;
transition: width 0.3s;
}
.dist-pct {
width: 2rem;
text-align: right;
font-variant-numeric: tabular-nums;
color: hsl(var(--color-muted-foreground));
font-size: 0.6875rem;
}
/* ── Weekday Pattern ──────────────────────────── */
.pattern-section {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.pattern-row {
display: flex;
justify-content: space-between;
gap: 0.25rem;
}
.pattern-col {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.125rem;
}
.pattern-dot {
width: 1.75rem;
height: 1.75rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.5625rem;
font-weight: 700;
color: white;
}
.pattern-dot.empty {
background: hsl(var(--color-border));
}
.pattern-label {
font-size: 0.5625rem;
color: hsl(var(--color-muted-foreground));
}
/* ── Insights ─────────────────────────────────── */
.insights-section {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.insight-row {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
padding: 0.25rem 0;
}
.ins-emoji { font-size: 0.875rem; }
.ins-name { flex: 1; color: hsl(var(--color-foreground)); }
.ins-val { font-weight: 600; font-variant-numeric: tabular-nums; }
.ins-count { font-size: 0.625rem; color: hsl(var(--color-muted-foreground)); }
</style>

View file

@ -0,0 +1,14 @@
/**
* Mood module collection accessors. No seed data needed (entries are user-created).
*/
import { db } from '$lib/data/database';
import type { LocalMoodEntry, LocalMoodSettings } from './types';
export const moodEntryTable = db.table<LocalMoodEntry>('moodEntries');
export const moodSettingsTable = db.table<LocalMoodSettings>('moodSettings');
export const MOOD_GUEST_SEED = {
moodEntries: [] satisfies LocalMoodEntry[],
moodSettings: [] satisfies LocalMoodSettings[],
};

View file

@ -0,0 +1,438 @@
<!--
QuickLog — Fast mood check-in: level slider, emotion pick, optional context.
-->
<script lang="ts">
import { moodStore } from '../stores/mood.svelte';
import {
CORE_EMOTIONS,
EMOTION_META,
ACTIVITY_LABELS,
MOOD_TAG_PRESETS,
type CoreEmotion,
type ActivityContext,
} from '../types';
interface Props {
onComplete: () => void;
onCancel: () => void;
}
let { onComplete, onCancel }: Props = $props();
let level = $state(5);
let emotion = $state<CoreEmotion | null>(null);
let activity = $state<ActivityContext | null>(null);
let notes = $state('');
let selectedTags = $state<string[]>([]);
let showDetails = $state(false);
// Split emotions by valence for the picker layout
let positiveEmotions = $derived(CORE_EMOTIONS.filter((e) => EMOTION_META[e].valence === 'positive'));
let neutralEmotions = $derived(CORE_EMOTIONS.filter((e) => EMOTION_META[e].valence === 'neutral'));
let negativeEmotions = $derived(CORE_EMOTIONS.filter((e) => EMOTION_META[e].valence === 'negative'));
function toggleTag(tag: string) {
if (selectedTags.includes(tag)) {
selectedTags = selectedTags.filter((t) => t !== tag);
} else {
selectedTags = [...selectedTags, tag];
}
}
async function handleSave() {
if (!emotion) return;
await moodStore.logMood({
level,
emotion,
activity,
notes,
tags: selectedTags,
});
onComplete();
}
function levelColor(val: number): string {
if (val >= 8) return '#22c55e';
if (val >= 6) return '#84cc16';
if (val >= 4) return '#f59e0b';
if (val >= 2) return '#f97316';
return '#ef4444';
}
</script>
<div class="log-overlay">
<div class="log-header">
<button class="close-btn" onclick={onCancel}>×</button>
<span class="header-title">Wie geht es dir?</span>
</div>
<div class="log-body">
<!-- Level Slider -->
<div class="level-section">
<div class="level-display" style:color={levelColor(level)}>
{level}
</div>
<input
class="level-slider"
type="range"
min="1"
max="10"
bind:value={level}
style:accent-color={levelColor(level)}
/>
<div class="level-labels">
<span>Schlecht</span>
<span>Super</span>
</div>
</div>
<!-- Emotion Picker -->
<div class="emotion-section">
<span class="section-label">Was fühlst du?</span>
<div class="emotion-grid">
{#each positiveEmotions as e}
<button
class="emotion-btn"
class:selected={emotion === e}
onclick={() => (emotion = emotion === e ? null : e)}
>
<span class="emo-emoji">{EMOTION_META[e].emoji}</span>
<span class="emo-label">{EMOTION_META[e].de}</span>
</button>
{/each}
{#each neutralEmotions as e}
<button
class="emotion-btn"
class:selected={emotion === e}
onclick={() => (emotion = emotion === e ? null : e)}
>
<span class="emo-emoji">{EMOTION_META[e].emoji}</span>
<span class="emo-label">{EMOTION_META[e].de}</span>
</button>
{/each}
{#each negativeEmotions as e}
<button
class="emotion-btn"
class:selected={emotion === e}
onclick={() => (emotion = emotion === e ? null : e)}
>
<span class="emo-emoji">{EMOTION_META[e].emoji}</span>
<span class="emo-label">{EMOTION_META[e].de}</span>
</button>
{/each}
</div>
</div>
<!-- Details Toggle -->
{#if !showDetails}
<button class="details-toggle" onclick={() => (showDetails = true)}>
+ Details hinzufügen
</button>
{:else}
<!-- Activity -->
<div class="activity-section">
<span class="section-label">Was machst du gerade?</span>
<div class="activity-grid">
{#each Object.entries(ACTIVITY_LABELS) as [key, meta]}
<button
class="activity-btn"
class:selected={activity === key}
onclick={() => (activity = activity === key ? null : (key as ActivityContext))}
>
<span class="act-emoji">{meta.emoji}</span>
<span class="act-label">{meta.de}</span>
</button>
{/each}
</div>
</div>
<!-- Tags -->
<div class="tags-section">
<span class="section-label">Tags</span>
<div class="tags-row">
{#each MOOD_TAG_PRESETS as tag}
<button
class="tag-chip"
class:active={selectedTags.includes(tag)}
onclick={() => toggleTag(tag)}
>{tag}</button>
{/each}
</div>
</div>
<!-- Notes -->
<textarea
class="notes-input"
placeholder="Notizen (optional)..."
bind:value={notes}
rows="2"
></textarea>
{/if}
<!-- Save -->
<button class="save-btn" onclick={handleSave} disabled={!emotion}>
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: 1rem;
}
.section-label {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: hsl(var(--color-muted-foreground));
}
/* ── Level ────────────────────────────────────── */
.level-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.375rem;
}
.level-display {
font-size: 2.5rem;
font-weight: 800;
line-height: 1;
transition: color 0.2s;
}
.level-slider {
width: 100%;
max-width: 300px;
height: 8px;
}
.level-labels {
display: flex;
justify-content: space-between;
width: 100%;
max-width: 300px;
font-size: 0.5625rem;
color: hsl(var(--color-muted-foreground));
}
/* ── Emotions ─────────────────────────────────── */
.emotion-section {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.emotion-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(70px, 1fr));
gap: 0.375rem;
}
.emotion-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.125rem;
padding: 0.375rem 0.25rem;
border-radius: 0.5rem;
background: hsl(var(--color-muted));
border: 2px solid transparent;
cursor: pointer;
transition: transform 0.1s, border-color 0.15s;
color: hsl(var(--color-foreground));
}
.emotion-btn:hover {
transform: scale(1.05);
}
.emotion-btn.selected {
border-color: #f59e0b;
background: hsl(40 80% 96%);
}
:global(.dark) .emotion-btn.selected {
background: hsl(40 30% 12%);
}
.emo-emoji {
font-size: 1.25rem;
line-height: 1;
}
.emo-label {
font-size: 0.5625rem;
text-align: center;
line-height: 1.2;
}
/* ── Activity ─────────────────────────────────── */
.activity-section {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.activity-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(70px, 1fr));
gap: 0.25rem;
}
.activity-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.0625rem;
padding: 0.25rem;
border-radius: 0.375rem;
background: transparent;
border: 1px solid hsl(var(--color-border));
cursor: pointer;
font-size: 0.5625rem;
color: hsl(var(--color-muted-foreground));
transition: background 0.15s;
}
.activity-btn.selected {
background: hsl(var(--color-muted));
border-color: #f59e0b;
color: hsl(var(--color-foreground));
}
.act-emoji {
font-size: 1rem;
}
.act-label {
line-height: 1.2;
}
/* ── Tags & Notes ─────────────────────────────── */
.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;
}
.tag-chip.active {
background: #f59e0b;
color: white;
border-color: #f59e0b;
}
.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: #f59e0b;
}
.details-toggle {
text-align: center;
padding: 0.375rem;
border: none;
background: none;
color: hsl(var(--color-muted-foreground));
font-size: 0.75rem;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.details-toggle:hover {
color: hsl(var(--color-foreground));
}
/* ── Save ─────────────────────────────────────── */
.save-btn {
padding: 0.75rem;
border-radius: 0.75rem;
background: #f59e0b;
color: white;
border: none;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
}
.save-btn:hover:not(:disabled) {
filter: brightness(1.1);
}
.save-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>

View file

@ -0,0 +1,5 @@
import { createModuleContext } from '$lib/data/module-context';
import type { MoodEntry, MoodSettings } from './types';
export const moodEntriesCtx = createModuleContext<MoodEntry[]>('moodEntries');
export const moodSettingsCtx = createModuleContext<MoodSettings | null>('moodSettings');

View file

@ -0,0 +1,44 @@
/**
* Mood module barrel exports.
*/
export { moodStore } from './stores/mood.svelte';
export {
useAllMoodEntries,
useMoodSettings,
toMoodEntry,
toMoodSettings,
todayDateStr,
getEntriesForDate,
getTodayEntries,
getAvgLevelForDate,
getAvgLevel,
getTopEmotion,
getEmotionDistribution,
getValenceRatio,
getActivityInsights,
getWeekdayPattern,
getTimeOfDayPattern,
getWeekMoodData,
getCurrentStreak,
getEffectiveSettings,
} from './queries';
export { moodEntryTable, moodSettingsTable, MOOD_GUEST_SEED } from './collections';
export {
CORE_EMOTIONS,
EMOTION_META,
ACTIVITY_LABELS,
MOOD_TAG_PRESETS,
DEFAULT_MOOD_SETTINGS,
} from './types';
export type {
CoreEmotion,
ActivityContext,
LocalMoodEntry,
LocalMoodSettings,
MoodEntry,
MoodSettings,
} from './types';

View file

@ -0,0 +1,6 @@
import type { ModuleConfig } from '$lib/data/module-registry';
export const moodModuleConfig: ModuleConfig = {
appId: 'mood',
tables: [{ name: 'moodEntries' }, { name: 'moodSettings' }],
};

View file

@ -0,0 +1,281 @@
/**
* Reactive Queries & Pure Helpers for the Mood module.
*/
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { decryptRecords } from '$lib/data/crypto';
import { db } from '$lib/data/database';
import type {
LocalMoodEntry,
LocalMoodSettings,
MoodEntry,
MoodSettings,
CoreEmotion,
ActivityContext,
} from './types';
import { DEFAULT_MOOD_SETTINGS, EMOTION_META } from './types';
// ─── Type Converters ────────────────────────────────────────
export function toMoodEntry(local: LocalMoodEntry): MoodEntry {
return {
id: local.id,
date: local.date,
time: local.time,
level: local.level,
emotion: local.emotion,
secondaryEmotions: local.secondaryEmotions ?? [],
activity: local.activity ?? null,
withWhom: local.withWhom ?? '',
notes: local.notes ?? '',
tags: local.tags ?? [],
createdAt: local.createdAt ?? new Date().toISOString(),
};
}
export function toMoodSettings(local: LocalMoodSettings): MoodSettings {
return {
id: local.id,
dailyTarget: local.dailyTarget ?? DEFAULT_MOOD_SETTINGS.dailyTarget,
reminderTimes: local.reminderTimes ?? DEFAULT_MOOD_SETTINGS.reminderTimes,
remindersEnabled: local.remindersEnabled ?? DEFAULT_MOOD_SETTINGS.remindersEnabled,
};
}
// ─── Live Queries ───────────────────────────────────────────
export function useAllMoodEntries() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalMoodEntry>('moodEntries').toArray();
const visible = locals.filter((e) => !e.deletedAt);
const decrypted = await decryptRecords('moodEntries', visible);
return decrypted.map(toMoodEntry).sort((a, b) => {
const cmp = b.date.localeCompare(a.date);
return cmp !== 0 ? cmp : b.time.localeCompare(a.time);
});
}, [] as MoodEntry[]);
}
export function useMoodSettings() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalMoodSettings>('moodSettings').toArray();
const row = locals.find((s) => !s.deletedAt);
return row ? toMoodSettings(row) : null;
}, null as MoodSettings | null);
}
// ─── Pure Helpers ───────────────────────────────────────────
export function todayDateStr(): string {
return new Date().toISOString().split('T')[0];
}
/** Entries for a specific date. */
export function getEntriesForDate(entries: MoodEntry[], date: string): MoodEntry[] {
return entries.filter((e) => e.date === date);
}
/** Today's entries. */
export function getTodayEntries(entries: MoodEntry[]): MoodEntry[] {
return getEntriesForDate(entries, todayDateStr());
}
/** Average mood level for a date. */
export function getAvgLevelForDate(entries: MoodEntry[], date: string): number {
const dayEntries = getEntriesForDate(entries, date);
if (dayEntries.length === 0) return 0;
return +(dayEntries.reduce((sum, e) => sum + e.level, 0) / dayEntries.length).toFixed(1);
}
/** Average mood level over the last N days (not entries). */
export function getAvgLevel(entries: MoodEntry[], days: number): number {
const d = new Date();
let total = 0;
let count = 0;
for (let i = 0; i < days; i++) {
const dateStr = d.toISOString().split('T')[0];
const avg = getAvgLevelForDate(entries, dateStr);
if (avg > 0) {
total += avg;
count++;
}
d.setDate(d.getDate() - 1);
}
return count > 0 ? +(total / count).toFixed(1) : 0;
}
/** Most frequent emotion over the last N entries. */
export function getTopEmotion(entries: MoodEntry[], n: number): CoreEmotion | null {
const slice = entries.slice(0, n);
if (slice.length === 0) return null;
const counts = new Map<CoreEmotion, number>();
for (const e of slice) {
counts.set(e.emotion, (counts.get(e.emotion) ?? 0) + 1);
}
let best: CoreEmotion | null = null;
let bestCount = 0;
for (const [emotion, count] of counts) {
if (count > bestCount) {
best = emotion;
bestCount = count;
}
}
return best;
}
/** Emotion frequency distribution over entries. */
export function getEmotionDistribution(
entries: MoodEntry[]
): { emotion: CoreEmotion; count: number; pct: number }[] {
if (entries.length === 0) return [];
const counts = new Map<CoreEmotion, number>();
for (const e of entries) {
counts.set(e.emotion, (counts.get(e.emotion) ?? 0) + 1);
}
return [...counts.entries()]
.map(([emotion, count]) => ({
emotion,
count,
pct: Math.round((count / entries.length) * 100),
}))
.sort((a, b) => b.count - a.count);
}
/** Positive vs negative ratio. */
export function getValenceRatio(entries: MoodEntry[]): { positive: number; negative: number; neutral: number } {
let positive = 0;
let negative = 0;
let neutral = 0;
for (const e of entries) {
const v = EMOTION_META[e.emotion]?.valence;
if (v === 'positive') positive++;
else if (v === 'negative') negative++;
else neutral++;
}
const total = entries.length || 1;
return {
positive: Math.round((positive / total) * 100),
negative: Math.round((negative / total) * 100),
neutral: Math.round((neutral / total) * 100),
};
}
/** Activity that correlates with highest/lowest mood. */
export function getActivityInsights(
entries: MoodEntry[]
): { activity: ActivityContext; avgLevel: number; count: number }[] {
const map = new Map<ActivityContext, { total: number; count: number }>();
for (const e of entries) {
if (!e.activity) continue;
const existing = map.get(e.activity) ?? { total: 0, count: 0 };
existing.total += e.level;
existing.count++;
map.set(e.activity, existing);
}
return [...map.entries()]
.filter(([_, v]) => v.count >= 2)
.map(([activity, v]) => ({
activity,
avgLevel: +(v.total / v.count).toFixed(1),
count: v.count,
}))
.sort((a, b) => b.avgLevel - a.avgLevel);
}
/** Day-of-week patterns: average mood per weekday. */
export function getWeekdayPattern(
entries: MoodEntry[]
): { day: number; label: string; avgLevel: number }[] {
const labels = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
const buckets = Array.from({ length: 7 }, () => ({ total: 0, count: 0 }));
for (const e of entries) {
const dayIdx = new Date(e.date + 'T00:00').getDay();
buckets[dayIdx].total += e.level;
buckets[dayIdx].count++;
}
return buckets.map((b, i) => ({
day: i,
label: labels[i],
avgLevel: b.count > 0 ? +(b.total / b.count).toFixed(1) : 0,
}));
}
/** Time-of-day pattern: average mood per time bucket (morning/afternoon/evening/night). */
export function getTimeOfDayPattern(
entries: MoodEntry[]
): { period: string; label: string; avgLevel: number; count: number }[] {
const buckets: Record<string, { label: string; total: number; count: number }> = {
morning: { label: 'Morgens (612)', total: 0, count: 0 },
afternoon: { label: 'Nachmittags (1217)', total: 0, count: 0 },
evening: { label: 'Abends (1722)', total: 0, count: 0 },
night: { label: 'Nachts (226)', total: 0, count: 0 },
};
for (const e of entries) {
const hour = parseInt(e.time.split(':')[0], 10);
let period: string;
if (hour >= 6 && hour < 12) period = 'morning';
else if (hour >= 12 && hour < 17) period = 'afternoon';
else if (hour >= 17 && hour < 22) period = 'evening';
else period = 'night';
buckets[period].total += e.level;
buckets[period].count++;
}
return Object.entries(buckets).map(([period, b]) => ({
period,
label: b.label,
avgLevel: b.count > 0 ? +(b.total / b.count).toFixed(1) : 0,
count: b.count,
}));
}
/** Week data: average mood level per day for the last 7 days. */
export function getWeekMoodData(
entries: MoodEntry[]
): { date: string; dayLabel: string; avgLevel: number; count: number }[] {
const now = new Date();
const dayOfWeek = now.getDay();
const monday = new Date(now);
monday.setDate(now.getDate() - ((dayOfWeek + 6) % 7));
const dayLabels = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
const result: { date: string; dayLabel: string; avgLevel: number; count: number }[] = [];
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 dayEntries = getEntriesForDate(entries, dateStr);
const avgLevel =
dayEntries.length > 0
? +(dayEntries.reduce((sum, e) => sum + e.level, 0) / dayEntries.length).toFixed(1)
: 0;
result.push({ date: dateStr, dayLabel: dayLabels[i], avgLevel, count: dayEntries.length });
}
return result;
}
/** Current logging streak (consecutive days with at least one entry). */
export function getCurrentStreak(entries: MoodEntry[]): 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;
}
/** Effective settings. */
export function getEffectiveSettings(settings: MoodSettings | null): MoodSettings {
if (settings) return settings;
return { id: 'default', ...DEFAULT_MOOD_SETTINGS };
}

View file

@ -0,0 +1,73 @@
/**
* Mood Store mutation-only service.
*/
import { encryptRecord } from '$lib/data/crypto';
import { moodEntryTable, moodSettingsTable } from '../collections';
import { toMoodEntry } from '../queries';
import type { LocalMoodEntry, LocalMoodSettings, CoreEmotion, ActivityContext } from '../types';
import { DEFAULT_MOOD_SETTINGS } from '../types';
export const moodStore = {
async logMood(input: {
level: number;
emotion: CoreEmotion;
secondaryEmotions?: CoreEmotion[];
activity?: ActivityContext | null;
withWhom?: string;
notes?: string;
tags?: string[];
}) {
const now = new Date();
const newLocal: LocalMoodEntry = {
id: crypto.randomUUID(),
date: now.toISOString().split('T')[0],
time: `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`,
level: input.level,
emotion: input.emotion,
secondaryEmotions: input.secondaryEmotions ?? [],
activity: input.activity ?? null,
withWhom: input.withWhom ?? '',
notes: input.notes ?? '',
tags: input.tags ?? [],
};
const snapshot = toMoodEntry({ ...newLocal });
await encryptRecord('moodEntries', newLocal);
await moodEntryTable.add(newLocal);
return snapshot;
},
async updateEntry(
id: string,
patch: Partial<
Pick<
LocalMoodEntry,
'level' | 'emotion' | 'secondaryEmotions' | 'activity' | 'withWhom' | 'notes' | 'tags'
>
>
) {
const wrapped = await encryptRecord('moodEntries', { ...patch });
await moodEntryTable.update(id, {
...wrapped,
updatedAt: new Date().toISOString(),
});
},
async deleteEntry(id: string) {
await moodEntryTable.update(id, { deletedAt: new Date().toISOString() });
},
async updateSettings(patch: Partial<Pick<LocalMoodSettings, 'dailyTarget' | 'reminderTimes' | 'remindersEnabled'>>) {
const existing = (await moodSettingsTable.toArray()).find((s) => !s.deletedAt);
if (existing) {
await moodSettingsTable.update(existing.id, { ...patch, updatedAt: new Date().toISOString() });
return;
}
const newLocal: LocalMoodSettings = {
id: crypto.randomUUID(),
...DEFAULT_MOOD_SETTINGS,
...patch,
};
await moodSettingsTable.add(newLocal);
},
};

View file

@ -0,0 +1,160 @@
/**
* Mood module types multi-daily mood tracking with emotions, context, and patterns.
*
* Tables:
* moodEntries individual mood check-ins (multiple per day)
* moodSettings singleton preferences
*/
import type { BaseRecord } from '@mana/local-store';
// ─── Enums / unions ─────────────────────────────────────────
/**
* Core emotions loosely based on Plutchik's wheel, simplified for quick selection.
* 8 primary emotions, each with a valence (positive/negative/neutral).
*/
export type CoreEmotion =
| 'happy'
| 'calm'
| 'energized'
| 'grateful'
| 'sad'
| 'anxious'
| 'angry'
| 'tired'
| 'stressed'
| 'bored'
| 'excited'
| 'loved'
| 'frustrated'
| 'hopeful'
| 'overwhelmed'
| 'neutral';
/** What the user is doing when logging. */
export type ActivityContext =
| 'work'
| 'exercise'
| 'social'
| 'alone'
| 'commute'
| 'eating'
| 'resting'
| 'creative'
| 'outdoors'
| 'screen'
| 'chores'
| 'other';
// ─── Local Record Types (Dexie) ─────────────────────────────
export interface LocalMoodEntry extends BaseRecord {
/** YYYY-MM-DD */
date: string;
/** HH:mm of the check-in */
time: string;
/** Overall energy/mood level 110 */
level: number;
/** Primary emotion */
emotion: CoreEmotion;
/** Optional secondary emotions */
secondaryEmotions: CoreEmotion[];
/** What are you doing? */
activity: ActivityContext | null;
/** Who are you with? (free text) */
withWhom: string;
/** Free-text note */
notes: string;
/** Tags for quick categorization */
tags: string[];
}
export interface LocalMoodSettings extends BaseRecord {
/** How many check-ins per day are suggested (default: 3) */
dailyTarget: number;
/** Reminder times HH:mm[] */
reminderTimes: string[];
/** Whether reminders are active */
remindersEnabled: boolean;
}
// ─── Domain Types (UI-facing) ───────────────────────────────
export interface MoodEntry {
id: string;
date: string;
time: string;
level: number;
emotion: CoreEmotion;
secondaryEmotions: CoreEmotion[];
activity: ActivityContext | null;
withWhom: string;
notes: string;
tags: string[];
createdAt: string;
}
export interface MoodSettings {
id: string;
dailyTarget: number;
reminderTimes: string[];
remindersEnabled: boolean;
}
// ─── Constants ──────────────────────────────────────────────
export const EMOTION_META: Record<
CoreEmotion,
{ de: string; en: string; emoji: string; valence: 'positive' | 'negative' | 'neutral'; color: string }
> = {
happy: { de: 'Fröhlich', en: 'Happy', emoji: '😊', valence: 'positive', color: '#f59e0b' },
calm: { de: 'Ruhig', en: 'Calm', emoji: '😌', valence: 'positive', color: '#06b6d4' },
energized: { de: 'Energiegeladen', en: 'Energized', emoji: '⚡', valence: 'positive', color: '#f97316' },
grateful: { de: 'Dankbar', en: 'Grateful', emoji: '🙏', valence: 'positive', color: '#ec4899' },
excited: { de: 'Aufgeregt', en: 'Excited', emoji: '🤩', valence: 'positive', color: '#ef4444' },
loved: { de: 'Geliebt', en: 'Loved', emoji: '🥰', valence: 'positive', color: '#f43f5e' },
hopeful: { de: 'Hoffnungsvoll', en: 'Hopeful', emoji: '🌱', valence: 'positive', color: '#22c55e' },
neutral: { de: 'Neutral', en: 'Neutral', emoji: '😐', valence: 'neutral', color: '#6b7280' },
bored: { de: 'Gelangweilt', en: 'Bored', emoji: '😑', valence: 'neutral', color: '#9ca3af' },
tired: { de: 'Müde', en: 'Tired', emoji: '😴', valence: 'negative', color: '#8b5cf6' },
sad: { de: 'Traurig', en: 'Sad', emoji: '😢', valence: 'negative', color: '#3b82f6' },
anxious: { de: 'Ängstlich', en: 'Anxious', emoji: '😰', valence: 'negative', color: '#a855f7' },
angry: { de: 'Wütend', en: 'Angry', emoji: '😡', valence: 'negative', color: '#dc2626' },
stressed: { de: 'Gestresst', en: 'Stressed', emoji: '😤', valence: 'negative', color: '#ea580c' },
frustrated: { de: 'Frustriert', en: 'Frustrated', emoji: '😣', valence: 'negative', color: '#b91c1c' },
overwhelmed: { de: 'Überfordert', en: 'Overwhelmed', emoji: '🤯', valence: 'negative', color: '#7c3aed' },
};
export const CORE_EMOTIONS: readonly CoreEmotion[] = [
'happy', 'calm', 'energized', 'grateful', 'excited', 'loved', 'hopeful',
'neutral', 'bored',
'tired', 'sad', 'anxious', 'angry', 'stressed', 'frustrated', 'overwhelmed',
] as const;
export const ACTIVITY_LABELS: Record<ActivityContext, { de: string; en: string; emoji: string }> = {
work: { de: 'Arbeit', en: 'Work', emoji: '💼' },
exercise: { de: 'Sport', en: 'Exercise', emoji: '🏃' },
social: { de: 'Sozial', en: 'Social', emoji: '👥' },
alone: { de: 'Allein', en: 'Alone', emoji: '🧘' },
commute: { de: 'Unterwegs', en: 'Commute', emoji: '🚶' },
eating: { de: 'Essen', en: 'Eating', emoji: '🍽️' },
resting: { de: 'Ruhen', en: 'Resting', emoji: '🛋️' },
creative: { de: 'Kreativ', en: 'Creative', emoji: '🎨' },
outdoors: { de: 'Draußen', en: 'Outdoors', emoji: '🌳' },
screen: { de: 'Bildschirm', en: 'Screen', emoji: '📱' },
chores: { de: 'Haushalt', en: 'Chores', emoji: '🧹' },
other: { de: 'Sonstiges', en: 'Other', emoji: '📌' },
};
export const MOOD_TAG_PRESETS = [
'Kaffee', 'Sport', 'Meditation', 'Schlecht geschlafen', 'Gut geschlafen',
'Natur', 'Musik', 'Streit', 'Erfolg', 'Deadline', 'Wochenende',
'Regen', 'Sonne', 'Kopfschmerzen', 'Periode',
] as const;
export const DEFAULT_MOOD_SETTINGS: Omit<LocalMoodSettings, keyof BaseRecord> = {
dailyTarget: 3,
reminderTimes: ['09:00', '14:00', '20:00'],
remindersEnabled: false,
};

View file

@ -5,6 +5,7 @@
*/
import { encryptRecord } from '$lib/data/crypto';
import { emitDomainEvent } from '$lib/data/events';
import {
sleepEntryTable,
sleepHygieneLogTable,
@ -84,6 +85,12 @@ export const sleepStore = {
const snapshot = toSleepEntry({ ...newLocal });
await encryptRecord('sleepEntries', newLocal);
await sleepEntryTable.add(newLocal);
emitDomainEvent('SleepLogged', 'sleep', 'sleepEntries', newLocal.id, {
entryId: newLocal.id,
date: input.date,
durationMin: durationMin,
quality: input.quality,
});
return snapshot;
},

View file

@ -0,0 +1,28 @@
import type { ModuleTool } from '$lib/data/tools/types';
export const sleepTools: ModuleTool[] = [
{
name: 'log_sleep',
module: 'sleep',
description: 'Loggt Schlaf (Schlafenszeit, Aufwachzeit, Qualitaet 1-5)',
parameters: [
{ name: 'bedtime', type: 'string', description: 'Schlafenszeit (HH:mm)', required: true },
{ name: 'wakeTime', type: 'string', description: 'Aufwachzeit (HH:mm)', required: true },
{ name: 'quality', type: 'number', description: 'Qualitaet 1-5', required: true },
],
async execute(params) {
const { sleepStore } = await import('./stores/sleep.svelte');
const today = new Date().toISOString().split('T')[0];
const entry = await sleepStore.logSleep({
date: today,
bedtime: params.bedtime as string,
wakeTime: params.wakeTime as string,
quality: params.quality as number,
});
return {
success: true,
data: entry,
message: `Schlaf geloggt: ${params.bedtime}${params.wakeTime} (Qualitaet: ${params.quality}/5)`,
};
},
},
];

View file

@ -0,0 +1,12 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { setContext } from 'svelte';
import { useAllMoodEntries, useMoodSettings } from '$lib/modules/mood/queries';
let { children }: { children: Snippet } = $props();
setContext('moodEntries', useAllMoodEntries());
setContext('moodSettings', useMoodSettings());
</script>
{@render children()}

View file

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

View file

@ -191,6 +191,11 @@ export const APP_ICONS = {
// 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>`
),
mood: svgToDataUrl(
// Smiley face — represents mood/emotion tracking.
// Warm amber→rose gradient for the emotional/feelings theme.
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="mo" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#f59e0b"/><stop offset="100%" style="stop-color:#f43f5e"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#mo)"/><circle cx="50" cy="50" r="28" fill="white" fill-opacity="0.9"/><circle cx="40" cy="44" r="3.5" fill="#f59e0b"/><circle cx="60" cy="44" r="3.5" fill="#f59e0b"/><path d="M38 58c3 5 7 7 12 7s9-2 12-7" stroke="#f59e0b" stroke-width="3" stroke-linecap="round" fill="none"/></svg>`
),
// ── Companion Brain ─────────────────────────────────
myday: svgToDataUrl(
`<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:#F59E0B"/><stop offset="100%" style="stop-color:#F97316"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#md)"/><circle cx="50" cy="44" r="16" fill="white" fill-opacity="0.9"/><line x1="50" y1="20" x2="50" y2="26" stroke="white" stroke-width="3" stroke-linecap="round" opacity="0.7"/><line x1="50" y1="62" x2="50" y2="68" stroke="white" stroke-width="3" stroke-linecap="round" opacity="0.7"/><line x1="26" y1="44" x2="32" y2="44" stroke="white" stroke-width="3" stroke-linecap="round" opacity="0.7"/><line x1="68" y1="44" x2="74" y2="44" stroke="white" stroke-width="3" stroke-linecap="round" opacity="0.7"/><rect x="24" y="74" width="52" height="4" rx="2" fill="white" fill-opacity="0.5"/><rect x="30" y="82" width="40" height="3" rx="1.5" fill="white" fill-opacity="0.3"/></svg>`

View file

@ -859,6 +859,24 @@ export const MANA_APPS: ManaApp[] = [
requiredTier: 'guest',
},
{
id: 'mood',
name: 'Mood',
description: {
de: 'Stimmungs-Tracking',
en: 'Mood Tracking',
},
longDescription: {
de: 'Tracke deine Stimmung mehrmals am Tag mit Emotionen, Kontext und Aktivität. Erkenne Muster: Wochentage, Tageszeiten, Aktivitäten — und wie sie deine Laune beeinflussen.',
en: 'Track your mood multiple times a day with emotions, context, and activity. Discover patterns: weekdays, times of day, activities — and how they affect your mood.',
},
icon: APP_ICONS.mood,
color: '#f59e0b',
comingSoon: false,
status: 'development',
requiredTier: 'guest',
},
// ── Companion Brain ─────────────────────────────────
{