From e927c1f10f6ed19da129107e34dfe77b271fedc2 Mon Sep 17 00:00:00 2001 From: Till JS Date: Mon, 13 Apr 2026 20:25:46 +0200 Subject: [PATCH] feat(brain): add Domain Event Bus and emit events from 5 pilot modules Phase 1 of the Companion Brain architecture. Introduces a typed, synchronous event bus with microtask-scheduled handlers, an append-only event store persisted to IndexedDB (_events table, v10 schema), and semantic domain events emitted from module stores. Pilot modules with emit() calls: - Todo: TaskCreated, TaskCompleted, TaskUncompleted, TaskDeleted, SubtasksUpdated - Calendar: CalendarEventCreated, CalendarEventUpdated, CalendarEventDeleted - Drink: DrinkLogged, DrinkEntryDeleted, DrinkEntryUndone - Nutriphi: MealLogged, MealFromPhotoLogged, MealDeleted - Places: PlaceCreated, PlaceDeleted, PlaceVisited, LocationLogged, TrackingStarted, TrackingStopped Also includes the full architecture plan at docs/architecture/COMPANION_BRAIN_ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mana/apps/web/src/lib/data/database.ts | 30 + .../apps/web/src/lib/data/events/catalog.ts | 296 ++++ .../mana/apps/web/src/lib/data/events/emit.ts | 44 + .../apps/web/src/lib/data/events/event-bus.ts | 69 + .../web/src/lib/data/events/event-store.ts | 156 ++ .../apps/web/src/lib/data/events/index.ts | 4 + .../apps/web/src/lib/data/events/types.ts | 48 + .../modules/calendar/stores/events.svelte.ts | 20 + .../lib/modules/drink/stores/drink.svelte.ts | 24 +- .../web/src/lib/modules/nutriphi/mutations.ts | 22 + .../modules/places/stores/places.svelte.ts | 19 + .../modules/places/stores/tracking.svelte.ts | 15 + .../lib/modules/todo/stores/tasks.svelte.ts | 39 +- .../COMPANION_BRAIN_ARCHITECTURE.md | 1333 +++++++++++++++++ 14 files changed, 2116 insertions(+), 3 deletions(-) create mode 100644 apps/mana/apps/web/src/lib/data/events/catalog.ts create mode 100644 apps/mana/apps/web/src/lib/data/events/emit.ts create mode 100644 apps/mana/apps/web/src/lib/data/events/event-bus.ts create mode 100644 apps/mana/apps/web/src/lib/data/events/event-store.ts create mode 100644 apps/mana/apps/web/src/lib/data/events/index.ts create mode 100644 apps/mana/apps/web/src/lib/data/events/types.ts create mode 100644 docs/architecture/COMPANION_BRAIN_ARCHITECTURE.md diff --git a/apps/mana/apps/web/src/lib/data/database.ts b/apps/mana/apps/web/src/lib/data/database.ts index b5c9f31be..9480315c4 100644 --- a/apps/mana/apps/web/src/lib/data/database.ts +++ b/apps/mana/apps/web/src/lib/data/database.ts @@ -398,6 +398,36 @@ db.version(8).stores({ recipes: 'id, difficulty, isFavorite, *tags', }); +// Schema version 9 — adds the Stretch module (guided stretching routines +// with mobility assessments, session tracking, and reminders). +// Additive only; no prior tables touched. +// +// Index strategy: +// - stretchExercises indexes bodyRegion + difficulty for the exercise picker +// filter strip, isPreset to separate seeds from custom. +// - stretchRoutines indexes routineType for the type-based filter tabs, +// order for the user's custom sort. +// - stretchSessions indexes startedAt for the history timeline view +// (range scan descending). +// - stretchAssessments indexes assessedAt for the trend chart. +// - stretchReminders indexes isActive so the reminder engine can quickly +// find enabled reminders without scanning the full table. +db.version(9).stores({ + stretchExercises: 'id, bodyRegion, difficulty, isPreset, isArchived, order', + stretchRoutines: 'id, routineType, order, isPinned, isPreset', + stretchSessions: 'id, routineId, startedAt, [startedAt]', + stretchAssessments: 'id, assessedAt', + stretchReminders: 'id, isActive', +}); + +// v10 — Domain Event Store for the Companion Brain. +// Append-only log of semantic events emitted by module stores. +// NOT synced (local intelligence only). Replaces _activity long-term. +db.version(10).stores({ + _events: + '++seq, type, meta.appId, meta.timestamp, meta.recordId, [meta.appId+meta.timestamp], [type+meta.timestamp]', +}); + // ─── Sync Routing ────────────────────────────────────────── // SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE, // toSyncName() and fromSyncName() are now derived from per-module diff --git a/apps/mana/apps/web/src/lib/data/events/catalog.ts b/apps/mana/apps/web/src/lib/data/events/catalog.ts new file mode 100644 index 000000000..c719fabfb --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/events/catalog.ts @@ -0,0 +1,296 @@ +/** + * Domain Event Catalog — Typed event definitions for all modules. + * + * Each module section defines payload interfaces and a string-literal + * union of event types. The top-level ManaEvent union covers every + * possible event so the EventStore and Projection Engine can work + * with full type safety. + * + * Pilot modules: Todo, Calendar, Drink, Nutriphi, Places. + */ + +import type { DomainEvent } from './types'; + +// ── Todo ──────────────────────────────────────────── + +export interface TaskCreatedPayload { + taskId: string; + title: string; + dueDate?: string; + priority?: number; + projectId?: string; + labelIds?: string[]; +} + +export interface TaskCompletedPayload { + taskId: string; + title: string; + projectId?: string; + wasOverdue: boolean; +} + +export interface TaskUncompletedPayload { + taskId: string; + title: string; +} + +export interface TaskUpdatedPayload { + taskId: string; + fields: string[]; +} + +export interface TaskDeletedPayload { + taskId: string; + title: string; +} + +export interface TaskRescheduledPayload { + taskId: string; + title: string; + oldDate?: string; + newDate: string; +} + +export interface SubtasksUpdatedPayload { + taskId: string; + total: number; + completed: number; +} + +export interface ReminderSetPayload { + taskId: string; + reminderId: string; + minutesBefore: number; + type?: string; +} + +export interface ReminderDeletedPayload { + taskId: string; + reminderId: string; +} + +export type TodoEventType = + | 'TaskCreated' + | 'TaskCompleted' + | 'TaskUncompleted' + | 'TaskUpdated' + | 'TaskDeleted' + | 'TaskRescheduled' + | 'SubtasksUpdated' + | 'ReminderSet' + | 'ReminderDeleted'; + +// ── Calendar ──────────────────────────────────────── + +export interface CalendarEventCreatedPayload { + eventId: string; + title: string; + startTime: string; + endTime: string; + isAllDay: boolean; + isRecurring: boolean; + calendarId: string; + location?: string; +} + +export interface CalendarEventUpdatedPayload { + eventId: string; + fields: string[]; +} + +export interface CalendarEventDeletedPayload { + eventId: string; + title: string; + wasRecurring: boolean; +} + +export interface CalendarEventMovedPayload { + eventId: string; + title: string; + oldStart: string; + newStart: string; +} + +export type CalendarEventType = + | 'CalendarEventCreated' + | 'CalendarEventUpdated' + | 'CalendarEventDeleted' + | 'CalendarEventMoved'; + +// ── Drink ─────────────────────────────────────────── + +export interface DrinkLoggedPayload { + entryId: string; + drinkType: string; + quantityMl: number; + name: string; + date: string; + time: string; + fromPreset: boolean; +} + +export interface DrinkEntryDeletedPayload { + entryId: string; + drinkType: string; + quantityMl: number; +} + +export interface DrinkEntryUndonePayload { + entryId: string; +} + +export type DrinkEventType = 'DrinkLogged' | 'DrinkEntryDeleted' | 'DrinkEntryUndone'; + +// ── Nutriphi ──────────────────────────────────────── + +export interface MealLoggedPayload { + mealId: string; + mealType: string; + inputType: string; + description: string; + calories?: number; + protein?: number; + date: string; +} + +export interface MealFromPhotoLoggedPayload { + mealId: string; + mealType: string; + photoMediaId: string; + confidence: number; + calories?: number; +} + +export interface MealDeletedPayload { + mealId: string; + mealType: string; +} + +export interface NutritionGoalSetPayload { + goalId: string; + dailyCalories: number; + dailyProtein?: number; + dailyCarbs?: number; + dailyFat?: number; +} + +export type NutriphiEventType = + | 'MealLogged' + | 'MealFromPhotoLogged' + | 'MealDeleted' + | 'NutritionGoalSet'; + +// ── Places ────────────────────────────────────────── + +export interface PlaceCreatedPayload { + placeId: string; + name: string; + category?: string; + lat: number; + lng: number; +} + +export interface PlaceDeletedPayload { + placeId: string; + name: string; +} + +export interface PlaceVisitedPayload { + placeId: string; + name: string; + visitCount: number; +} + +export interface LocationLoggedPayload { + logId: string; + lat: number; + lng: number; + placeId?: string; + accuracy?: number; +} + +export interface TrackingStartedPayload { + timestamp: string; +} + +export interface TrackingStoppedPayload { + durationMs: number; + logCount: number; +} + +export type PlacesEventType = + | 'PlaceCreated' + | 'PlaceDeleted' + | 'PlaceVisited' + | 'LocationLogged' + | 'TrackingStarted' + | 'TrackingStopped'; + +// ── System Events (Goals, Companion) ──────────────── + +export interface GoalReachedPayload { + goalId: string; + title: string; + value: number; + target: number; + period: string; +} + +export interface GoalProgressPayload { + goalId: string; + title: string; + value: number; + target: number; +} + +export type SystemEventType = 'GoalReached' | 'GoalProgress'; + +// ── Union of all event types ──────────────────────── + +export type ManaEventType = + | TodoEventType + | CalendarEventType + | DrinkEventType + | NutriphiEventType + | PlacesEventType + | SystemEventType; + +/** + * Discriminated union of all domain events. + * Use this for the EventStore subscriber and Projection handlers. + */ +export type ManaEvent = + // Todo + | DomainEvent<'TaskCreated', TaskCreatedPayload> + | DomainEvent<'TaskCompleted', TaskCompletedPayload> + | DomainEvent<'TaskUncompleted', TaskUncompletedPayload> + | DomainEvent<'TaskUpdated', TaskUpdatedPayload> + | DomainEvent<'TaskDeleted', TaskDeletedPayload> + | DomainEvent<'TaskRescheduled', TaskRescheduledPayload> + | DomainEvent<'SubtasksUpdated', SubtasksUpdatedPayload> + | DomainEvent<'ReminderSet', ReminderSetPayload> + | DomainEvent<'ReminderDeleted', ReminderDeletedPayload> + // Calendar + | DomainEvent<'CalendarEventCreated', CalendarEventCreatedPayload> + | DomainEvent<'CalendarEventUpdated', CalendarEventUpdatedPayload> + | DomainEvent<'CalendarEventDeleted', CalendarEventDeletedPayload> + | DomainEvent<'CalendarEventMoved', CalendarEventMovedPayload> + // Drink + | DomainEvent<'DrinkLogged', DrinkLoggedPayload> + | DomainEvent<'DrinkEntryDeleted', DrinkEntryDeletedPayload> + | DomainEvent<'DrinkEntryUndone', DrinkEntryUndonePayload> + // Nutriphi + | DomainEvent<'MealLogged', MealLoggedPayload> + | DomainEvent<'MealFromPhotoLogged', MealFromPhotoLoggedPayload> + | DomainEvent<'MealDeleted', MealDeletedPayload> + | DomainEvent<'NutritionGoalSet', NutritionGoalSetPayload> + // Places + | DomainEvent<'PlaceCreated', PlaceCreatedPayload> + | DomainEvent<'PlaceDeleted', PlaceDeletedPayload> + | DomainEvent<'PlaceVisited', PlaceVisitedPayload> + | DomainEvent<'LocationLogged', LocationLoggedPayload> + | DomainEvent<'TrackingStarted', TrackingStartedPayload> + | DomainEvent<'TrackingStopped', TrackingStoppedPayload> + // System + | DomainEvent<'GoalReached', GoalReachedPayload> + | DomainEvent<'GoalProgress', GoalProgressPayload>; diff --git a/apps/mana/apps/web/src/lib/data/events/emit.ts b/apps/mana/apps/web/src/lib/data/events/emit.ts new file mode 100644 index 000000000..d87516a13 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/events/emit.ts @@ -0,0 +1,44 @@ +/** + * Convenience helper for emitting domain events from module stores. + * + * Builds the EventMeta automatically so stores only need to specify + * the event type, routing info, and payload. + */ + +import { eventBus } from './event-bus'; +import { getEffectiveUserId } from '../current-user'; +import type { DomainEvent } from './types'; + +/** + * Emit a domain event on the shared bus. + * + * @example + * ```ts + * emitDomainEvent('TaskCompleted', 'todo', 'tasks', id, { + * taskId: id, title: task.title, wasOverdue: true, + * }); + * ``` + */ +export function emitDomainEvent

( + type: string, + appId: string, + collection: string, + recordId: string, + payload: P, + causedBy?: string +): void { + const event: DomainEvent = { + type, + payload, + meta: { + id: crypto.randomUUID(), + timestamp: new Date().toISOString(), + appId, + collection, + recordId, + userId: getEffectiveUserId(), + causedBy, + }, + }; + eventBus.emit(event); +} diff --git a/apps/mana/apps/web/src/lib/data/events/event-bus.ts b/apps/mana/apps/web/src/lib/data/events/event-bus.ts new file mode 100644 index 000000000..5170b9ffb --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/events/event-bus.ts @@ -0,0 +1,69 @@ +/** + * EventBus — Synchronous in-process event dispatcher. + * + * Handlers are invoked via queueMicrotask so they run after the current + * Dexie transaction commits but before the next frame — no setTimeout + * delay, no transaction interference. + * + * A re-entrancy guard prevents infinite loops when a handler emits + * another event of the same type. + */ + +import type { DomainEvent, EventBus, EventHandler } from './types'; + +export function createEventBus(): EventBus { + const handlers = new Map>(); + const anyHandlers = new Set(); + const emitting = new Set(); + + return { + emit(event: DomainEvent) { + queueMicrotask(() => { + if (emitting.has(event.type)) { + console.warn(`[event-bus] re-entrant emit blocked: ${event.type}`); + return; + } + emitting.add(event.type); + try { + const typeHandlers = handlers.get(event.type); + if (typeHandlers) { + for (const h of typeHandlers) { + try { + h(event); + } catch (err) { + console.error(`[event-bus] handler error for ${event.type}:`, err); + } + } + } + for (const h of anyHandlers) { + try { + h(event); + } catch (err) { + console.error(`[event-bus] anyHandler error for ${event.type}:`, err); + } + } + } finally { + emitting.delete(event.type); + } + }); + }, + + on(type, handler) { + if (!handlers.has(type)) handlers.set(type, new Set()); + handlers.get(type)!.add(handler); + return () => handlers.get(type)?.delete(handler); + }, + + onAny(handler) { + anyHandlers.add(handler); + return () => anyHandlers.delete(handler); + }, + + off(type, handler) { + handlers.get(type)?.delete(handler); + }, + }; +} + +/** Singleton bus shared across the entire app. */ +export const eventBus: EventBus = createEventBus(); diff --git a/apps/mana/apps/web/src/lib/data/events/event-store.ts b/apps/mana/apps/web/src/lib/data/events/event-store.ts new file mode 100644 index 000000000..a6d94f5ed --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/events/event-store.ts @@ -0,0 +1,156 @@ +/** + * Event Store — Persists all domain events to the _events Dexie table. + * + * Subscribes to eventBus.onAny() and writes each event as an append-only + * row. Provides query helpers for Projections and the Correlation Engine. + * + * Retention: 90 days / 50,000 events max (whichever is reached first). + */ + +import { db } from '../database'; +import { eventBus } from './event-bus'; +import type { DomainEvent } from './types'; + +const EVENTS_TABLE = '_events'; +const MAX_EVENTS = 50_000; +const TTL_MS = 90 * 24 * 60 * 60 * 1000; // 90 days + +let unsubscribe: (() => void) | null = null; + +/** Start persisting all domain events. Call once at app init. */ +export function startEventStore(): void { + if (unsubscribe) return; + unsubscribe = eventBus.onAny((event: DomainEvent) => { + db.table(EVENTS_TABLE) + .add({ + type: event.type, + payload: event.payload, + meta: event.meta, + }) + .catch((err: unknown) => { + console.error('[event-store] failed to persist event:', err); + }); + }); +} + +/** Stop persisting events (for cleanup / tests). */ +export function stopEventStore(): void { + unsubscribe?.(); + unsubscribe = null; +} + +// ── Queries ───────────────────────────────────────── + +export interface EventQuery { + appId?: string; + type?: string; + since?: string; // ISO timestamp + until?: string; // ISO timestamp + limit?: number; // default 100 +} + +interface StoredEvent { + seq?: number; + type: string; + payload: unknown; + meta: { + id: string; + timestamp: string; + appId: string; + collection: string; + recordId: string; + userId: string; + causedBy?: string; + }; +} + +/** Query persisted events. Most recent first. */ +export async function queryEvents(query: EventQuery = {}): Promise { + const limit = Math.min(query.limit ?? 100, 1000); + let collection; + + if (query.appId && query.type) { + // Use compound index for appId, filter type in JS + collection = db + .table(EVENTS_TABLE) + .where('[meta.appId+meta.timestamp]') + .between([query.appId, query.since ?? ''], [query.appId, query.until ?? '\uffff']); + } else if (query.appId) { + collection = db + .table(EVENTS_TABLE) + .where('[meta.appId+meta.timestamp]') + .between([query.appId, query.since ?? ''], [query.appId, query.until ?? '\uffff']); + } else if (query.type) { + collection = db + .table(EVENTS_TABLE) + .where('[type+meta.timestamp]') + .between([query.type, query.since ?? ''], [query.type, query.until ?? '\uffff']); + } else { + collection = db.table(EVENTS_TABLE).orderBy('seq'); + } + + let results: StoredEvent[] = await collection.reverse().limit(limit).toArray(); + + // Apply additional filters not covered by the index + if (query.type && query.appId) { + results = results.filter((e) => e.type === query.type); + } + if (query.since && !query.appId && !query.type) { + results = results.filter((e) => e.meta.timestamp >= query.since!); + } + if (query.until && !query.appId && !query.type) { + results = results.filter((e) => e.meta.timestamp <= query.until!); + } + + return results.map((row) => ({ + type: row.type, + payload: row.payload, + meta: row.meta, + })); +} + +/** Count events by type within a date range. */ +export async function countEventsByType( + since: string, + until: string +): Promise> { + const rows: StoredEvent[] = await db + .table(EVENTS_TABLE) + .where('meta.timestamp') + .between(since, until) + .toArray(); + + const counts: Record = {}; + for (const row of rows) { + counts[row.type] = (counts[row.type] ?? 0) + 1; + } + return counts; +} + +// ── Pruning ───────────────────────────────────────── + +/** Remove events older than TTL or exceeding max count. */ +export async function pruneEventStore(): Promise { + const cutoff = new Date(Date.now() - TTL_MS).toISOString(); + let deleted = 0; + + // Delete by age + const old = await db.table(EVENTS_TABLE).where('meta.timestamp').below(cutoff).primaryKeys(); + if (old.length > 0) { + await db.table(EVENTS_TABLE).bulkDelete(old); + deleted += old.length; + } + + // Delete overflow (keep newest MAX_EVENTS) + const total = await db.table(EVENTS_TABLE).count(); + if (total > MAX_EVENTS) { + const overflow = total - MAX_EVENTS; + const oldest = await db.table(EVENTS_TABLE).orderBy('seq').limit(overflow).primaryKeys(); + if (oldest.length > 0) { + await db.table(EVENTS_TABLE).bulkDelete(oldest); + deleted += oldest.length; + } + } + + return deleted; +} diff --git a/apps/mana/apps/web/src/lib/data/events/index.ts b/apps/mana/apps/web/src/lib/data/events/index.ts new file mode 100644 index 000000000..3687db1f0 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/events/index.ts @@ -0,0 +1,4 @@ +export { eventBus, createEventBus } from './event-bus'; +export { emitDomainEvent } from './emit'; +export type { DomainEvent, EventMeta, EventBus, EventHandler } from './types'; +export type * from './catalog'; diff --git a/apps/mana/apps/web/src/lib/data/events/types.ts b/apps/mana/apps/web/src/lib/data/events/types.ts new file mode 100644 index 000000000..0abb305b3 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/events/types.ts @@ -0,0 +1,48 @@ +/** + * Domain Event types for the Mana Companion Brain. + * + * Every module mutation emits a typed DomainEvent via the EventBus. + * Events carry semantic meaning ("TaskCompleted") rather than raw CRUD + * operations ("tasks table updated"), enabling Projections, Rules, and + * the LLM Context Builder to work without reverse-engineering field diffs. + */ + +// ── Core Event Shape ──────────────────────────────── + +export interface DomainEvent { + readonly type: T; + readonly payload: P; + readonly meta: EventMeta; +} + +export interface EventMeta { + /** Unique event ID */ + readonly id: string; + /** ISO timestamp */ + readonly timestamp: string; + /** Source module (e.g. 'todo', 'drink') */ + readonly appId: string; + /** Source Dexie table */ + readonly collection: string; + /** Affected record ID */ + readonly recordId: string; + /** User who triggered this */ + readonly userId: string; + /** Parent event ID (for trigger chains / cascades) */ + readonly causedBy?: string; +} + +// ── Bus Interface ─────────────────────────────────── + +export type EventHandler = (event: E) => void; + +export interface EventBus { + /** Emit a domain event. Handlers run asynchronously via queueMicrotask. */ + emit(event: DomainEvent): void; + /** Subscribe to a specific event type. Returns unsubscribe function. */ + on(type: T, handler: EventHandler): () => void; + /** Subscribe to all events. Returns unsubscribe function. */ + onAny(handler: EventHandler): () => void; + /** Unsubscribe a handler from a specific event type. */ + off(type: string, handler: EventHandler): void; +} diff --git a/apps/mana/apps/web/src/lib/modules/calendar/stores/events.svelte.ts b/apps/mana/apps/web/src/lib/modules/calendar/stores/events.svelte.ts index a0bb49534..8c7ae3224 100644 --- a/apps/mana/apps/web/src/lib/modules/calendar/stores/events.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/calendar/stores/events.svelte.ts @@ -10,6 +10,7 @@ import { db } from '$lib/data/database'; import { encryptRecord } from '$lib/data/crypto'; +import { emitDomainEvent } from '$lib/data/events'; import { createBlock, updateBlock, deleteBlock } from '$lib/data/time-blocks/service'; import { timeBlockTable } from '$lib/data/time-blocks/collections'; import { @@ -85,6 +86,16 @@ export const eventsStore = { // reads go through queries.ts which decrypts on the way out. await encryptRecord('events', newLocal); await db.table('events').add(newLocal); + emitDomainEvent('CalendarEventCreated', 'calendar', 'events', eventId, { + eventId, + title: input.title, + startTime: input.startTime, + endTime: input.endTime, + isAllDay: input.isAllDay ?? false, + isRecurring: !!input.recurrenceRule, + calendarId: input.calendarId, + location: input.location, + }); CalendarEvents.eventCreated(!!input.recurrenceRule); return { success: true, data: { id: eventId, timeBlockId } }; } catch (e) { @@ -142,6 +153,10 @@ export const eventsStore = { await encryptRecord('events', localData); await db.table('events').update(id, localData); + emitDomainEvent('CalendarEventUpdated', 'calendar', 'events', id, { + eventId: id, + fields: Object.keys(input).filter((k) => input[k as keyof typeof input] !== undefined), + }); CalendarEvents.eventUpdated(); return { success: true }; } catch (e) { @@ -342,6 +357,11 @@ export const eventsStore = { deletedAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); + emitDomainEvent('CalendarEventDeleted', 'calendar', 'events', id, { + eventId: id, + title: event?.title ?? '', + wasRecurring: false, + }); CalendarEvents.eventDeleted(); return { success: true }; } catch (e) { diff --git a/apps/mana/apps/web/src/lib/modules/drink/stores/drink.svelte.ts b/apps/mana/apps/web/src/lib/modules/drink/stores/drink.svelte.ts index 0cab7cfdc..0072b7704 100644 --- a/apps/mana/apps/web/src/lib/modules/drink/stores/drink.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/drink/stores/drink.svelte.ts @@ -5,6 +5,7 @@ */ import { encryptRecord } from '$lib/data/crypto'; +import { emitDomainEvent } from '$lib/data/events'; import { drinkEntryTable, drinkPresetTable } from '../collections'; import { toDrinkEntry, toDrinkPreset, todayStr, nowTime } from '../queries'; import type { LocalDrinkEntry, LocalDrinkPreset, DrinkType } from '../types'; @@ -34,6 +35,15 @@ export const drinkStore = { const snapshot = toDrinkEntry({ ...newLocal }); await encryptRecord('drinkEntries', newLocal); await drinkEntryTable.add(newLocal); + emitDomainEvent('DrinkLogged', 'drink', 'drinkEntries', newLocal.id, { + entryId: newLocal.id, + drinkType: input.drinkType, + quantityMl: input.quantityMl, + name: input.name, + date: newLocal.date, + time: newLocal.time, + fromPreset: !!input.presetId, + }); return snapshot; }, @@ -64,10 +74,18 @@ export const drinkStore = { }, async deleteEntry(id: string) { + const entry = await drinkEntryTable.get(id); await drinkEntryTable.update(id, { deletedAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); + if (entry) { + emitDomainEvent('DrinkEntryDeleted', 'drink', 'drinkEntries', id, { + entryId: id, + drinkType: entry.drinkType, + quantityMl: entry.quantityMl, + }); + } }, async undoLastEntry() { @@ -76,10 +94,14 @@ export const drinkStore = { .filter((e) => !e.deletedAt) .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? '')); if (active.length > 0) { - await drinkEntryTable.update(active[0].id, { + const entry = active[0]; + await drinkEntryTable.update(entry.id, { deletedAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); + emitDomainEvent('DrinkEntryUndone', 'drink', 'drinkEntries', entry.id, { + entryId: entry.id, + }); } }, diff --git a/apps/mana/apps/web/src/lib/modules/nutriphi/mutations.ts b/apps/mana/apps/web/src/lib/modules/nutriphi/mutations.ts index 32ce1adda..1d1605cfb 100644 --- a/apps/mana/apps/web/src/lib/modules/nutriphi/mutations.ts +++ b/apps/mana/apps/web/src/lib/modules/nutriphi/mutations.ts @@ -14,6 +14,7 @@ import { db } from '$lib/data/database'; import { encryptRecord, decryptRecord } from '$lib/data/crypto'; +import { emitDomainEvent } from '$lib/data/events'; import { uploadMealPhoto, analyzeMealPhoto, @@ -74,6 +75,15 @@ export const mealMutations = { const encrypted: Record = { ...row }; await encryptRecord('meals', encrypted); await db.table('meals').add(encrypted); + emitDomainEvent('MealLogged', 'nutriphi', 'meals', row.id, { + mealId: row.id, + mealType: dto.mealType, + inputType: 'text', + description: dto.description, + calories: dto.nutrition?.calories, + protein: dto.nutrition?.protein, + date: row.date, + }); return row; }, @@ -99,6 +109,13 @@ export const mealMutations = { const encrypted: Record = { ...row }; await encryptRecord('meals', encrypted); await db.table('meals').add(encrypted); + emitDomainEvent('MealFromPhotoLogged', 'nutriphi', 'meals', row.id, { + mealId: row.id, + mealType: dto.mealType, + photoMediaId: dto.photoMediaId, + confidence: dto.confidence, + calories: dto.nutrition?.calories, + }); return row; }, @@ -131,8 +148,13 @@ export const mealMutations = { }, async delete(id: string): Promise { + const existing = await db.table('meals').get(id); const now = new Date().toISOString(); await db.table('meals').update(id, { deletedAt: now, updatedAt: now }); + emitDomainEvent('MealDeleted', 'nutriphi', 'meals', id, { + mealId: id, + mealType: existing?.mealType ?? '', + }); }, }; diff --git a/apps/mana/apps/web/src/lib/modules/places/stores/places.svelte.ts b/apps/mana/apps/web/src/lib/modules/places/stores/places.svelte.ts index 677b21a26..7af8af449 100644 --- a/apps/mana/apps/web/src/lib/modules/places/stores/places.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/places/stores/places.svelte.ts @@ -6,6 +6,7 @@ */ import { encryptRecord, decryptRecord } from '$lib/data/crypto'; +import { emitDomainEvent } from '$lib/data/events'; import { createBlock } from '$lib/data/time-blocks/service'; import { placeTable } from '../collections'; import { toPlace } from '../queries'; @@ -41,6 +42,13 @@ export const placesStore = { const plaintextSnapshot = toPlace({ ...newLocal }); await encryptRecord('places', newLocal); await placeTable.add(newLocal); + emitDomainEvent('PlaceCreated', 'places', 'places', newLocal.id, { + placeId: newLocal.id, + name: data.name, + category: data.category, + lat: data.latitude, + lng: data.longitude, + }); return plaintextSnapshot; }, @@ -67,10 +75,16 @@ export const placesStore = { }, async deletePlace(id: string) { + const local = await placeTable.get(id); + const decrypted = local ? await decryptRecord('places', { ...local }) : null; await placeTable.update(id, { deletedAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); + emitDomainEvent('PlaceDeleted', 'places', 'places', id, { + placeId: id, + name: (decrypted?.name as string) ?? '', + }); }, async toggleFavorite(id: string) { @@ -114,5 +128,10 @@ export const placesStore = { title: placeName, color: '#a855f7', }); + emitDomainEvent('PlaceVisited', 'places', 'places', id, { + placeId: id, + name: placeName, + visitCount: (local.visitCount ?? 0) + 1, + }); }, }; diff --git a/apps/mana/apps/web/src/lib/modules/places/stores/tracking.svelte.ts b/apps/mana/apps/web/src/lib/modules/places/stores/tracking.svelte.ts index 6caace052..a9e682831 100644 --- a/apps/mana/apps/web/src/lib/modules/places/stores/tracking.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/places/stores/tracking.svelte.ts @@ -6,6 +6,7 @@ */ import { decryptRecords, encryptRecord } from '$lib/data/crypto'; +import { emitDomainEvent } from '$lib/data/events'; import { createBlock } from '$lib/data/time-blocks/service'; import { locationLogTable, placeTable } from '../collections'; import { getDistanceKm, findNearestPlace, toPlace } from '../queries'; @@ -48,6 +49,9 @@ function startTracking() { error = null; isTracking = true; + emitDomainEvent('TrackingStarted', 'places', 'locationLogs', '', { + timestamp: new Date().toISOString(), + }); _watchId = navigator.geolocation.watchPosition( async (pos) => { @@ -79,6 +83,10 @@ function stopTracking() { _watchId = null; } isTracking = false; + emitDomainEvent('TrackingStopped', 'places', 'locationLogs', '', { + durationMs: 0, + logCount: 0, + }); } async function getCurrentPosition(): Promise { @@ -135,6 +143,13 @@ async function logPosition(pos: GeolocationPosition) { await encryptRecord('locationLogs', log); await locationLogTable.add(log); + emitDomainEvent('LocationLogged', 'places', 'locationLogs', log.id, { + logId: log.id, + lat, + lng, + placeId: nearest?.id, + accuracy: pos.coords.accuracy, + }); // Update visit count on the matched place + create TimeBlock if (nearest) { diff --git a/apps/mana/apps/web/src/lib/modules/todo/stores/tasks.svelte.ts b/apps/mana/apps/web/src/lib/modules/todo/stores/tasks.svelte.ts index 67d53b8e8..c97944859 100644 --- a/apps/mana/apps/web/src/lib/modules/todo/stores/tasks.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/todo/stores/tasks.svelte.ts @@ -10,6 +10,7 @@ import { toTask } from '../queries'; import type { LocalTask, TaskPriority, Subtask } from '../types'; import { createBlock, updateBlock, deleteBlock } from '$lib/data/time-blocks/service'; import { encryptRecord, decryptRecord } from '$lib/data/crypto'; +import { emitDomainEvent } from '$lib/data/events'; import { transcribeAudio } from '$lib/voice/transcribe'; import { TodoEvents } from '@mana/shared-utils/analytics'; import { tagCollection, type LocalTag } from '@mana/shared-stores'; @@ -153,6 +154,14 @@ export const tasksStore = { const plaintextSnapshot = toTask({ ...newLocal }); await encryptRecord('tasks', newLocal); await taskTable.add(newLocal); + emitDomainEvent('TaskCreated', 'todo', 'tasks', taskId, { + taskId, + title: plaintextSnapshot.title, + dueDate: data.dueDate, + priority: data.priority, + projectId: data.projectId, + labelIds: data.labelIds, + }); TodoEvents.taskCreated(!!data.dueDate); return plaintextSnapshot; }, @@ -362,6 +371,7 @@ export const tasksStore = { async deleteTask(id: string) { const task = await taskTable.get(id); + const decrypted = task ? await decryptRecord('tasks', { ...task }) : null; if (task?.scheduledBlockId) { await deleteBlock(task.scheduledBlockId); } @@ -370,24 +380,44 @@ export const tasksStore = { deletedAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); + emitDomainEvent('TaskDeleted', 'todo', 'tasks', id, { + taskId: id, + title: (decrypted?.title as string) ?? '', + }); TodoEvents.taskDeleted(); }, async completeTask(id: string) { + const task = await taskTable.get(id); + const decrypted = task ? await decryptRecord('tasks', { ...task }) : null; + const now = new Date().toISOString(); + const wasOverdue = task?.dueDate != null && (task.dueDate as string) < now.slice(0, 10); await taskTable.update(id, { isCompleted: true, - completedAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), + completedAt: now, + updatedAt: now, + }); + emitDomainEvent('TaskCompleted', 'todo', 'tasks', id, { + taskId: id, + title: (decrypted?.title as string) ?? '', + projectId: task?.projectId, + wasOverdue, }); TodoEvents.taskCompleted(); }, async uncompleteTask(id: string) { + const task = await taskTable.get(id); + const decrypted = task ? await decryptRecord('tasks', { ...task }) : null; await taskTable.update(id, { isCompleted: false, completedAt: null, updatedAt: new Date().toISOString(), }); + emitDomainEvent('TaskUncompleted', 'todo', 'tasks', id, { + taskId: id, + title: (decrypted?.title as string) ?? '', + }); }, async toggleComplete(id: string) { @@ -408,6 +438,11 @@ export const tasksStore = { }; await encryptRecord('tasks', diff); await taskTable.update(id, diff); + emitDomainEvent('SubtasksUpdated', 'todo', 'tasks', id, { + taskId: id, + total: subtasks.length, + completed: subtasks.filter((s) => s.isCompleted).length, + }); }, async updateLabels(id: string, labelIds: string[]) { diff --git a/docs/architecture/COMPANION_BRAIN_ARCHITECTURE.md b/docs/architecture/COMPANION_BRAIN_ARCHITECTURE.md new file mode 100644 index 000000000..eb1a9ed00 --- /dev/null +++ b/docs/architecture/COMPANION_BRAIN_ARCHITECTURE.md @@ -0,0 +1,1333 @@ +# Mana Companion Brain — Architecture & Implementation Plan + +> Vollstaendiger Umbau-Plan fuer ein zentrales Intelligenz-System ueber alle Module. +> Start mit 5 Pilot-Modulen: **Todo, Calendar, Drink, Nutriphi, Places**. +> Stand: April 2026 + +--- + +## 1. Vision + +Mana hat 40+ Module, die isoliert arbeiten. Der Companion Brain verbindet sie zu einem System, das den Nutzer proaktiv begleitet — erinnert, motiviert, Muster erkennt und Zusammenhaenge zwischen Modulen herstellt. Alles lokal, privacy-first. + +**Drei Saeulen:** +1. **Pulse** — Regelbasierte Nudges & Tageszusammenfassungen (kein LLM) +2. **Rituale** — Gefuehrte Routinen die Daten in Module schreiben (AI-generiert) +3. **Companion Chat** — LLM mit Tool-Zugriff auf alle Module + +**Fundament:** +- Domain Event Bus (semantische Events statt CRUD-Logs) +- Projection Engine (live-reaktive Aggregation ueber alle Module) +- Goal System (moduluebergreifende Ziele mit Fortschritt) +- Semantic Memory (extrahiertes Nutzerwissen, persistent) +- Tool Layer (standardisierter LLM-Zugriff auf Module) +- Feedback Loop (Nudge-Outcomes fuer Lernfaehigkeit) + +--- + +## 2. Architektur-Uebersicht + +``` ++---------------------------------------------------+ +| MODULE LAYER | +| Todo - Calendar - Drink - Nutriphi - Places | +| Jedes Modul emittiert Domain Events via Stores | ++------------------------+--------------------------+ + | emit() + v ++---------------------------------------------------+ +| EVENT BUS | +| Typed, synchron, in-process | +| TaskCompleted - DrinkLogged - EventCreated ... | ++--+--------+--------+--------+--------+-----------+ + | | | | | + v v v v v ++------+ +------+ +------+ +------+ +------+ +|Event | |State | |Proj. | |Rule | |Trig- | +|Store | |Write | |Engine| |Engine| |gers | +| | |Dexie | | | | | | | ++------+ +------+ +--+---+ +--+---+ +------+ + | | + +----------+--------+----------+ + v v v v ++---------------------------------------------------+ +| INTELLIGENCE LAYER | +| | +| +------------+ +----------+ +-------+ +--------+ | +| |Projections | | Memory | | Goals | |Feedback| | +| |DaySnapshot | | Patterns | | Meter | | Loop | | +| |Streaks | | Prefs | | Track | | Nudge | | +| |Correlations| | Context | | Link | | Outcome| | +| +-----+------+ +----+-----+ +---+---+ +---+----+ | +| | | | | | +| +--------------+-----------+---------+ | +| v | +| Context Document Generator | +| (~500 Token Nutzer-Snapshot) | ++------------------------+--------------------------+ + | + v ++---------------------------------------------------+ +| INTERACTION LAYER | +| | +| +----------+ +----------+ +---------+ +---------+ | +| | Pulse | | Rituale | |Companion| |Insights | | +| | Engine | | (AI-gen) | | Chat | | Cards | | +| | regelb. | | | |LLM+Tool | | | | +| +----------+ +----------+ +---------+ +---------+ | +| | +| Feedback: Nudge -> Outcome -> Memory Update | ++---------------------------------------------------+ +``` + +--- + +## 3. Domain Event System + +### 3.1 Warum Domain Events statt CRUD-Logs + +Aktuell loggt `_activity` nur `{ op: 'update', collection: 'tasks', recordId }`. Daraus laesst sich nicht ableiten, **was** passiert ist. Wurde der Task erledigt? Umbenannt? Verschoben? Das erzwingt Archaeologie — Felder vergleichen, Semantik raten. + +Domain Events tragen Bedeutung: `TaskCompleted { taskId, title, project }` ist sofort verstaendlich fuer Projections, Rules, LLM und Mensch. + +### 3.2 Event Bus Interface + +**Neues File: `apps/mana/apps/web/src/lib/data/events/event-bus.ts`** + +```typescript +// ── Core Types ────────────────────────────────────── + +export interface DomainEvent { + type: T; + payload: P; + meta: EventMeta; +} + +export interface EventMeta { + id: string; // crypto.randomUUID() + timestamp: string; // ISO + appId: string; // source module + collection: string; // source table + recordId: string; // affected record + userId: string; // from getEffectiveUserId() + causedBy?: string; // parent event id (for trigger chains) +} + +// ── Bus Interface ─────────────────────────────────── + +export type EventHandler = (event: E) => void; + +export interface EventBus { + emit(event: DomainEvent): void; + on(type: T, handler: EventHandler): () => void; + onAny(handler: EventHandler): () => void; + off(type: string, handler: EventHandler): void; +} +``` + +**Implementierung:** Einfacher synchroner Dispatcher mit async Subscribers. +- `emit()` ist synchron (blockiert Dexie-Hook nicht) +- Handlers laufen in `queueMicrotask()` — nach dem Hook, aber vor dem naechsten Frame +- Guard gegen Endlos-Loops: `_emitting` Set verhindert re-entrant emits vom selben Event-Typ + +```typescript +export function createEventBus(): EventBus { + const handlers = new Map>(); + const anyHandlers = new Set(); + + return { + emit(event: DomainEvent) { + queueMicrotask(() => { + const typeHandlers = handlers.get(event.type); + if (typeHandlers) { + for (const h of typeHandlers) h(event); + } + for (const h of anyHandlers) h(event); + }); + }, + + on(type, handler) { + if (!handlers.has(type)) handlers.set(type, new Set()); + handlers.get(type)!.add(handler); + return () => handlers.get(type)?.delete(handler); + }, + + onAny(handler) { + anyHandlers.add(handler); + return () => anyHandlers.delete(handler); + }, + + off(type, handler) { + handlers.get(type)?.delete(handler); + }, + }; +} + +// Singleton +export const eventBus = createEventBus(); +``` + +### 3.3 Event Store + +Ersetzt die `_activity`-Tabelle als primaere Quelle fuer "was ist passiert". + +**Neue Dexie-Tabelle `_events`:** +``` +_events: '++seq, meta.id, meta.type, meta.appId, meta.timestamp, + [meta.appId+meta.timestamp], [meta.type+meta.timestamp]' +``` + +Felder: +- `seq` — Auto-increment (Reihenfolge-Garantie) +- `type` — Domain Event Type (z.B. 'TaskCompleted') +- `payload` — Serialisiertes Event-Payload (verschluesselt fuer sensitive Felder) +- `meta` — EventMeta Objekt + +**Retention:** 90 Tage (wie `_activity`), max 50.000 Events. Pruning via bestehender Quota-Recovery. + +**Subscriber:** `eventBus.onAny()` schreibt jedes Event in `_events`. + +### 3.4 Domain Events pro Modul (5 Pilot-Module) + +#### Todo Events + +| Event | Payload | Abgeleitet aus | +|-------|---------|----------------| +| `TaskCreated` | `{ taskId, title, dueDate?, priority?, projectId?, labelIds? }` | `tasksStore.createTask()` | +| `TaskCompleted` | `{ taskId, title, projectId?, wasOverdue: boolean }` | `tasksStore.completeTask()` | +| `TaskUncompleted` | `{ taskId, title }` | `tasksStore.uncompleteTask()` | +| `TaskUpdated` | `{ taskId, fields: string[] }` | `tasksStore.updateTask()` | +| `TaskDeleted` | `{ taskId, title }` | `tasksStore.deleteTask()` | +| `TaskRescheduled` | `{ taskId, title, oldDate?, newDate }` | `updateTask` wenn `dueDate` aendert | +| `SubtasksUpdated` | `{ taskId, total, completed }` | `tasksStore.updateSubtasks()` | +| `ReminderSet` | `{ taskId, minutesBefore, type }` | `remindersStore.createReminder()` | + +#### Calendar Events + +| Event | Payload | Abgeleitet aus | +|-------|---------|----------------| +| `CalendarEventCreated` | `{ eventId, title, startTime, endTime, isAllDay, isRecurring, calendarId }` | `eventsStore.createEvent()` | +| `CalendarEventUpdated` | `{ eventId, fields: string[] }` | `eventsStore.updateEvent()` | +| `CalendarEventDeleted` | `{ eventId, title, wasRecurring }` | `eventsStore.deleteEvent()` | +| `CalendarEventMoved` | `{ eventId, title, oldStart, newStart }` | `updateEvent` wenn `startTime` aendert | + +#### Drink Events + +| Event | Payload | Abgeleitet aus | +|-------|---------|----------------| +| `DrinkLogged` | `{ entryId, drinkType, quantityMl, name, date, time, fromPreset: boolean }` | `drinkStore.logDrink()`, `logFromPreset()` | +| `DrinkEntryDeleted` | `{ entryId, drinkType, quantityMl }` | `drinkStore.deleteEntry()` | +| `DrinkEntryUndone` | `{ entryId }` | `drinkStore.undoLastEntry()` | +| `DrinkGoalReached` | `{ date, goalMl, actualMl, drinkType: 'water' }` | Projection erkennt Zielerreichung | + +#### Nutriphi Events + +| Event | Payload | Abgeleitet aus | +|-------|---------|----------------| +| `MealLogged` | `{ mealId, mealType, inputType, description, calories?, protein?, date }` | `mealMutations.create()` | +| `MealFromPhotoLogged` | `{ mealId, mealType, photoMediaId, confidence, foods? }` | `mealMutations.createFromPhoto()` | +| `MealDeleted` | `{ mealId, mealType }` | `mealMutations.delete()` | +| `NutritionGoalSet` | `{ dailyCalories, dailyProtein?, dailyCarbs?, dailyFat? }` | `goalMutations.create/update()` | +| `DailyCalorieGoalReached` | `{ date, goal, actual }` | Projection erkennt Zielerreichung | + +#### Places Events + +| Event | Payload | Abgeleitet aus | +|-------|---------|----------------| +| `PlaceCreated` | `{ placeId, name, category?, lat, lng }` | `placesStore.createPlace()` | +| `PlaceVisited` | `{ placeId, name, visitCount }` | `placesStore.recordVisit()` | +| `LocationLogged` | `{ logId, lat, lng, placeId?, accuracy }` | `trackingStore.logNow()` | +| `TrackingStarted` | `{}` | `trackingStore.startTracking()` | +| `TrackingStopped` | `{ durationMs, logCount }` | `trackingStore.stopTracking()` | + +### 3.5 Event-Emission aus Module Stores + +Jeder Store bekommt `emit()`-Calls in seinen Mutations. Kein Umbau der Dexie-Hooks noetig — Events werden **im Store** emittiert, nicht im Hook. + +**Warum im Store statt im Hook?** Der Hook sieht nur CRUD. Der Store kennt die Semantik. `completeTask()` weiss, dass es ein Completion ist — der Hook sieht nur `update({ completedAt })`. + +**Beispiel: Todo Store nach Umbau:** + +```typescript +// stores/tasks.svelte.ts +import { eventBus } from '$lib/data/events/event-bus'; + +export const tasksStore = { + async completeTask(id: string) { + const task = await taskTable.get(id); + if (!task) return; + const now = new Date().toISOString(); + const wasOverdue = task.dueDate && task.dueDate < now.slice(0, 10); + + await taskTable.update(id, { completedAt: now, updatedAt: now }); + + eventBus.emit({ + type: 'TaskCompleted', + payload: { + taskId: id, + title: task.title, // plaintext snapshot (pre-encryption) + projectId: task.projectId, + wasOverdue, + }, + meta: { + id: crypto.randomUUID(), + timestamp: now, + appId: 'todo', + collection: 'tasks', + recordId: id, + userId: getEffectiveUserId(), + }, + }); + }, + // ... andere Mutations analog +}; +``` + +**Konvention:** Jede Store-Mutation die einen Seiteneffekt hat, emittiert ein Event. Reine UI-State-Aenderungen (z.B. `calendarViewStore.setDate()`) emittieren nicht. + +### 3.6 Event Helper fuer Module + +Um Boilerplate zu reduzieren, ein `createEventEmitter` Helper: + +**Neues File: `apps/mana/apps/web/src/lib/data/events/emit.ts`** + +```typescript +import { eventBus } from './event-bus'; +import { getEffectiveUserId } from '../current-user'; + +export function emitDomainEvent

( + type: string, + appId: string, + collection: string, + recordId: string, + payload: P, + causedBy?: string +): void { + eventBus.emit({ + type, + payload, + meta: { + id: crypto.randomUUID(), + timestamp: new Date().toISOString(), + appId, + collection, + recordId, + userId: getEffectiveUserId(), + causedBy, + }, + }); +} +``` + +Aufruf im Store wird dann einzeilig: + +```typescript +emitDomainEvent('TaskCompleted', 'todo', 'tasks', id, { + taskId: id, title: task.title, projectId: task.projectId, wasOverdue, +}); +``` + +--- + +## 4. Projection Engine + +### 4.1 Prinzip + +Projections sind **live-reaktive Aggregationen** ueber Modul-Daten. Sie hoeren Domain Events und aktualisieren sich inkrementell. Consumers (Pulse, Companion, Dashboard) lesen Projections — nie Rohdaten. + +**Neuer Ordner: `apps/mana/apps/web/src/lib/data/projections/`** + +### 4.2 DaySnapshot + +Beantwortet: "Was ist heute los?" + +```typescript +// projections/day-snapshot.ts + +export interface DaySnapshot { + date: string; // YYYY-MM-DD + + // Todo + tasks: { + total: number; + completed: number; + overdue: number; + dueToday: TaskSummary[]; + }; + + // Calendar + events: { + upcoming: EventSummary[]; // naechste 5 Events + total: number; + nextEvent?: EventSummary; + }; + + // Drink + drinks: { + water: { ml: number; goal: number; percent: number }; + coffee: { ml: number; count: number }; + other: { ml: number; count: number }; + total: { ml: number; count: number }; + }; + + // Nutriphi + nutrition: { + meals: number; + calories: { actual: number; goal: number; percent: number }; + protein?: { actual: number; goal: number }; + }; + + // Places + places: { + visited: number; + currentLocation?: { lat: number; lng: number; placeName?: string }; + tracking: boolean; + }; +} +``` + +**Implementierung:** Dexie liveQueries die auf `$derived` gemapped werden. Event-Listener fuer inkrementelle Updates (z.B. `DrinkLogged` addiert direkt statt neu zu querien). + +### 4.3 Streaks + +Beantwortet: "Was laeuft gut, was droht zu brechen?" + +```typescript +// projections/streaks.ts + +export interface StreakInfo { + moduleId: string; + label: string; // "Wasser-Ziel", "Journal", "Sport" + currentStreak: number; // Tage in Folge + longestStreak: number; + lastActiveDate: string; // YYYY-MM-DD + status: 'active' | 'at_risk' | 'broken'; + // active: heute oder gestern aktiv + // at_risk: gestern nicht aktiv, vorgestern schon + // broken: >1 Tag Pause +} +``` + +Berechnet aus: TimeBlocks + Modul-spezifische Logik (Drink: Tagesziel erreicht, Todo: mindestens 1 Task erledigt, etc.) + +### 4.4 Correlations + +Beantwortet: "Was haengt zusammen?" + +```typescript +// projections/correlations.ts + +export interface Correlation { + id: string; + factorA: { module: string; metric: string; label: string }; + factorB: { module: string; metric: string; label: string }; + coefficient: number; // Pearson r, -1 bis +1 + pValue: number; // Statistische Signifikanz + sampleSize: number; // Anzahl Tage + direction: 'positive' | 'negative'; + sentence: string; // "An Tagen mit Sport trinkst du 30% mehr Wasser" + computedAt: string; +} +``` + +**Berechnung:** 1x taeglich, ueber TimeBlocks + Tagesaggregate der letzten 30-90 Tage. Pearson-Korrelation zwischen Paaren. Nur Korrelationen mit |r| > 0.3 und p < 0.05 werden gespeichert. + +**Metriken pro Modul:** +- Todo: tasks_completed_count, overdue_count +- Calendar: events_count, meeting_hours +- Drink: water_ml, coffee_count, goal_reached (boolean) +- Nutriphi: calories, protein, meals_count +- Places: places_visited, distance_km + +### 4.5 ContactHealth (spaeter, nicht in Pilot) + +Wird mit dem Contacts-Modul relevant. Trackt Kontakthaeufigkeit vs. erwartete Frequenz. + +--- + +## 5. Goal System + +### 5.1 Datenmodell + +**Neue Dexie-Tabelle: `goals`** +``` +goals: 'id, moduleId, status, [moduleId+status]' +``` + +```typescript +export interface LocalGoal { + id: string; + title: string; // "4x Sport pro Woche" + description?: string; + + // Metrik-Definition + metric: GoalMetric; + target: GoalTarget; + + // Verknuepfung + moduleId: string; // primaeres Modul + linkedModules: string[]; // weitere beteiligte Module + + // Status + status: 'active' | 'paused' | 'completed' | 'abandoned'; + + // Tracking + currentValue: number; // live berechnet + currentPeriodStart: string; // Beginn der aktuellen Periode + history: GoalPeriodResult[]; // vergangene Perioden + + createdAt: string; + updatedAt: string; + deletedAt?: string; +} + +export interface GoalMetric { + source: 'event_count' | 'event_sum' | 'streak_days' | 'custom'; + eventType?: string; // Domain Event Type (z.B. 'DrinkLogged') + filterField?: string; // z.B. 'drinkType' + filterValue?: string; // z.B. 'water' + sumField?: string; // z.B. 'quantityMl' (fuer event_sum) +} + +export interface GoalTarget { + value: number; // Zielwert + period: 'day' | 'week' | 'month'; + comparison: 'gte' | 'lte' | 'eq'; // >= (Sport), <= (Kaffee), = (exakt) +} + +export interface GoalPeriodResult { + periodStart: string; + periodEnd: string; + value: number; + reached: boolean; +} +``` + +### 5.2 Goal-Tracking via Events + +Der Goal-Tracker subscribed auf den Event Bus und aktualisiert `currentValue` inkrementell: + +```typescript +// Beispiel: Ziel "8 Glaeser Wasser/Tag" +// metric: { source: 'event_count', eventType: 'DrinkLogged', filterField: 'drinkType', filterValue: 'water' } +// target: { value: 8, period: 'day', comparison: 'gte' } + +eventBus.on('DrinkLogged', (event) => { + if (event.payload.drinkType === 'water') { + goal.currentValue += 1; + if (goal.currentValue >= goal.target.value) { + emitDomainEvent('GoalReached', 'companion', 'goals', goal.id, { + goalId: goal.id, title: goal.title, value: goal.currentValue, + }); + } + } +}); +``` + +### 5.3 Vordefinierte Ziel-Templates + +Fuer den Start 10-15 Templates die der Nutzer mit einem Tap aktiviert: + +- 8 Glaeser Wasser/Tag (Drink, event_count, DrinkLogged, water) +- 2000 kcal/Tag (Nutriphi, event_sum, MealLogged, calories) +- 5 Tasks/Tag erledigen (Todo, event_count, TaskCompleted) +- Alle Mahlzeiten tracken (Nutriphi, event_count, MealLogged, 3/day) +- Jeden Tag einen neuen Ort besuchen (Places, event_count, PlaceVisited, 1/day) + +--- + +## 6. Semantic Memory + +### 6.1 Datenmodell + +**Neue Dexie-Tabelle: `_memory`** +``` +_memory: 'id, category, confidence, lastConfirmed, [category+confidence]' +``` + +```typescript +export interface MemoryFact { + id: string; + category: 'pattern' | 'preference' | 'relationship' | 'context'; + + content: string; // Menschenlesbarer Fakt + // "Trainiert typischerweise Mo/Mi/Fr abends" + // "Trinkt morgens immer zuerst Kaffee, dann Wasser" + // "Meetings haeufig Di/Do vormittags" + + confidence: number; // 0.0 - 1.0 + confirmations: number; // wie oft bestaetigt + contradictions: number; // wie oft widersprochen + + sourceEvents: string[]; // Event-IDs die diesen Fakt stuetzen + sourceModules: string[]; // beteiligte Module + + firstSeen: string; // wann erstmals erkannt + lastConfirmed: string; // letzte Bestaetigung + expiresAt?: string; // fuer temporaere Kontexte + + createdAt: string; + updatedAt: string; + deletedAt?: string; +} +``` + +### 6.2 Extraktion + +**Zwei Wege:** + +1. **Regelbasiert (kein LLM):** Pattern-Detektoren ueber Event-Stream: + - Wiederholungs-Detektor: "3x in 2 Wochen am Montag trainiert → Pattern: trainiert montags" + - Zeitfenster-Detektor: "Tasks werden zu 80% zwischen 09-12 Uhr erledigt → Preference: Morgen-Produktivitaet" + - Sequenz-Detektor: "Kaffee wird immer vor dem ersten Event geloggt → Pattern: Kaffee vor Meetings" + +2. **LLM-basiert (Tier 1 browser):** Woechentlich zusammengefasste Events an lokales Gemma-Modell: + - "Hier sind die Events der letzten Woche. Welche Muster und Praeferenzen erkennst du?" + - Ergebnis als JSON parsen → MemoryFact[] schreiben + +### 6.3 Confidence-Lifecycle + +``` +Neuer Fakt erkannt → confidence: 0.3 +Nochmal bestaetigt → confidence: 0.5 +3+ Bestaetigungen → confidence: 0.7 +10+ Bestaetigungen → confidence: 0.9 +Widerspruch erkannt → confidence -= 0.15 +Laenger als 30 Tage nicht → confidence -= 0.05/Woche + bestaetigt +confidence < 0.1 → Fakt wird geloescht +``` + +--- + +## 7. Context Document Generator + +### 7.1 Zweck + +Komprimiert den gesamten Nutzerzustand in ein ~500 Token Dokument, das als System-Prompt an das LLM geht. Aktualisiert sich bei jedem Companion-Aufruf. + +### 7.2 Template + +```typescript +// projections/context-document.ts + +export function generateContextDocument( + day: DaySnapshot, + streaks: StreakInfo[], + goals: LocalGoal[], + memory: MemoryFact[], + correlations: Correlation[] +): string { + return `## Nutzer-Kontext (${day.date}) + +### Heute +- ${day.tasks.dueToday.length} Tasks faellig (${day.tasks.completed} erledigt, ${day.tasks.overdue} ueberfaellig) +- ${day.events.total} Termine${day.events.nextEvent ? ` — naechster: ${day.events.nextEvent.title} um ${day.events.nextEvent.startTime}` : ''} +- Wasser: ${day.drinks.water.ml}ml von ${day.drinks.water.goal}ml (${day.drinks.water.percent}%) +- Ernaehrung: ${day.nutrition.calories.actual} von ${day.nutrition.calories.goal} kcal, ${day.nutrition.meals} Mahlzeiten +${day.places.tracking ? `- Standort-Tracking aktiv` : ''} + +### Ziele +${goals.filter(g => g.status === 'active').map(g => + `- "${g.title}" — ${g.currentValue}/${g.target.value} (${g.target.period})` +).join('\n')} + +### Streaks +${streaks.filter(s => s.status !== 'broken').map(s => + `- ${s.label}: ${s.currentStreak} Tage${s.status === 'at_risk' ? ' (GEFAEHRDET)' : ''}` +).join('\n')} +${streaks.filter(s => s.status === 'broken').map(s => + `- ${s.label}: UNTERBROCHEN seit ${daysSince(s.lastActiveDate)} Tagen` +).join('\n')} + +### Bekannte Muster +${memory.filter(m => m.confidence > 0.5).map(m => `- ${m.content}`).join('\n')} + +### Korrelationen +${correlations.slice(0, 3).map(c => `- ${c.sentence}`).join('\n')} +`; +} +``` + +--- + +## 8. Tool Layer (LLM Write-Access) + +### 8.1 ModuleTool Interface + +**Neues File: `apps/mana/apps/web/src/lib/data/tools/types.ts`** + +```typescript +export interface ModuleTool { + name: string; // 'create_task', 'log_drink' + module: string; // 'todo', 'drink' + description: string; // Fuer LLM Function-Schema + parameters: ToolParameter[]; + execute: (params: Record) => Promise; +} + +export interface ToolParameter { + name: string; + type: 'string' | 'number' | 'boolean'; + description: string; + required: boolean; + enum?: string[]; // z.B. ['water', 'coffee', 'tea'] +} + +export interface ToolResult { + success: boolean; + data?: unknown; + message?: string; // Menschenlesbare Bestaetigung +} +``` + +### 8.2 Tool-Definitionen (5 Pilot-Module) + +**Jedes Modul bekommt eine `tools.ts`:** + +```typescript +// modules/todo/tools.ts +export const todoTools: ModuleTool[] = [ + { + name: 'create_task', + module: 'todo', + description: 'Erstellt einen neuen Task', + parameters: [ + { name: 'title', type: 'string', description: 'Titel des Tasks', required: true }, + { name: 'dueDate', type: 'string', description: 'Faelligkeitsdatum (YYYY-MM-DD)', required: false }, + { name: 'priority', type: 'number', description: 'Prioritaet 0-3', required: false }, + ], + execute: async (params) => { + const task = await tasksStore.createTask({ + title: params.title as string, + dueDate: params.dueDate as string | undefined, + priority: params.priority as number | undefined, + }); + return { success: true, data: task, message: `Task "${task.title}" erstellt` }; + }, + }, + { + name: 'complete_task', + module: 'todo', + description: 'Markiert einen Task als erledigt', + parameters: [ + { name: 'taskId', type: 'string', description: 'ID des Tasks', required: true }, + ], + execute: async (params) => { + await tasksStore.completeTask(params.taskId as string); + return { success: true, message: 'Task erledigt' }; + }, + }, +]; + +// modules/drink/tools.ts +export const drinkTools: ModuleTool[] = [ + { + name: 'log_drink', + module: 'drink', + description: 'Loggt ein Getraenk', + parameters: [ + { name: 'drinkType', type: 'string', description: 'Art', required: true, enum: ['water', 'coffee', 'tea', 'juice', 'alcohol', 'other'] }, + { name: 'quantityMl', type: 'number', description: 'Menge in ml', required: true }, + { name: 'name', type: 'string', description: 'Name des Getraenks', required: false }, + ], + execute: async (params) => { + const entry = await drinkStore.logDrink({ + name: (params.name as string) ?? (params.drinkType as string), + drinkType: params.drinkType as DrinkType, + quantityMl: params.quantityMl as number, + }); + return { success: true, data: entry, message: `${params.quantityMl}ml ${params.drinkType} geloggt` }; + }, + }, +]; + +// modules/calendar/tools.ts — create_event +// modules/nutriphi/tools.ts — log_meal +// modules/places/tools.ts — record_visit, create_place +``` + +### 8.3 Tool Registry + +**Neues File: `apps/mana/apps/web/src/lib/data/tools/registry.ts`** + +```typescript +import { todoTools } from '$lib/modules/todo/tools'; +import { calendarTools } from '$lib/modules/calendar/tools'; +import { drinkTools } from '$lib/modules/drink/tools'; +import { nutriphiTools } from '$lib/modules/nutriphi/tools'; +import { placesTools } from '$lib/modules/places/tools'; + +const ALL_TOOLS: ModuleTool[] = [ + ...todoTools, + ...calendarTools, + ...drinkTools, + ...nutriphiTools, + ...placesTools, +]; + +export function getTools(): ModuleTool[] { + return ALL_TOOLS; +} + +export function getTool(name: string): ModuleTool | undefined { + return ALL_TOOLS.find((t) => t.name === name); +} + +export function getToolsForLlm(): LlmFunctionSchema[] { + return ALL_TOOLS.map((t) => ({ + name: t.name, + description: t.description, + parameters: { + type: 'object', + properties: Object.fromEntries( + t.parameters.map((p) => [p.name, { + type: p.type, + description: p.description, + ...(p.enum ? { enum: p.enum } : {}), + }]) + ), + required: t.parameters.filter((p) => p.required).map((p) => p.name), + }, + })); +} +``` + +### 8.4 Integration mit LLM Orchestrator + +Der bestehende `LlmOrchestrator` in `@mana/shared-llm` bekommt eine neue Methode: + +```typescript +// In shared-llm/src/orchestrator.ts + +async runWithTools( + task: LlmTask, + input: { messages: ChatMessage[]; tools: LlmFunctionSchema[] }, +): Promise> +``` + +Das LLM gibt `tool_use` Responses zurueck, die der Orchestrator ueber `getTool(name).execute(params)` ausfuehrt. Das Ergebnis wird als `tool_result` Message zurueckgefuettert. + +--- + +## 9. Rule Engine (Pulse) + +### 9.1 Prinzip + +Die Rule Engine liest Projections und erzeugt Nudges. Kein LLM — rein deterministisch. Laeuft auf zwei Wegen: + +1. **Event-getriggert:** Reagiert auf Domain Events (z.B. `TaskCompleted` → Streak-Check) +2. **Zeitgesteuert:** Periodische Checks (Morgen-Summary, Abend-Reflexion, stuendlich) + +### 9.2 Rule Interface + +**Neues File: `apps/mana/apps/web/src/lib/companion/rules/types.ts`** + +```typescript +export interface PulseRule { + id: string; + name: string; + description: string; + + // Trigger + trigger: + | { kind: 'event'; eventType: string } + | { kind: 'schedule'; cron: string } // z.B. '0 8 * * *' (08:00 taeglich) + | { kind: 'interval'; minutes: number }; // z.B. 60 (stuendlich) + + // Check — gibt null zurueck wenn kein Nudge noetig + check: (ctx: RuleContext) => Promise; +} + +export interface RuleContext { + day: DaySnapshot; + streaks: StreakInfo[]; + goals: LocalGoal[]; + memory: MemoryFact[]; + now: Date; +} + +export interface Nudge { + id: string; + type: NudgeType; + title: string; + body: string; + priority: 'low' | 'medium' | 'high'; + actionLabel?: string; // "Jetzt loggen" + actionRoute?: string; // '/drink' + actionTool?: string; // 'log_drink' — Companion kann direkt ausfuehren + expiresAt?: string; // wann der Nudge irrelevant wird +} + +type NudgeType = + | 'streak_warning' + | 'goal_progress' + | 'goal_reached' + | 'morning_summary' + | 'evening_reflection' + | 'overdue_tasks' + | 'water_reminder' + | 'meal_reminder' + | 'correlation_insight'; +``` + +### 9.3 Vordefinierte Rules (Pilot) + +```typescript +// rules/water-reminder.ts +export const waterReminderRule: PulseRule = { + id: 'water-reminder', + name: 'Wasser-Erinnerung', + trigger: { kind: 'interval', minutes: 90 }, + async check(ctx) { + const { water } = ctx.day.drinks; + if (water.percent >= 100) return null; // Ziel erreicht + const hourOfDay = ctx.now.getHours(); + if (hourOfDay < 8 || hourOfDay > 21) return null; // Nachtruhe + + const remaining = water.goal - water.ml; + const hoursLeft = 21 - hourOfDay; + const mlPerHour = Math.ceil(remaining / hoursLeft); + + return { + id: `water-${ctx.day.date}-${hourOfDay}`, + type: 'water_reminder', + title: 'Wasser trinken', + body: `Noch ${remaining}ml bis zum Ziel. ~${mlPerHour}ml pro Stunde.`, + priority: water.percent < 50 ? 'medium' : 'low', + actionLabel: 'Glas loggen', + actionTool: 'log_drink', + }; + }, +}; + +// rules/streak-warning.ts +export const streakWarningRule: PulseRule = { + id: 'streak-warning', + name: 'Streak-Warnung', + trigger: { kind: 'schedule', cron: '0 18 * * *' }, // 18:00 taeglich + async check(ctx) { + const atRisk = ctx.streaks.filter(s => s.status === 'at_risk'); + if (atRisk.length === 0) return null; + + const best = atRisk.reduce((a, b) => a.currentStreak > b.currentStreak ? a : b); + return { + id: `streak-${ctx.day.date}`, + type: 'streak_warning', + title: `${best.label}-Streak in Gefahr!`, + body: `${best.currentStreak} Tage — nicht heute brechen.`, + priority: best.currentStreak > 7 ? 'high' : 'medium', + }; + }, +}; + +// rules/morning-summary.ts +// rules/evening-reflection.ts +// rules/overdue-tasks.ts +// rules/meal-reminder.ts +// rules/goal-reached.ts +``` + +### 9.4 Rule Engine Runner + +Integriert sich in den bestehenden Reminder-Scheduler als zusaetzliche Source: + +```typescript +// companion/rules/engine.ts + +export function createRuleEngine(rules: PulseRule[]): ReminderSource { + return { + id: 'companion-pulse', + async checkDue(): Promise { + const ctx = await buildRuleContext(); + const nudges: Nudge[] = []; + + for (const rule of rules) { + if (shouldTrigger(rule)) { + const nudge = await rule.check(ctx); + if (nudge && !isDismissed(nudge.id)) { + nudges.push(nudge); + } + } + } + + return nudges.map(toReminder); + }, + async markSent(id) { /* track in localStorage */ }, + }; +} +``` + +--- + +## 10. Feedback Loop + +### 10.1 Datenmodell + +**Neue Dexie-Tabelle: `_nudgeOutcomes`** +``` +_nudgeOutcomes: '++id, nudgeId, nudgeType, outcome, timestamp, [nudgeType+outcome]' +``` + +```typescript +export interface NudgeOutcome { + id?: number; + nudgeId: string; + nudgeType: NudgeType; + outcome: 'acted' | 'dismissed' | 'snoozed' | 'expired' | 'auto_resolved'; + latencyMs?: number; // Zeit bis Reaktion + timestamp: string; +} +``` + +### 10.2 Lerneffekt + +Aggregation ueber `_nudgeOutcomes` beeinflusst die Rule Engine: + +```typescript +// Beispiel: Wasser-Reminder wird um 10:00 immer dismissed +// → confidence fuer "Nutzer mag morgens keine Wasser-Reminder" steigt +// → Rule Engine verschiebt auf 11:00 + +// Beispiel: Streak-Warning um 18:00 fuehrt zu 80% zu Aktion +// → confidence fuer "18:00 ist guter Zeitpunkt" steigt +// → bleibt bei 18:00 +``` + +Memory-Facts werden aus Outcome-Patterns extrahiert und fliessen in den Context Document Generator. + +--- + +## 11. Companion Chat (Interaction Layer) + +### 11.1 Modul-Struktur + +**Neues Modul: `apps/mana/apps/web/src/lib/modules/companion/`** + +``` +companion/ + module.config.ts — Registriert companion-Tabellen + collections.ts — conversations, messages, rituals, goals + stores/ + chat.svelte.ts — Chat-Mutations (send, receive, tool-call) + rituals.svelte.ts — Ritual-CRUD + Step-Execution + goals.svelte.ts — Goal-CRUD + Progress-Tracking + queries.ts — Live-Queries fuer Chat, Rituals, Goals + tools.ts — Companion-eigene Tools (read_context, get_insights) + components/ + CompanionChat.svelte — Chat-Interface mit Tool-Execution + CompanionFeed.svelte — Timeline von Nudges + Insights + Chat + RitualRunner.svelte — Step-by-Step Ritual-UI + GoalCard.svelte — Ziel-Fortschritts-Anzeige +``` + +### 11.2 Chat-Flow + +``` +User: "Wie laeuft mein Tag?" + | + v +CompanionChat → LLM Orchestrator + | + | System Prompt = Context Document (~500 Tokens) + | + Tool Schemas (getToolsForLlm()) + | + User Message + | + v +LLM (Gemma lokal ODER Gemini Cloud) + | + | Response: "Du hast heute 3/7 Tasks erledigt und erst 400ml + | Wasser getrunken. Dein Kalender ist ab 15:00 frei — guter + | Zeitpunkt fuer die ueberfaelligen Tasks. Soll ich dich + | in 2 Stunden ans Wasser erinnern?" + | + | tool_use: create_reminder(...) + | + v +Tool Execution → drinkStore / remindersStore + | + v +CompanionChat zeigt Antwort + Aktions-Bestaetigung +``` + +### 11.3 Ritual-Generierung + +``` +User: "Erstell mir eine Morgenroutine" + | + v +LLM + Context Document + Tool Schemas + | + | LLM sieht: Nutzer hat Drink, Todo, Nutriphi, Calendar aktiv + | Memory: "Trinkt morgens zuerst Kaffee" + | Goals: "8 Glaeser Wasser/Tag" + | + v +Generiertes Ritual: + 1. Glas Wasser loggen (tool: log_drink, water, 250ml) + 2. Stimmung checken (free_text → journal) + 3. Tages-Tasks priorisieren (zeigt DaySnapshot.tasks.dueToday) + 4. Kalender-Ueberblick (zeigt DaySnapshot.events.upcoming) + 5. Fruehstueck loggen (tool: log_meal) +``` + +--- + +## 12. Neue Dateien & Ordnerstruktur + +``` +apps/mana/apps/web/src/lib/ + data/ + events/ + event-bus.ts — EventBus Singleton + event-store.ts — Persistenz in _events Tabelle + emit.ts — Helper fuer Module + types.ts — DomainEvent, EventMeta Interfaces + catalog.ts — Alle Event-Type Definitionen (union type) + projections/ + day-snapshot.ts — DaySnapshot Aggregation + streaks.ts — Streak-Berechnung + correlations.ts — Statistische Korrelationen + context-document.ts — LLM-Prompt-Generator + tools/ + types.ts — ModuleTool Interface + registry.ts — Tool-Sammlung + LLM-Schema-Generator + executor.ts — Tool-Ausfuehrung mit Validierung + companion/ + rules/ + types.ts — PulseRule, Nudge, RuleContext + engine.ts — Rule Runner (als ReminderSource) + water-reminder.ts + streak-warning.ts + morning-summary.ts + evening-reflection.ts + overdue-tasks.ts + meal-reminder.ts + goal-check.ts + feedback/ + types.ts — NudgeOutcome + tracker.ts — Outcome-Recording + analyzer.ts — Pattern-Extraktion aus Outcomes + modules/ + companion/ + module.config.ts + collections.ts + stores/ + chat.svelte.ts + rituals.svelte.ts + goals.svelte.ts + queries.ts + tools.ts + components/ + CompanionChat.svelte + CompanionFeed.svelte + RitualRunner.svelte + GoalCard.svelte + todo/ + tools.ts — NEU: Tool-Definitionen + stores/tasks.svelte.ts — ANPASSEN: emit() Calls + calendar/ + tools.ts — NEU + stores/events.svelte.ts — ANPASSEN + drink/ + tools.ts — NEU + stores/drink.svelte.ts — ANPASSEN + nutriphi/ + tools.ts — NEU + mutations.ts — ANPASSEN + places/ + tools.ts — NEU + stores/places.svelte.ts — ANPASSEN + stores/tracking.svelte.ts — ANPASSEN +``` + +--- + +## 13. Neue Dexie-Tabellen + +```typescript +// In database.ts, naechste Version: + +// Event Store (ersetzt _activity langfristig) +_events: '++seq, type, [meta.appId+meta.timestamp], [meta.type+meta.timestamp], meta.recordId', + +// Goals +goals: 'id, moduleId, status, [moduleId+status]', +goalHistory: '++id, goalId, periodStart', + +// Semantic Memory +_memory: 'id, category, confidence, lastConfirmed, [category+confidence]', + +// Feedback Loop +_nudgeOutcomes: '++id, nudgeId, nudgeType, outcome, timestamp, [nudgeType+outcome]', + +// Companion Chat +companionConversations: 'id, createdAt', +companionMessages: 'id, conversationId, role, createdAt, [conversationId+createdAt]', + +// Rituals +rituals: 'id, status, createdAt', +ritualSteps: 'id, ritualId, order, [ritualId+order]', +ritualLogs: '++id, ritualId, date, [ritualId+date]', +``` + +--- + +## 14. Implementierungs-Reihenfolge + +### Phase 1: Event-Fundament (Woche 1-2) + +1. `data/events/` — EventBus, EventStore, emit Helper, Type Catalog +2. Domain Events fuer 5 Pilot-Module definieren (catalog.ts) +3. Stores der 5 Module umbauen: `emit()` Calls einfuegen +4. Event Store Subscriber: `eventBus.onAny()` → `_events` Tabelle +5. Tests: Events werden korrekt emittiert und persistiert + +**Ergebnis:** Semantischer Event-Stream fliesst, Dexie-Writes + Events parallel. + +### Phase 2: Projections (Woche 2-3) + +1. DaySnapshot Projection (live Dexie-Queries + Event-Listener) +2. Streaks Projection (basierend auf Events + TimeBlocks) +3. Context Document Generator (Template-basiert) +4. Dashboard-Widget: "Mein Tag" Karte mit DaySnapshot-Daten + +**Ergebnis:** Zentraler Ueberblick ueber alle 5 Module, live-reaktiv. + +### Phase 3: Goals + Pulse (Woche 3-4) + +1. Goal Datenmodell + Store + Queries +2. Goal-Tracking via Event-Subscription +3. Goal-Templates (5 vordefinierte) +4. Rule Engine mit 5 initialen Rules +5. Integration in Reminder-Scheduler +6. Nudge-UI: Toast / Bottom-Sheet + +**Ergebnis:** Nutzer setzt Ziele, bekommt proaktive Nudges. + +### Phase 4: Tool Layer (Woche 4-5) + +1. ModuleTool Interface + Registry +2. tools.ts fuer 5 Pilot-Module +3. Tool Executor mit Validierung +4. LLM Function Schema Generator +5. Integration in LLM Orchestrator (`runWithTools`) + +**Ergebnis:** LLM kann Module lesen und beschreiben. + +### Phase 5: Companion Chat (Woche 5-6) + +1. Companion Modul (collections, stores, queries) +2. CompanionChat Svelte-Komponente +3. Chat-Flow: Context Document + Tools + LLM +4. CompanionFeed: Timeline von Nudges + Chat + +**Ergebnis:** Nutzer kann mit dem System sprechen und Aktionen ausfuehren. + +### Phase 6: Rituale (Woche 6-7) + +1. Ritual Datenmodell (steps, logs) +2. RitualRunner Komponente +3. AI-Ritual-Generierung via Companion Chat +4. Vordefinierte Ritual-Templates (Morgen, Abend, Wasser) + +**Ergebnis:** Gefuehrte Routinen die in Module schreiben. + +### Phase 7: Memory + Correlations (Woche 7-8) + +1. Semantic Memory Tabelle + Store +2. Regelbasierte Pattern-Extraktion +3. Correlation Engine ueber TimeBlocks +4. Memory + Correlations in Context Document +5. Feedback Loop (NudgeOutcome Tracking) +6. LLM-basierte Memory-Extraktion (optional, Tier 1) + +**Ergebnis:** System lernt ueber Zeit, Insights werden praeziser. + +### Phase 8: Rollout auf weitere Module (Woche 8+) + +Pro Modul: +1. Domain Events definieren (catalog.ts erweitern) +2. Store Mutations mit emit() versehen +3. tools.ts schreiben +4. Projections erweitern (DaySnapshot Felder) +5. Goal-Templates hinzufuegen +6. Pulse Rules hinzufuegen + +Geschaetzter Aufwand pro Modul: 1-2 Tage. + +--- + +## 15. Abhaengigkeiten & Reihenfolge-Graph + +``` +Phase 1 (Events) ──────┬──> Phase 2 (Projections) + | | + | v + ├──> Phase 3 (Goals + Pulse) + | | + v v + Phase 4 (Tools) ──> Phase 5 (Companion Chat) + | + v + Phase 6 (Rituale) + | + v + Phase 7 (Memory) + | + v + Phase 8 (Rollout) +``` + +Phase 1 ist Voraussetzung fuer alles. Phase 2-4 koennen teilweise parallel laufen. + +--- + +## 16. Privacy-Garantien + +| Daten | Verarbeitung | Speicherung | +|-------|-------------|-------------| +| Domain Events | Lokal (Browser) | IndexedDB, encrypted | +| Projections | Lokal (Browser) | In-Memory, nicht persistiert | +| Goals | Lokal + Sync | IndexedDB → mana-sync (encrypted) | +| Memory Facts | Lokal (Browser) | IndexedDB, encrypted | +| Context Document | Lokal (Browser) | In-Memory, nie persistiert | +| LLM Inference | Tier 1: Browser (Gemma) | Kein Server-Kontakt | +| | Tier 2: mana-llm (Ollama) | Context geht an eigenen Server | +| | Tier 3: Cloud (Gemini) | Nur mit explizitem Consent | +| Nudge Outcomes | Lokal (Browser) | IndexedDB, nicht synced | +| Tool Execution | Lokal (Browser) | Writes gehen in Module-Tabellen | + +**Invariante:** Sensitive Daten (Journal, Dreams, Finance, Nutriphi) werden **nie** an Tier 2/3 gesendet — erzwungen durch `contentClass: 'sensitive'` im LLM Orchestrator. + +--- + +## 17. Migration: _activity → _events + +Die `_activity`-Tabelle bleibt vorerst bestehen (Sync-Debugging). Langfristig: + +1. Phase 1-2: `_events` wird parallel zu `_activity` befuellt +2. Phase 7: Alle Consumers (Activity-Page, Debug-Tools) auf `_events` umstellen +3. Danach: `_activity`-Befuellung aus Hooks entfernen, Tabelle als deprecated markieren +4. Naechste Major-Version: Tabelle loeschen + +--- + +## 18. Testing-Strategie + +### Unit Tests +- Event Bus: emit/subscribe/unsubscribe, ordering, no re-entrant loops +- Projections: DaySnapshot korrekt aus Mock-Daten, Streak-Berechnung +- Rules: Nudge-Generierung unter verschiedenen Bedingungen +- Tools: Parameter-Validierung, Execute-Flow +- Correlations: Pearson-Berechnung, Signifikanz-Filter + +### Integration Tests +- Store emit() → EventBus → EventStore → Projection Update +- Rule Engine → Nudge → UI → Outcome → Memory Update +- Companion Chat → LLM Mock → Tool Call → Store Mutation → Event + +### E2E Tests +- Morgenroutine-Ritual durchspielen: 5 Steps → Daten in 3 Modulen +- Wasser-Ziel erreichen: 8x log_drink → GoalReached Event → Nudge +- Companion-Frage: "Wie war meine Woche?" → Context Document → Antwort