From 9066b6c9ae276998b13b175b4ab6c771765f2869 Mon Sep 17 00:00:00 2001 From: Till JS Date: Mon, 13 Apr 2026 20:40:42 +0200 Subject: [PATCH] feat(brain): add Goal system, Pulse Rule Engine, and Feedback Loop Phase 3 of the Companion Brain architecture. Goal System (companion/goals/): - LocalGoal model with metric + target definitions - Event bus subscriber that auto-tracks progress per period - 6 goal templates (water, tasks, meals, calories, places, coffee) - CRUD store with pause/resume/complete/abandon lifecycle Pulse Rule Engine (companion/rules/): - 5 deterministic rules: water reminder (90min interval), streak warning (18:00), morning summary (08:00), overdue tasks (10+15:00), meal reminder (12+19:00) - ReminderSource adapter for existing reminder scheduler - Interval + schedule triggers with per-rule last-run tracking - Dismissal tracking via localStorage Feedback Loop (companion/feedback/): - NudgeOutcome model (acted/dismissed/snoozed/expired + latency) - Persisted to _nudgeOutcomes IndexedDB table - Stats + action rate queries for future rule optimization Also adds companionGoals, _memory, _nudgeOutcomes tables (v10 schema). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../web/src/lib/companion/feedback/index.ts | 2 + .../web/src/lib/companion/feedback/tracker.ts | 54 ++++++ .../web/src/lib/companion/feedback/types.ts | 18 ++ .../apps/web/src/lib/companion/goals/index.ts | 4 + .../web/src/lib/companion/goals/queries.ts | 23 +++ .../apps/web/src/lib/companion/goals/store.ts | 177 ++++++++++++++++++ .../apps/web/src/lib/companion/goals/types.ts | 121 ++++++++++++ .../web/src/lib/companion/rules/engine.ts | 139 ++++++++++++++ .../apps/web/src/lib/companion/rules/index.ts | 3 + .../apps/web/src/lib/companion/rules/rules.ts | 153 +++++++++++++++ .../apps/web/src/lib/companion/rules/types.ts | 50 +++++ apps/mana/apps/web/src/lib/data/database.ts | 4 + 12 files changed, 748 insertions(+) create mode 100644 apps/mana/apps/web/src/lib/companion/feedback/index.ts create mode 100644 apps/mana/apps/web/src/lib/companion/feedback/tracker.ts create mode 100644 apps/mana/apps/web/src/lib/companion/feedback/types.ts create mode 100644 apps/mana/apps/web/src/lib/companion/goals/index.ts create mode 100644 apps/mana/apps/web/src/lib/companion/goals/queries.ts create mode 100644 apps/mana/apps/web/src/lib/companion/goals/store.ts create mode 100644 apps/mana/apps/web/src/lib/companion/goals/types.ts create mode 100644 apps/mana/apps/web/src/lib/companion/rules/engine.ts create mode 100644 apps/mana/apps/web/src/lib/companion/rules/index.ts create mode 100644 apps/mana/apps/web/src/lib/companion/rules/rules.ts create mode 100644 apps/mana/apps/web/src/lib/companion/rules/types.ts diff --git a/apps/mana/apps/web/src/lib/companion/feedback/index.ts b/apps/mana/apps/web/src/lib/companion/feedback/index.ts new file mode 100644 index 000000000..f307cf25e --- /dev/null +++ b/apps/mana/apps/web/src/lib/companion/feedback/index.ts @@ -0,0 +1,2 @@ +export { recordOutcome, getOutcomeStats, getActionRate } from './tracker'; +export type { NudgeOutcome } from './types'; diff --git a/apps/mana/apps/web/src/lib/companion/feedback/tracker.ts b/apps/mana/apps/web/src/lib/companion/feedback/tracker.ts new file mode 100644 index 000000000..4b8e8c57f --- /dev/null +++ b/apps/mana/apps/web/src/lib/companion/feedback/tracker.ts @@ -0,0 +1,54 @@ +/** + * Feedback Tracker — Records nudge outcomes to IndexedDB. + * + * Used by the nudge UI to track whether users act on, dismiss, + * or ignore nudges. Over time, patterns emerge that can adjust + * rule timing and priority. + */ + +import { db } from '$lib/data/database'; +import type { NudgeType } from '../rules/types'; +import type { NudgeOutcome } from './types'; + +const TABLE = '_nudgeOutcomes'; + +export async function recordOutcome( + nudgeId: string, + nudgeType: NudgeType, + outcome: NudgeOutcome['outcome'], + latencyMs?: number +): Promise { + await db.table(TABLE).add({ + nudgeId, + nudgeType, + outcome, + latencyMs, + timestamp: new Date().toISOString(), + }); +} + +/** Get outcome stats for a nudge type (last 30 days). */ +export async function getOutcomeStats( + nudgeType: NudgeType +): Promise<{ acted: number; dismissed: number; snoozed: number; expired: number; total: number }> { + const cutoff = new Date(Date.now() - 30 * 86400000).toISOString(); + const rows: NudgeOutcome[] = await db + .table(TABLE) + .where('[nudgeType+outcome]') + .between([nudgeType, ''], [nudgeType, '\uffff']) + .filter((r: NudgeOutcome) => r.timestamp >= cutoff) + .toArray(); + + const stats = { acted: 0, dismissed: 0, snoozed: 0, expired: 0, total: rows.length }; + for (const r of rows) { + if (r.outcome in stats) stats[r.outcome as keyof typeof stats]++; + } + return stats; +} + +/** Action rate for a nudge type (0-1). Returns null if insufficient data. */ +export async function getActionRate(nudgeType: NudgeType): Promise { + const stats = await getOutcomeStats(nudgeType); + if (stats.total < 5) return null; + return stats.acted / stats.total; +} diff --git a/apps/mana/apps/web/src/lib/companion/feedback/types.ts b/apps/mana/apps/web/src/lib/companion/feedback/types.ts new file mode 100644 index 000000000..d7d9e80cb --- /dev/null +++ b/apps/mana/apps/web/src/lib/companion/feedback/types.ts @@ -0,0 +1,18 @@ +/** + * Nudge Feedback Loop types. + * + * Tracks how users respond to nudges so the system can learn + * which nudges are helpful and when to send them. + */ + +import type { NudgeType } from '../rules/types'; + +export interface NudgeOutcome { + id?: number; // auto-increment + nudgeId: string; + nudgeType: NudgeType; + outcome: 'acted' | 'dismissed' | 'snoozed' | 'expired'; + /** Milliseconds between nudge shown and user reaction */ + latencyMs?: number; + timestamp: string; +} diff --git a/apps/mana/apps/web/src/lib/companion/goals/index.ts b/apps/mana/apps/web/src/lib/companion/goals/index.ts new file mode 100644 index 000000000..3d7e90869 --- /dev/null +++ b/apps/mana/apps/web/src/lib/companion/goals/index.ts @@ -0,0 +1,4 @@ +export { goalStore, startGoalTracker, stopGoalTracker } from './store'; +export { useActiveGoals, useAllGoals } from './queries'; +export { GOAL_TEMPLATES } from './types'; +export type { LocalGoal, GoalTemplate, GoalMetric, GoalTarget } from './types'; diff --git a/apps/mana/apps/web/src/lib/companion/goals/queries.ts b/apps/mana/apps/web/src/lib/companion/goals/queries.ts new file mode 100644 index 000000000..ab6735afa --- /dev/null +++ b/apps/mana/apps/web/src/lib/companion/goals/queries.ts @@ -0,0 +1,23 @@ +/** + * Goal Queries — Reactive reads for the Goal system. + */ + +import { useLiveQueryWithDefault } from '@mana/local-store/svelte'; +import { db } from '$lib/data/database'; +import type { LocalGoal } from './types'; + +const TABLE = 'companionGoals'; + +export function useActiveGoals() { + return useLiveQueryWithDefault(async () => { + const all = await db.table(TABLE).toArray(); + return all.filter((g) => g.status === 'active' && !g.deletedAt); + }, []); +} + +export function useAllGoals() { + return useLiveQueryWithDefault(async () => { + const all = await db.table(TABLE).toArray(); + return all.filter((g) => !g.deletedAt); + }, []); +} diff --git a/apps/mana/apps/web/src/lib/companion/goals/store.ts b/apps/mana/apps/web/src/lib/companion/goals/store.ts new file mode 100644 index 000000000..16d03d8eb --- /dev/null +++ b/apps/mana/apps/web/src/lib/companion/goals/store.ts @@ -0,0 +1,177 @@ +/** + * Goal Store — CRUD + event-driven progress tracking. + * + * Goals are persisted in the companionGoals Dexie table. Progress + * is tracked by subscribing to the domain event bus and incrementing + * currentValue when matching events arrive. + */ + +import { db } from '$lib/data/database'; +import { eventBus } from '$lib/data/events/event-bus'; +import { emitDomainEvent } from '$lib/data/events/emit'; +import type { DomainEvent } from '$lib/data/events/types'; +import type { LocalGoal, GoalTemplate } from './types'; + +const TABLE = 'companionGoals'; + +function periodStart(period: 'day' | 'week' | 'month'): string { + const now = new Date(); + if (period === 'day') return now.toISOString().split('T')[0]; + if (period === 'week') { + const d = new Date(now); + d.setDate(d.getDate() - d.getDay() + (d.getDay() === 0 ? -6 : 1)); // Monday + return d.toISOString().split('T')[0]; + } + // month + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`; +} + +function matchesGoal(goal: LocalGoal, event: DomainEvent): boolean { + if (event.type !== goal.metric.eventType) return false; + if (goal.metric.filterField && goal.metric.filterValue) { + const payload = event.payload as Record; + if (String(payload[goal.metric.filterField]) !== goal.metric.filterValue) return false; + } + return true; +} + +function getIncrement(goal: LocalGoal, event: DomainEvent): number { + if (goal.metric.source === 'event_count') return 1; + if (goal.metric.source === 'event_sum' && goal.metric.sumField) { + const payload = event.payload as Record; + const val = payload[goal.metric.sumField]; + return typeof val === 'number' ? val : 0; + } + return 0; +} + +// ── Event subscription ────────────────────────────── + +let unsubscribe: (() => void) | null = null; + +export function startGoalTracker(): void { + if (unsubscribe) return; + unsubscribe = eventBus.onAny(async (event: DomainEvent) => { + const goals = await db.table(TABLE).toArray(); + const active = goals.filter((g) => g.status === 'active' && !g.deletedAt); + + for (const goal of active) { + if (!matchesGoal(goal, event)) continue; + + // Reset if period rolled over + const currentPeriod = periodStart(goal.target.period); + const needsReset = goal.currentPeriodStart !== currentPeriod; + const newValue = (needsReset ? 0 : goal.currentValue) + getIncrement(goal, event); + + await db.table(TABLE).update(goal.id, { + currentValue: newValue, + currentPeriodStart: currentPeriod, + updatedAt: new Date().toISOString(), + }); + + // Check if goal reached + const reached = + goal.target.comparison === 'gte' + ? newValue >= goal.target.value + : newValue <= goal.target.value; + + if (reached && (needsReset || goal.currentValue < goal.target.value)) { + emitDomainEvent('GoalReached', 'companion', TABLE, goal.id, { + goalId: goal.id, + title: goal.title, + value: newValue, + target: goal.target.value, + period: goal.target.period, + }); + } + } + }); +} + +export function stopGoalTracker(): void { + unsubscribe?.(); + unsubscribe = null; +} + +// ── CRUD ──────────────────────────────────────────── + +export const goalStore = { + async createFromTemplate(template: GoalTemplate): Promise { + const now = new Date().toISOString(); + const goal: LocalGoal = { + id: crypto.randomUUID(), + title: template.title, + description: template.description, + metric: template.metric, + target: template.target, + moduleId: template.moduleId, + status: 'active', + currentValue: 0, + currentPeriodStart: periodStart(template.target.period), + createdAt: now, + updatedAt: now, + }; + await db.table(TABLE).add(goal); + return goal; + }, + + async create(input: { + title: string; + description?: string; + moduleId: string; + metric: LocalGoal['metric']; + target: LocalGoal['target']; + }): Promise { + const now = new Date().toISOString(); + const goal: LocalGoal = { + id: crypto.randomUUID(), + title: input.title, + description: input.description, + metric: input.metric, + target: input.target, + moduleId: input.moduleId, + status: 'active', + currentValue: 0, + currentPeriodStart: periodStart(input.target.period), + createdAt: now, + updatedAt: now, + }; + await db.table(TABLE).add(goal); + return goal; + }, + + async pause(id: string): Promise { + await db.table(TABLE).update(id, { + status: 'paused', + updatedAt: new Date().toISOString(), + }); + }, + + async resume(id: string): Promise { + await db.table(TABLE).update(id, { + status: 'active', + updatedAt: new Date().toISOString(), + }); + }, + + async complete(id: string): Promise { + await db.table(TABLE).update(id, { + status: 'completed', + updatedAt: new Date().toISOString(), + }); + }, + + async abandon(id: string): Promise { + await db.table(TABLE).update(id, { + status: 'abandoned', + updatedAt: new Date().toISOString(), + }); + }, + + async delete(id: string): Promise { + await db.table(TABLE).update(id, { + deletedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + }, +}; diff --git a/apps/mana/apps/web/src/lib/companion/goals/types.ts b/apps/mana/apps/web/src/lib/companion/goals/types.ts new file mode 100644 index 000000000..abf66f7e9 --- /dev/null +++ b/apps/mana/apps/web/src/lib/companion/goals/types.ts @@ -0,0 +1,121 @@ +/** + * Goal System types for the Companion Brain. + * + * Goals connect modules via metrics ("4x Sport/Woche", "8 Glaeser + * Wasser/Tag"). Progress is tracked by subscribing to domain events. + */ + +export interface LocalGoal { + id: string; + title: string; + description?: string; + + /** How to measure progress */ + metric: GoalMetric; + /** What counts as success */ + target: GoalTarget; + + /** Primary module */ + moduleId: string; + + status: 'active' | 'paused' | 'completed' | 'abandoned'; + + /** Current period progress (resets each period) */ + currentValue: number; + currentPeriodStart: string; // ISO date + + createdAt: string; + updatedAt: string; + deletedAt?: string; +} + +export interface GoalMetric { + /** How to aggregate */ + source: 'event_count' | 'event_sum'; + /** Which domain event to count/sum */ + eventType: string; + /** Optional filter: only count events where payload[filterField] === filterValue */ + filterField?: string; + filterValue?: string; + /** For event_sum: which payload field to sum */ + sumField?: string; +} + +export interface GoalTarget { + value: number; + period: 'day' | 'week' | 'month'; + /** gte = at least, lte = at most */ + comparison: 'gte' | 'lte'; +} + +// ── Templates ─────────────────────────────────────── + +export interface GoalTemplate { + id: string; + title: string; + description: string; + moduleId: string; + metric: GoalMetric; + target: GoalTarget; +} + +export const GOAL_TEMPLATES: GoalTemplate[] = [ + { + id: 'tpl-water-daily', + title: '8 Glaeser Wasser am Tag', + description: 'Taeglich 2000ml Wasser trinken', + moduleId: 'drink', + metric: { + source: 'event_count', + eventType: 'DrinkLogged', + filterField: 'drinkType', + filterValue: 'water', + }, + target: { value: 8, period: 'day', comparison: 'gte' }, + }, + { + id: 'tpl-tasks-daily', + title: '5 Tasks pro Tag', + description: 'Jeden Tag mindestens 5 Tasks erledigen', + moduleId: 'todo', + metric: { source: 'event_count', eventType: 'TaskCompleted' }, + target: { value: 5, period: 'day', comparison: 'gte' }, + }, + { + id: 'tpl-meals-daily', + title: 'Alle Mahlzeiten tracken', + description: 'Mindestens 3 Mahlzeiten pro Tag erfassen', + moduleId: 'nutriphi', + metric: { source: 'event_count', eventType: 'MealLogged' }, + target: { value: 3, period: 'day', comparison: 'gte' }, + }, + { + id: 'tpl-calories-daily', + title: 'Kalorien-Ziel einhalten', + description: 'Maximal 2000 kcal pro Tag', + moduleId: 'nutriphi', + metric: { source: 'event_sum', eventType: 'MealLogged', sumField: 'calories' }, + target: { value: 2000, period: 'day', comparison: 'lte' }, + }, + { + id: 'tpl-places-weekly', + title: 'Neue Orte entdecken', + description: 'Mindestens 3 verschiedene Orte pro Woche besuchen', + moduleId: 'places', + metric: { source: 'event_count', eventType: 'PlaceVisited' }, + target: { value: 3, period: 'week', comparison: 'gte' }, + }, + { + id: 'tpl-coffee-limit', + title: 'Kaffee-Limit', + description: 'Maximal 3 Kaffee pro Tag', + moduleId: 'drink', + metric: { + source: 'event_count', + eventType: 'DrinkLogged', + filterField: 'drinkType', + filterValue: 'coffee', + }, + target: { value: 3, period: 'day', comparison: 'lte' }, + }, +]; diff --git a/apps/mana/apps/web/src/lib/companion/rules/engine.ts b/apps/mana/apps/web/src/lib/companion/rules/engine.ts new file mode 100644 index 000000000..bcbaf6529 --- /dev/null +++ b/apps/mana/apps/web/src/lib/companion/rules/engine.ts @@ -0,0 +1,139 @@ +/** + * Pulse Rule Engine — Evaluates rules against projections and produces nudges. + * + * Integrates as a ReminderSource into the existing reminder scheduler. + * Interval rules run every N minutes; schedule rules run once at specific hours. + */ + +import { db } from '$lib/data/database'; +import type { DaySnapshot, StreakInfo } from '$lib/data/projections/types'; +import type { LocalGoal } from '../goals/types'; +import type { PulseRule, RuleContext, Nudge } from './types'; +import { DEFAULT_RULES } from './rules'; + +const DISMISSED_KEY = 'mana:dismissed-nudges'; +const LAST_RUN_KEY = 'mana:pulse-last-run'; + +function getDismissed(): Set { + try { + const raw = localStorage.getItem(DISMISSED_KEY); + return raw ? new Set(JSON.parse(raw) as string[]) : new Set(); + } catch { + return new Set(); + } +} + +export function dismissNudge(nudgeId: string): void { + const dismissed = getDismissed(); + dismissed.add(nudgeId); + // Keep only last 200 entries + const arr = [...dismissed].slice(-200); + localStorage.setItem(DISMISSED_KEY, JSON.stringify(arr)); +} + +function getLastRun(): Record { + try { + const raw = localStorage.getItem(LAST_RUN_KEY); + return raw ? (JSON.parse(raw) as Record) : {}; + } catch { + return {}; + } +} + +function setLastRun(ruleId: string, timestamp: number): void { + const runs = getLastRun(); + runs[ruleId] = timestamp; + localStorage.setItem(LAST_RUN_KEY, JSON.stringify(runs)); +} + +function shouldRun(rule: PulseRule, now: Date): boolean { + const lastRun = getLastRun(); + const lastMs = lastRun[rule.id] ?? 0; + const elapsedMs = now.getTime() - lastMs; + + if (rule.trigger.kind === 'interval') { + return elapsedMs >= rule.trigger.minutes * 60 * 1000; + } + + if (rule.trigger.kind === 'schedule') { + const hour = now.getHours(); + if (!rule.trigger.hours.includes(hour)) return false; + // Only run once per hour slot + const lastHour = lastMs > 0 ? new Date(lastMs).getHours() : -1; + const lastDate = lastMs > 0 ? new Date(lastMs).toDateString() : ''; + return lastHour !== hour || lastDate !== now.toDateString(); + } + + return false; +} + +/** + * Run all rules and return any nudges that should be shown. + * + * @param day - Current DaySnapshot + * @param streaks - Current streaks + * @param goals - Active goals + * @param rules - Rules to evaluate (defaults to built-in rules) + */ +export function evaluateRules( + day: DaySnapshot, + streaks: StreakInfo[], + goals: LocalGoal[], + rules: PulseRule[] = DEFAULT_RULES +): Nudge[] { + const now = new Date(); + const dismissed = getDismissed(); + const ctx: RuleContext = { + day, + streaks, + goals, + now, + hour: now.getHours(), + }; + + const nudges: Nudge[] = []; + + for (const rule of rules) { + if (!shouldRun(rule, now)) continue; + + const nudge = rule.check(ctx); + setLastRun(rule.id, now.getTime()); + + if (nudge && !dismissed.has(nudge.id)) { + nudges.push(nudge); + } + } + + return nudges; +} + +/** + * Create a ReminderSource adapter for the existing reminder scheduler. + * + * This bridges the Pulse Rule Engine into the existing infrastructure + * so nudges appear as OS notifications via the notification service. + */ +export function createPulseReminderSource( + getDay: () => DaySnapshot, + getStreaks: () => StreakInfo[], + getGoals: () => LocalGoal[] +) { + return { + id: 'companion-pulse', + + async checkDue() { + const nudges = evaluateRules(getDay(), getStreaks(), getGoals()); + return nudges.map((n) => ({ + id: n.id, + title: n.title, + body: n.body, + tag: `pulse-${n.type}`, + })); + }, + + async markSent(id: string) { + // Nudges are one-shot per ID (date+hour encoded) + // No additional tracking needed beyond the lastRun mechanism + }, + }; +} diff --git a/apps/mana/apps/web/src/lib/companion/rules/index.ts b/apps/mana/apps/web/src/lib/companion/rules/index.ts new file mode 100644 index 000000000..1f5bff52f --- /dev/null +++ b/apps/mana/apps/web/src/lib/companion/rules/index.ts @@ -0,0 +1,3 @@ +export { evaluateRules, createPulseReminderSource, dismissNudge } from './engine'; +export { DEFAULT_RULES } from './rules'; +export type { PulseRule, Nudge, NudgeType, RuleContext } from './types'; diff --git a/apps/mana/apps/web/src/lib/companion/rules/rules.ts b/apps/mana/apps/web/src/lib/companion/rules/rules.ts new file mode 100644 index 000000000..ff1780394 --- /dev/null +++ b/apps/mana/apps/web/src/lib/companion/rules/rules.ts @@ -0,0 +1,153 @@ +/** + * Built-in Pulse Rules for the 5 pilot modules. + */ + +import type { PulseRule } from './types'; + +export const waterReminderRule: PulseRule = { + id: 'water-reminder', + name: 'Wasser-Erinnerung', + trigger: { kind: 'interval', minutes: 90 }, + check(ctx) { + const { water } = ctx.day.drinks; + if (water.percent >= 100) return null; + if (ctx.hour < 8 || ctx.hour > 21) return null; + + const remaining = water.goal - water.ml; + const hoursLeft = Math.max(21 - ctx.hour, 1); + const mlPerHour = Math.ceil(remaining / hoursLeft); + + return { + id: `water-${ctx.day.date}-${ctx.hour}`, + type: 'water_reminder', + title: 'Wasser trinken', + body: `Noch ${remaining}ml bis zum Ziel (~${mlPerHour}ml/Stunde).`, + priority: water.percent < 50 ? 'medium' : 'low', + actionLabel: 'Glas loggen', + actionRoute: '/drink', + }; + }, +}; + +export const streakWarningRule: PulseRule = { + id: 'streak-warning', + name: 'Streak-Warnung', + trigger: { kind: 'schedule', hours: [18] }, + 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 verlieren.`, + priority: best.currentStreak > 7 ? 'high' : 'medium', + }; + }, +}; + +export const morningSummaryRule: PulseRule = { + id: 'morning-summary', + name: 'Morgen-Zusammenfassung', + trigger: { kind: 'schedule', hours: [8] }, + check(ctx) { + const { tasks, events } = ctx.day; + const parts: string[] = []; + + if (tasks.dueToday.length > 0) { + parts.push(`${tasks.dueToday.length} Tasks faellig`); + } + if (tasks.overdue > 0) { + parts.push(`${tasks.overdue} ueberfaellig`); + } + if (events.total > 0) { + parts.push(`${events.total} Termine`); + } + + if (parts.length === 0) { + return { + id: `morning-${ctx.day.date}`, + type: 'morning_summary', + title: 'Guten Morgen!', + body: 'Keine Tasks oder Termine heute — freier Tag.', + priority: 'low', + }; + } + + return { + id: `morning-${ctx.day.date}`, + type: 'morning_summary', + title: 'Guten Morgen!', + body: `Heute: ${parts.join(', ')}.`, + priority: tasks.overdue > 0 ? 'medium' : 'low', + }; + }, +}; + +export const overdueTasksRule: PulseRule = { + id: 'overdue-tasks', + name: 'Ueberfaellige Tasks', + trigger: { kind: 'schedule', hours: [10, 15] }, + check(ctx) { + if (ctx.day.tasks.overdue === 0) return null; + + return { + id: `overdue-${ctx.day.date}-${ctx.hour}`, + type: 'overdue_tasks', + title: `${ctx.day.tasks.overdue} ueberfaellige Tasks`, + body: 'Erledigen oder verschieben?', + priority: ctx.day.tasks.overdue > 3 ? 'high' : 'medium', + actionLabel: 'Tasks anzeigen', + actionRoute: '/todo', + }; + }, +}; + +export const mealReminderRule: PulseRule = { + id: 'meal-reminder', + name: 'Mahlzeit-Erinnerung', + trigger: { kind: 'schedule', hours: [12, 19] }, + check(ctx) { + const { meals, calories } = ctx.day.nutrition; + + // Lunch check at 12 + if (ctx.hour === 12 && meals < 1) { + return { + id: `meal-lunch-${ctx.day.date}`, + type: 'meal_reminder', + title: 'Mittagessen tracken', + body: 'Noch keine Mahlzeit heute erfasst.', + priority: 'low', + actionLabel: 'Mahlzeit loggen', + actionRoute: '/nutriphi', + }; + } + + // Dinner check at 19 + if (ctx.hour === 19 && meals < 2) { + return { + id: `meal-dinner-${ctx.day.date}`, + type: 'meal_reminder', + title: 'Abendessen tracken', + body: `Erst ${meals} Mahlzeit(en) heute (${calories.actual} kcal).`, + priority: 'low', + actionLabel: 'Mahlzeit loggen', + actionRoute: '/nutriphi', + }; + } + + return null; + }, +}; + +/** All built-in rules */ +export const DEFAULT_RULES: PulseRule[] = [ + waterReminderRule, + streakWarningRule, + morningSummaryRule, + overdueTasksRule, + mealReminderRule, +]; diff --git a/apps/mana/apps/web/src/lib/companion/rules/types.ts b/apps/mana/apps/web/src/lib/companion/rules/types.ts new file mode 100644 index 000000000..a9b4ba7d1 --- /dev/null +++ b/apps/mana/apps/web/src/lib/companion/rules/types.ts @@ -0,0 +1,50 @@ +/** + * Pulse Rule Engine types. + * + * Rules are deterministic (no LLM) and produce Nudges from projections. + */ + +import type { DaySnapshot, StreakInfo } from '$lib/data/projections/types'; +import type { LocalGoal } from '../goals/types'; + +export interface PulseRule { + id: string; + name: string; + /** When to check */ + trigger: { kind: 'interval'; minutes: number } | { kind: 'schedule'; hours: number[] }; // e.g. [8, 18] = 08:00 and 18:00 + /** Returns a Nudge if action needed, null otherwise */ + check: (ctx: RuleContext) => Nudge | null; +} + +export interface RuleContext { + day: DaySnapshot; + streaks: StreakInfo[]; + goals: LocalGoal[]; + now: Date; + hour: number; // 0-23 +} + +export interface Nudge { + id: string; + type: NudgeType; + title: string; + body: string; + priority: 'low' | 'medium' | 'high'; + /** Button label */ + actionLabel?: string; + /** Route to navigate to */ + actionRoute?: string; + /** Tool name for Companion to execute */ + actionTool?: string; + /** When this nudge becomes irrelevant */ + expiresAt?: string; +} + +export type NudgeType = + | 'streak_warning' + | 'goal_progress' + | 'goal_reached' + | 'morning_summary' + | 'overdue_tasks' + | 'water_reminder' + | 'meal_reminder'; diff --git a/apps/mana/apps/web/src/lib/data/database.ts b/apps/mana/apps/web/src/lib/data/database.ts index 6b647a779..c0599fc03 100644 --- a/apps/mana/apps/web/src/lib/data/database.ts +++ b/apps/mana/apps/web/src/lib/data/database.ts @@ -426,6 +426,10 @@ db.version(9).stores({ db.version(10).stores({ _events: '++seq, type, meta.appId, meta.timestamp, meta.recordId, [meta.appId+meta.timestamp], [type+meta.timestamp]', + // Companion Brain: Goals, Memory, Feedback + companionGoals: 'id, moduleId, status, [moduleId+status]', + _memory: 'id, category, confidence, lastConfirmed, [category+confidence]', + _nudgeOutcomes: '++id, nudgeId, nudgeType, outcome, timestamp, [nudgeType+outcome]', }); // Schema version 11 — adds the Mail module (local draft cache).