From 40e1145e9f9839f4cbefc3ec971bd433a47eb00f Mon Sep 17 00:00:00 2001 From: Till JS Date: Mon, 13 Apr 2026 20:35:36 +0200 Subject: [PATCH] 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) --- .../lib/data/projections/context-document.ts | 107 ++++++++++ .../src/lib/data/projections/day-snapshot.ts | 194 ++++++++++++++++++ .../web/src/lib/data/projections/index.ts | 4 + .../web/src/lib/data/projections/streaks.ts | 160 +++++++++++++++ .../web/src/lib/data/projections/types.ts | 72 +++++++ .../apps/web/src/routes/(app)/+layout.svelte | 3 + 6 files changed, 540 insertions(+) create mode 100644 apps/mana/apps/web/src/lib/data/projections/context-document.ts create mode 100644 apps/mana/apps/web/src/lib/data/projections/day-snapshot.ts create mode 100644 apps/mana/apps/web/src/lib/data/projections/index.ts create mode 100644 apps/mana/apps/web/src/lib/data/projections/streaks.ts create mode 100644 apps/mana/apps/web/src/lib/data/projections/types.ts diff --git a/apps/mana/apps/web/src/lib/data/projections/context-document.ts b/apps/mana/apps/web/src/lib/data/projections/context-document.ts new file mode 100644 index 000000000..48df36b40 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/projections/context-document.ts @@ -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'); +} diff --git a/apps/mana/apps/web/src/lib/data/projections/day-snapshot.ts b/apps/mana/apps/web/src/lib/data/projections/day-snapshot.ts new file mode 100644 index 000000000..e49241693 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/projections/day-snapshot.ts @@ -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 { + const today = todayStr(); + const now = new Date().toISOString(); + + // ── Tasks ─────────────────────────────────────── + const allTasks = await db.table('tasks').toArray(); + const activeTasks = allTasks.filter((t) => !t.deletedAt); + const decryptedTasks = await decryptRecords('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('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('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('drinkEntries').toArray(); + const todayDrinks = allDrinks.filter((d) => !d.deletedAt && d.date === today); + const decryptedDrinks = await decryptRecords('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('meals').toArray(); + const todayMeals = allMeals.filter((m) => !m.deletedAt && m.date === today); + const decryptedMeals = await decryptRecords('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('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('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(); + *

{day.value.tasks.completed} Tasks erledigt

+ * ``` + */ +export function useDaySnapshot() { + return useLiveQueryWithDefault(buildSnapshot, emptySnapshot(todayStr())); +} diff --git a/apps/mana/apps/web/src/lib/data/projections/index.ts b/apps/mana/apps/web/src/lib/data/projections/index.ts new file mode 100644 index 000000000..95d2f408b --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/projections/index.ts @@ -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'; diff --git a/apps/mana/apps/web/src/lib/data/projections/streaks.ts b/apps/mana/apps/web/src/lib/data/projections/streaks.ts new file mode 100644 index 000000000..18865d10b --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/projections/streaks.ts @@ -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; +} + +const streakDefs: StreakDef[] = [ + { + id: 'streak-water-goal', + moduleId: 'drink', + label: 'Wasser-Ziel', + async checkDate(date: string) { + const entries = await db.table('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('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('meals').toArray(); + return meals.some((m) => !m.deletedAt && m.date === date); + }, + }, +]; + +// ── Streak Calculator ─────────────────────────────── + +const MAX_LOOKBACK = 90; // days + +async function computeStreak(def: StreakDef): Promise { + 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 { + return Promise.all(streakDefs.map(computeStreak)); +} + +/** + * Reactive streak list — updates when underlying tables change. + * + * ```svelte + * const streaks = useStreaks(); + * {#each streaks.value as s} + *

{s.label}: {s.currentStreak} Tage ({s.status})

+ * {/each} + * ``` + */ +export function useStreaks() { + return useLiveQueryWithDefault(buildAllStreaks, []); +} diff --git a/apps/mana/apps/web/src/lib/data/projections/types.ts b/apps/mana/apps/web/src/lib/data/projections/types.ts new file mode 100644 index 000000000..01368be2a --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/projections/types.ts @@ -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'; +} diff --git a/apps/mana/apps/web/src/routes/(app)/+layout.svelte b/apps/mana/apps/web/src/routes/(app)/+layout.svelte index 7beb41d38..94928af22 100644 --- a/apps/mana/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/mana/apps/web/src/routes/(app)/+layout.svelte @@ -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