feat(brain): add Projection Engine with DaySnapshot, Streaks, and Context Document

Phase 2 of the Companion Brain. Adds live-reactive projections that
aggregate data across all 5 pilot modules into high-level views:

- DaySnapshot: today's tasks (total/completed/overdue/due), calendar
  events (upcoming/next), drink intake (water/coffee/total with goals),
  nutrition (meals/calories/protein with goals), places visited
- Streaks: consecutive-day tracking for water goal, task completion,
  and meal logging with active/at_risk/broken status (90-day lookback)
- Context Document: ~500 token markdown generator combining DaySnapshot
  + Streaks for LLM system prompts

Also wires startEventStore() into the app layout so domain events
from Phase 1 are persisted to IndexedDB on every module mutation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-13 20:35:36 +02:00
parent aabf130480
commit 40e1145e9f
6 changed files with 540 additions and 0 deletions

View file

@ -0,0 +1,107 @@
/**
* Context Document Generator Produces a ~500 token text snapshot
* of the user's current state for use as an LLM system prompt.
*
* Combines DaySnapshot + Streaks into a structured markdown string
* that any LLM tier (local Gemma or cloud) can reason over.
*/
import type { DaySnapshot, StreakInfo } from './types';
function formatTime(iso: string): string {
try {
return new Date(iso).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
} catch {
return iso.slice(11, 16);
}
}
/**
* Generate a concise user context document.
*
* @param day - Today's snapshot
* @param streaks - Current streak info
* @returns Markdown string (~300-500 tokens)
*/
export function generateContextDocument(day: DaySnapshot, streaks: StreakInfo[]): string {
const lines: string[] = [];
lines.push(`## Nutzer-Kontext (${day.date})\n`);
// ── Today ───────────────────────────────────────
lines.push('### Heute');
// Tasks
const taskLine = `- ${day.tasks.total} offene Tasks`;
const extras: string[] = [];
if (day.tasks.completed > 0) extras.push(`${day.tasks.completed} erledigt`);
if (day.tasks.overdue > 0) extras.push(`${day.tasks.overdue} ueberfaellig`);
if (day.tasks.dueToday.length > 0) extras.push(`${day.tasks.dueToday.length} heute faellig`);
lines.push(extras.length > 0 ? `${taskLine} (${extras.join(', ')})` : taskLine);
if (day.tasks.dueToday.length > 0) {
for (const t of day.tasks.dueToday.slice(0, 5)) {
lines.push(` - "${t.title}"${t.priority === 'high' ? ' (hohe Prioritaet)' : ''}`);
}
}
// Events
if (day.events.total > 0) {
lines.push(`- ${day.events.total} Termine`);
if (day.events.nextEvent) {
const e = day.events.nextEvent;
lines.push(` - Naechster: "${e.title}" um ${formatTime(e.startTime)}`);
}
for (const e of day.events.upcoming.slice(1, 4)) {
lines.push(` - "${e.title}" ${formatTime(e.startTime)}-${formatTime(e.endTime)}`);
}
} else {
lines.push('- Keine Termine');
}
// Drinks
lines.push(
`- Wasser: ${day.drinks.water.ml}ml / ${day.drinks.water.goal}ml (${day.drinks.water.percent}%)`
);
if (day.drinks.coffee.count > 0) {
lines.push(`- Kaffee: ${day.drinks.coffee.count}x (${day.drinks.coffee.ml}ml)`);
}
// Nutrition
lines.push(
`- Ernaehrung: ${day.nutrition.meals} Mahlzeiten, ${day.nutrition.calories.actual} / ${day.nutrition.calories.goal} kcal (${day.nutrition.calories.percent}%)`
);
if (day.nutrition.protein) {
lines.push(` - Protein: ${day.nutrition.protein.actual}g / ${day.nutrition.protein.goal}g`);
}
// Places
if (day.places.visitedToday > 0) {
lines.push(`- ${day.places.visitedToday} Orte besucht`);
}
if (day.places.tracking) {
lines.push('- Standort-Tracking aktiv');
}
// ── Streaks ─────────────────────────────────────
const activeStreaks = streaks.filter((s) => s.status === 'active');
const atRisk = streaks.filter((s) => s.status === 'at_risk');
const broken = streaks.filter((s) => s.status === 'broken' && s.currentStreak === 0);
if (activeStreaks.length > 0 || atRisk.length > 0) {
lines.push('\n### Streaks');
for (const s of activeStreaks) {
lines.push(`- ${s.label}: ${s.currentStreak} Tage (aktiv)`);
}
for (const s of atRisk) {
lines.push(`- ${s.label}: ${s.currentStreak} Tage (GEFAEHRDET — heute noch nicht aktiv)`);
}
for (const s of broken) {
if (s.longestStreak > 0) {
lines.push(`- ${s.label}: unterbrochen (Rekord: ${s.longestStreak} Tage)`);
}
}
}
return lines.join('\n');
}

View file

@ -0,0 +1,194 @@
/**
* DaySnapshot Live-reactive aggregation of today's data across modules.
*
* Answers: "What's happening today?" by querying all 5 pilot modules
* and returning a single flat object. Updates automatically when any
* underlying Dexie table changes (via liveQuery subscriptions).
*
* Usage in Svelte components:
* const day = useDaySnapshot();
* // day.value.tasks.completed, day.value.drinks.water.percent, ...
*/
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '../database';
import { decryptRecords } from '../crypto';
import { DEFAULT_DAILY_GOAL_ML } from '$lib/modules/drink/types';
import { DEFAULT_DAILY_VALUES } from '$lib/modules/nutriphi/constants';
import { trackingStore } from '$lib/modules/places/stores/tracking.svelte';
import type { LocalTask } from '$lib/modules/todo/types';
import type { LocalEvent } from '$lib/modules/calendar/types';
import type { LocalDrinkEntry } from '$lib/modules/drink/types';
import type { LocalMeal, LocalGoal as NutriGoal } from '$lib/modules/nutriphi/types';
import type { LocalPlace } from '$lib/modules/places/types';
import type { LocalTimeBlock } from '../time-blocks/types';
import type { DaySnapshot, TaskSummary, EventSummary } from './types';
function todayStr(): string {
return new Date().toISOString().split('T')[0];
}
function emptySnapshot(date: string): DaySnapshot {
return {
date,
tasks: { total: 0, completed: 0, overdue: 0, dueToday: [] },
events: { upcoming: [], total: 0, nextEvent: null },
drinks: {
water: { ml: 0, goal: DEFAULT_DAILY_GOAL_ML, percent: 0 },
coffee: { ml: 0, count: 0 },
total: { ml: 0, count: 0 },
},
nutrition: {
meals: 0,
calories: { actual: 0, goal: DEFAULT_DAILY_VALUES.calories, percent: 0 },
protein: null,
},
places: { visitedToday: 0, tracking: false },
};
}
async function buildSnapshot(): Promise<DaySnapshot> {
const today = todayStr();
const now = new Date().toISOString();
// ── Tasks ───────────────────────────────────────
const allTasks = await db.table<LocalTask>('tasks').toArray();
const activeTasks = allTasks.filter((t) => !t.deletedAt);
const decryptedTasks = await decryptRecords<LocalTask>('tasks', activeTasks);
const completedCount = decryptedTasks.filter((t) => t.isCompleted).length;
const overdue = decryptedTasks.filter(
(t) => !t.isCompleted && t.dueDate != null && (t.dueDate as string) < today
);
const dueToday = decryptedTasks.filter((t) => !t.isCompleted && (t.dueDate as string) === today);
const dueTodaySummaries: TaskSummary[] = dueToday.map((t) => ({
id: t.id,
title: (t.title as string) ?? '',
priority: t.priority as string | undefined,
projectId: t.projectId as string | undefined,
}));
// ── Calendar Events ─────────────────────────────
const todayStart = `${today}T00:00:00`;
const todayEnd = `${today}T23:59:59`;
const blocks = await db
.table<LocalTimeBlock>('timeBlocks')
.where('startDate')
.between(todayStart, todayEnd + '\uffff')
.toArray();
const eventBlocks = blocks.filter(
(b) => !b.deletedAt && b.type === 'event' && b.sourceModule === 'calendar'
);
const decryptedBlocks = await decryptRecords<LocalTimeBlock>('timeBlocks', eventBlocks);
const eventSummaries: EventSummary[] = decryptedBlocks
.sort((a, b) => (a.startDate as string).localeCompare(b.startDate as string))
.map((b) => ({
id: b.sourceId,
title: (b.title as string) ?? '',
startTime: b.startDate,
endTime: b.endDate ?? b.startDate,
isAllDay: b.allDay ?? false,
calendarId: '',
}));
const upcomingEvents = eventSummaries.filter((e) => e.startTime >= now).slice(0, 5);
const nextEvent = upcomingEvents[0] ?? null;
// ── Drinks ──────────────────────────────────────
const allDrinks = await db.table<LocalDrinkEntry>('drinkEntries').toArray();
const todayDrinks = allDrinks.filter((d) => !d.deletedAt && d.date === today);
const decryptedDrinks = await decryptRecords<LocalDrinkEntry>('drinkEntries', todayDrinks);
let waterMl = 0;
let coffeeMl = 0;
let coffeeCount = 0;
let totalMl = 0;
let totalCount = 0;
for (const d of decryptedDrinks) {
const ml = d.quantityMl ?? 0;
totalMl += ml;
totalCount++;
if (d.drinkType === 'water') waterMl += ml;
if (d.drinkType === 'coffee') {
coffeeMl += ml;
coffeeCount++;
}
}
// ── Nutrition ───────────────────────────────────
const allMeals = await db.table<LocalMeal>('meals').toArray();
const todayMeals = allMeals.filter((m) => !m.deletedAt && m.date === today);
const decryptedMeals = await decryptRecords<LocalMeal>('meals', todayMeals);
let totalCalories = 0;
let totalProtein = 0;
for (const m of decryptedMeals) {
const n = m.nutrition as { calories?: number; protein?: number } | null;
if (n) {
totalCalories += n.calories ?? 0;
totalProtein += n.protein ?? 0;
}
}
const nutriGoals = await db.table<NutriGoal>('goals').toArray();
const activeGoal = nutriGoals.find((g) => !g.deletedAt);
const calorieGoal = activeGoal?.dailyCalories ?? DEFAULT_DAILY_VALUES.calories;
const proteinGoal = activeGoal?.dailyProtein;
// ── Places ──────────────────────────────────────
const allPlaces = await db.table<LocalPlace>('places').toArray();
const visitedToday = allPlaces.filter(
(p) => !p.deletedAt && p.lastVisitedAt && (p.lastVisitedAt as string).startsWith(today)
).length;
return {
date: today,
tasks: {
total: decryptedTasks.filter((t) => !t.isCompleted).length,
completed: completedCount,
overdue: overdue.length,
dueToday: dueTodaySummaries,
},
events: {
upcoming: upcomingEvents,
total: eventSummaries.length,
nextEvent,
},
drinks: {
water: {
ml: waterMl,
goal: DEFAULT_DAILY_GOAL_ML,
percent: Math.round((waterMl / DEFAULT_DAILY_GOAL_ML) * 100),
},
coffee: { ml: coffeeMl, count: coffeeCount },
total: { ml: totalMl, count: totalCount },
},
nutrition: {
meals: decryptedMeals.length,
calories: {
actual: Math.round(totalCalories),
goal: calorieGoal,
percent: Math.min(Math.round((totalCalories / calorieGoal) * 100), 100),
},
protein: proteinGoal ? { actual: Math.round(totalProtein), goal: proteinGoal } : null,
},
places: {
visitedToday,
tracking: trackingStore.isTracking,
},
};
}
/**
* Reactive DaySnapshot updates automatically when any underlying
* table changes. Use in Svelte components:
*
* ```svelte
* const day = useDaySnapshot();
* <p>{day.value.tasks.completed} Tasks erledigt</p>
* ```
*/
export function useDaySnapshot() {
return useLiveQueryWithDefault<DaySnapshot>(buildSnapshot, emptySnapshot(todayStr()));
}

View file

@ -0,0 +1,4 @@
export { useDaySnapshot } from './day-snapshot';
export { useStreaks } from './streaks';
export { generateContextDocument } from './context-document';
export type { DaySnapshot, StreakInfo, TaskSummary, EventSummary } from './types';

View file

@ -0,0 +1,160 @@
/**
* Streaks Tracks consecutive-day activity across modules.
*
* Each streak definition queries a specific module to check if "today
* counts" (e.g. water goal reached, at least 1 task completed, etc.).
* The streak engine then looks backwards through the event store to
* compute the current streak length.
*
* Status:
* active today or yesterday was active
* at_risk yesterday was NOT active, but the day before was
* broken more than 1 day gap
*/
import { db } from '../database';
import { decryptRecords } from '../crypto';
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { DEFAULT_DAILY_GOAL_ML } from '$lib/modules/drink/types';
import type { LocalTask } from '$lib/modules/todo/types';
import type { LocalDrinkEntry } from '$lib/modules/drink/types';
import type { LocalMeal } from '$lib/modules/nutriphi/types';
import type { StreakInfo } from './types';
// ── Helpers ─────────────────────────────────────────
function dateStr(d: Date): string {
return d.toISOString().split('T')[0];
}
function daysAgo(n: number): string {
const d = new Date();
d.setDate(d.getDate() - n);
return dateStr(d);
}
function daysBetween(a: string, b: string): number {
const msPerDay = 86400000;
return Math.floor((new Date(b).getTime() - new Date(a).getTime()) / msPerDay);
}
function streakStatus(lastActiveDate: string, today: string): StreakInfo['status'] {
const gap = daysBetween(lastActiveDate, today);
if (gap <= 0) return 'active'; // today
if (gap === 1) return 'at_risk'; // yesterday
return 'broken';
}
// ── Streak Definitions ──────────────────────────────
interface StreakDef {
id: string;
moduleId: string;
label: string;
/** Check if a given date "counts" as active. */
checkDate: (date: string) => Promise<boolean>;
}
const streakDefs: StreakDef[] = [
{
id: 'streak-water-goal',
moduleId: 'drink',
label: 'Wasser-Ziel',
async checkDate(date: string) {
const entries = await db.table<LocalDrinkEntry>('drinkEntries').toArray();
const dayEntries = entries.filter(
(e) => !e.deletedAt && e.date === date && e.drinkType === 'water'
);
let totalMl = 0;
for (const e of dayEntries) totalMl += e.quantityMl ?? 0;
return totalMl >= DEFAULT_DAILY_GOAL_ML;
},
},
{
id: 'streak-tasks-completed',
moduleId: 'todo',
label: 'Tasks erledigt',
async checkDate(date: string) {
const tasks = await db.table<LocalTask>('tasks').toArray();
return tasks.some(
(t) =>
!t.deletedAt &&
t.isCompleted &&
t.completedAt != null &&
(t.completedAt as string).startsWith(date)
);
},
},
{
id: 'streak-meals-logged',
moduleId: 'nutriphi',
label: 'Mahlzeiten getrackt',
async checkDate(date: string) {
const meals = await db.table<LocalMeal>('meals').toArray();
return meals.some((m) => !m.deletedAt && m.date === date);
},
},
];
// ── Streak Calculator ───────────────────────────────
const MAX_LOOKBACK = 90; // days
async function computeStreak(def: StreakDef): Promise<StreakInfo> {
const today = dateStr(new Date());
let lastActiveDate = '';
let currentStreak = 0;
let longestStreak = 0;
let runningStreak = 0;
let streakBroken = false;
for (let i = 0; i < MAX_LOOKBACK; i++) {
const date = daysAgo(i);
const active = await def.checkDate(date);
if (active) {
if (!lastActiveDate) lastActiveDate = date;
if (!streakBroken) {
currentStreak++;
}
runningStreak++;
} else {
if (!streakBroken && i > 0) {
// First gap ends the current streak
streakBroken = true;
}
if (runningStreak > longestStreak) longestStreak = runningStreak;
runningStreak = 0;
}
}
if (runningStreak > longestStreak) longestStreak = runningStreak;
if (currentStreak > longestStreak) longestStreak = currentStreak;
return {
id: def.id,
moduleId: def.moduleId,
label: def.label,
currentStreak,
longestStreak,
lastActiveDate: lastActiveDate || today,
status: lastActiveDate ? streakStatus(lastActiveDate, today) : 'broken',
};
}
async function buildAllStreaks(): Promise<StreakInfo[]> {
return Promise.all(streakDefs.map(computeStreak));
}
/**
* Reactive streak list updates when underlying tables change.
*
* ```svelte
* const streaks = useStreaks();
* {#each streaks.value as s}
* <p>{s.label}: {s.currentStreak} Tage ({s.status})</p>
* {/each}
* ```
*/
export function useStreaks() {
return useLiveQueryWithDefault<StreakInfo[]>(buildAllStreaks, []);
}

View file

@ -0,0 +1,72 @@
/**
* Projection types for the Companion Brain.
*
* Projections are live-reactive aggregations over module data.
* They answer high-level questions ("What's happening today?",
* "Which streaks are at risk?") without consumers needing to
* know which tables to query.
*/
// ── DaySnapshot ─────────────────────────────────────
export interface TaskSummary {
id: string;
title: string;
priority?: string;
projectId?: string;
}
export interface EventSummary {
id: string;
title: string;
startTime: string;
endTime: string;
isAllDay: boolean;
calendarId: string;
}
export interface DaySnapshot {
date: string; // YYYY-MM-DD
tasks: {
total: number;
completed: number;
overdue: number;
dueToday: TaskSummary[];
};
events: {
upcoming: EventSummary[];
total: number;
nextEvent: EventSummary | null;
};
drinks: {
water: { ml: number; goal: number; percent: number };
coffee: { ml: number; count: number };
total: { ml: number; count: number };
};
nutrition: {
meals: number;
calories: { actual: number; goal: number; percent: number };
protein: { actual: number; goal: number } | null;
};
places: {
visitedToday: number;
tracking: boolean;
};
}
// ── Streaks ─────────────────────────────────────────
export interface StreakInfo {
id: string;
moduleId: string;
label: string;
currentStreak: number;
longestStreak: number;
lastActiveDate: string; // YYYY-MM-DD
status: 'active' | 'at_risk' | 'broken';
}

View file

@ -5,6 +5,7 @@
import { onDestroy, setContext } from 'svelte';
import { createReminderScheduler } from '@mana/shared-stores';
import { todoReminderSource } from '$lib/modules/todo/reminder-source';
import { startEventStore, stopEventStore } from '$lib/data/events/event-store';
import KeyboardShortcutsModal from '$lib/components/KeyboardShortcutsModal.svelte';
import SessionWarning from '$lib/components/SessionWarning.svelte';
import EncryptionIntroBanner from '$lib/components/EncryptionIntroBanner.svelte';
@ -417,6 +418,7 @@
linkLocalStore.initialize(),
]);
initSharedUload();
startEventStore();
await dashboardStore.initialize();
// Start the persistent LLM task queue. Idempotent — safe to call
@ -517,6 +519,7 @@
onDestroy(() => {
unifiedSync?.stopAll();
reminderScheduler.stop();
stopEventStore();
guestMode?.destroy();
// Fire-and-forget — we don't need to await; the in-flight task
// will finish in the background and the next page session will