mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
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:
parent
6d7e4d0fb1
commit
d6d50e4d94
26 changed files with 1941 additions and 94 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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: [] },
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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/)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
35
apps/mana/apps/web/src/lib/modules/meditate/tools.ts
Normal file
35
apps/mana/apps/web/src/lib/modules/meditate/tools.ts
Normal 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`,
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
498
apps/mana/apps/web/src/lib/modules/mood/ListView.svelte
Normal file
498
apps/mana/apps/web/src/lib/modules/mood/ListView.svelte
Normal 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>
|
||||
14
apps/mana/apps/web/src/lib/modules/mood/collections.ts
Normal file
14
apps/mana/apps/web/src/lib/modules/mood/collections.ts
Normal 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[],
|
||||
};
|
||||
|
|
@ -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>
|
||||
5
apps/mana/apps/web/src/lib/modules/mood/context.ts
Normal file
5
apps/mana/apps/web/src/lib/modules/mood/context.ts
Normal 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');
|
||||
44
apps/mana/apps/web/src/lib/modules/mood/index.ts
Normal file
44
apps/mana/apps/web/src/lib/modules/mood/index.ts
Normal 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';
|
||||
6
apps/mana/apps/web/src/lib/modules/mood/module.config.ts
Normal file
6
apps/mana/apps/web/src/lib/modules/mood/module.config.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import type { ModuleConfig } from '$lib/data/module-registry';
|
||||
|
||||
export const moodModuleConfig: ModuleConfig = {
|
||||
appId: 'mood',
|
||||
tables: [{ name: 'moodEntries' }, { name: 'moodSettings' }],
|
||||
};
|
||||
281
apps/mana/apps/web/src/lib/modules/mood/queries.ts
Normal file
281
apps/mana/apps/web/src/lib/modules/mood/queries.ts
Normal 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 (6–12)', total: 0, count: 0 },
|
||||
afternoon: { label: 'Nachmittags (12–17)', total: 0, count: 0 },
|
||||
evening: { label: 'Abends (17–22)', total: 0, count: 0 },
|
||||
night: { label: 'Nachts (22–6)', 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 };
|
||||
}
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
160
apps/mana/apps/web/src/lib/modules/mood/types.ts
Normal file
160
apps/mana/apps/web/src/lib/modules/mood/types.ts
Normal 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 1–10 */
|
||||
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,
|
||||
};
|
||||
|
|
@ -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;
|
||||
},
|
||||
|
||||
|
|
|
|||
28
apps/mana/apps/web/src/lib/modules/sleep/tools.ts
Normal file
28
apps/mana/apps/web/src/lib/modules/sleep/tools.ts
Normal 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)`,
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
12
apps/mana/apps/web/src/routes/(app)/mood/+layout.svelte
Normal file
12
apps/mana/apps/web/src/routes/(app)/mood/+layout.svelte
Normal 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()}
|
||||
9
apps/mana/apps/web/src/routes/(app)/mood/+page.svelte
Normal file
9
apps/mana/apps/web/src/routes/(app)/mood/+page.svelte
Normal 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 />
|
||||
|
|
@ -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>`
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────
|
||||
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue