mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
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) <noreply@anthropic.com>
This commit is contained in:
parent
a3de6b3d81
commit
9066b6c9ae
12 changed files with 748 additions and 0 deletions
2
apps/mana/apps/web/src/lib/companion/feedback/index.ts
Normal file
2
apps/mana/apps/web/src/lib/companion/feedback/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { recordOutcome, getOutcomeStats, getActionRate } from './tracker';
|
||||
export type { NudgeOutcome } from './types';
|
||||
54
apps/mana/apps/web/src/lib/companion/feedback/tracker.ts
Normal file
54
apps/mana/apps/web/src/lib/companion/feedback/tracker.ts
Normal file
|
|
@ -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<void> {
|
||||
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<number | null> {
|
||||
const stats = await getOutcomeStats(nudgeType);
|
||||
if (stats.total < 5) return null;
|
||||
return stats.acted / stats.total;
|
||||
}
|
||||
18
apps/mana/apps/web/src/lib/companion/feedback/types.ts
Normal file
18
apps/mana/apps/web/src/lib/companion/feedback/types.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
4
apps/mana/apps/web/src/lib/companion/goals/index.ts
Normal file
4
apps/mana/apps/web/src/lib/companion/goals/index.ts
Normal file
|
|
@ -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';
|
||||
23
apps/mana/apps/web/src/lib/companion/goals/queries.ts
Normal file
23
apps/mana/apps/web/src/lib/companion/goals/queries.ts
Normal file
|
|
@ -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<LocalGoal[]>(async () => {
|
||||
const all = await db.table<LocalGoal>(TABLE).toArray();
|
||||
return all.filter((g) => g.status === 'active' && !g.deletedAt);
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function useAllGoals() {
|
||||
return useLiveQueryWithDefault<LocalGoal[]>(async () => {
|
||||
const all = await db.table<LocalGoal>(TABLE).toArray();
|
||||
return all.filter((g) => !g.deletedAt);
|
||||
}, []);
|
||||
}
|
||||
177
apps/mana/apps/web/src/lib/companion/goals/store.ts
Normal file
177
apps/mana/apps/web/src/lib/companion/goals/store.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<LocalGoal>(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<LocalGoal> {
|
||||
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<LocalGoal> {
|
||||
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<void> {
|
||||
await db.table(TABLE).update(id, {
|
||||
status: 'paused',
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
async resume(id: string): Promise<void> {
|
||||
await db.table(TABLE).update(id, {
|
||||
status: 'active',
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
async complete(id: string): Promise<void> {
|
||||
await db.table(TABLE).update(id, {
|
||||
status: 'completed',
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
async abandon(id: string): Promise<void> {
|
||||
await db.table(TABLE).update(id, {
|
||||
status: 'abandoned',
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await db.table(TABLE).update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
};
|
||||
121
apps/mana/apps/web/src/lib/companion/goals/types.ts
Normal file
121
apps/mana/apps/web/src/lib/companion/goals/types.ts
Normal file
|
|
@ -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' },
|
||||
},
|
||||
];
|
||||
139
apps/mana/apps/web/src/lib/companion/rules/engine.ts
Normal file
139
apps/mana/apps/web/src/lib/companion/rules/engine.ts
Normal file
|
|
@ -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<string> {
|
||||
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<string, number> {
|
||||
try {
|
||||
const raw = localStorage.getItem(LAST_RUN_KEY);
|
||||
return raw ? (JSON.parse(raw) as Record<string, number>) : {};
|
||||
} 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
|
||||
},
|
||||
};
|
||||
}
|
||||
3
apps/mana/apps/web/src/lib/companion/rules/index.ts
Normal file
3
apps/mana/apps/web/src/lib/companion/rules/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { evaluateRules, createPulseReminderSource, dismissNudge } from './engine';
|
||||
export { DEFAULT_RULES } from './rules';
|
||||
export type { PulseRule, Nudge, NudgeType, RuleContext } from './types';
|
||||
153
apps/mana/apps/web/src/lib/companion/rules/rules.ts
Normal file
153
apps/mana/apps/web/src/lib/companion/rules/rules.ts
Normal file
|
|
@ -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,
|
||||
];
|
||||
50
apps/mana/apps/web/src/lib/companion/rules/types.ts
Normal file
50
apps/mana/apps/web/src/lib/companion/rules/types.ts
Normal file
|
|
@ -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';
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue