mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 03:41:10 +02:00
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:
parent
aabf130480
commit
40e1145e9f
6 changed files with 540 additions and 0 deletions
107
apps/mana/apps/web/src/lib/data/projections/context-document.ts
Normal file
107
apps/mana/apps/web/src/lib/data/projections/context-document.ts
Normal 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');
|
||||
}
|
||||
194
apps/mana/apps/web/src/lib/data/projections/day-snapshot.ts
Normal file
194
apps/mana/apps/web/src/lib/data/projections/day-snapshot.ts
Normal 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()));
|
||||
}
|
||||
4
apps/mana/apps/web/src/lib/data/projections/index.ts
Normal file
4
apps/mana/apps/web/src/lib/data/projections/index.ts
Normal 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';
|
||||
160
apps/mana/apps/web/src/lib/data/projections/streaks.ts
Normal file
160
apps/mana/apps/web/src/lib/data/projections/streaks.ts
Normal 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, []);
|
||||
}
|
||||
72
apps/mana/apps/web/src/lib/data/projections/types.ts
Normal file
72
apps/mana/apps/web/src/lib/data/projections/types.ts
Normal 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';
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue