feat(brain): add Goal Editor UI and event-driven incremental streaks

Final two TODOs resolved — the Companion Brain backlog is now empty.

Goal Editor (GoalEditor.svelte):
- Modal with event type picker (13 options), count/sum mode,
  optional filter field/value, target value/period/comparison
- Integrated into Goals ListView with "Eigenes" button alongside
  the existing "Vorlage" template picker
- Creates custom goals via goalStore.create()

Incremental Streaks (rewritten streaks.ts):
- Persistent _streakState table replaces the 90-day lookback scan
- 6 streak definitions: water goal, tasks, meals, workout, journal,
  meditation — each triggered by specific domain events
- Event bus subscription marks streaks active on matching events
- markActive() is O(1): read state → check if today already active
  → increment or reset based on consecutive day check
- useStreaks() reads from _streakState (single table scan, no
  per-day queries) instead of 270+ queries worst case
- startStreakTracker/stopStreakTracker wired into layout lifecycle

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-13 23:52:45 +02:00
parent 677f6b799d
commit 399e927c00
5 changed files with 465 additions and 108 deletions

View file

@ -440,6 +440,7 @@ db.version(14).stores({
rituals: 'id, status, createdAt',
ritualSteps: 'id, ritualId, order, [ritualId+order]',
ritualLogs: '++id, ritualId, date, [ritualId+date]',
_streakState: 'id, lastActiveDate',
});
// Schema version 15 — adds the Mood module (multi-daily mood tracking with

View file

@ -1,48 +1,46 @@
/**
* Streaks Tracks consecutive-day activity across modules.
* Streaks Event-driven consecutive-day tracking.
*
* 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.
* Persistent state in `_streakState` table. Updated incrementally
* via event bus subscription instead of scanning 90 days of history.
*
* On relevant events (DrinkLogged, TaskCompleted, MealLogged, etc.),
* the streak for today is marked active. On each read, we check if
* the streak is still consecutive or has been broken.
*
* Status:
* active today or yesterday was active
* at_risk yesterday was NOT active, but the day before was
* broken more than 1 day gap
* active today is active
* at_risk today not yet active, but yesterday was
* broken gap > 1 day
*/
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 { eventBus } from '../events/event-bus';
import type { DomainEvent } from '../events/types';
import type { StreakInfo } from './types';
// ── Helpers ─────────────────────────────────────────
// ── Persistent State ────────────────────────────────
function dateStr(d: Date): string {
return d.toISOString().split('T')[0];
interface StreakState {
id: string;
label: string;
moduleId: string;
currentStreak: number;
longestStreak: number;
lastActiveDate: string; // YYYY-MM-DD
}
function daysAgo(n: number): string {
const TABLE = '_streakState';
function todayStr(): string {
return new Date().toISOString().split('T')[0];
}
function yesterdayStr(): 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';
d.setDate(d.getDate() - 1);
return d.toISOString().split('T')[0];
}
// ── Streak Definitions ──────────────────────────────
@ -51,109 +49,153 @@ interface StreakDef {
id: string;
moduleId: string;
label: string;
/** Check if a given date "counts" as active. */
checkDate: (date: string) => Promise<boolean>;
/** Domain event types that count as "active" for this streak */
triggerEvents: string[];
/** Optional: only count events where this payload filter matches */
filter?: (payload: Record<string, unknown>) => boolean;
}
const streakDefs: StreakDef[] = [
const STREAK_DEFS: 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;
},
triggerEvents: ['DrinkLogged'],
filter: (p) => p.drinkType === 'water',
},
{
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)
);
},
triggerEvents: ['TaskCompleted'],
},
{
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);
triggerEvents: ['MealLogged', 'MealFromPhotoLogged'],
},
{
id: 'streak-workout',
moduleId: 'body',
label: 'Workout',
triggerEvents: ['WorkoutFinished'],
},
{
id: 'streak-journal',
moduleId: 'journal',
label: 'Journal',
triggerEvents: ['JournalEntryCreated'],
},
{
id: 'streak-meditation',
moduleId: 'meditate',
label: 'Meditation',
triggerEvents: ['MeditationCompleted'],
},
];
// ── Streak Calculator ───────────────────────────────
// ── Core Logic ──────────────────────────────────────
const MAX_LOOKBACK = 90; // days
async function markActive(streakId: string): Promise<void> {
const today = todayStr();
const existing = await db.table<StreakState>(TABLE).get(streakId);
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,
if (!existing) {
// First ever activation — seed from definition
const def = STREAK_DEFS.find((d) => d.id === streakId);
if (!def) return;
await db.table(TABLE).add({
id: streakId,
label: def.label,
currentStreak,
longestStreak,
lastActiveDate: lastActiveDate || today,
status: lastActiveDate ? streakStatus(lastActiveDate, today) : 'broken',
};
moduleId: def.moduleId,
currentStreak: 1,
longestStreak: 1,
lastActiveDate: today,
});
return;
}
if (existing.lastActiveDate === today) return; // Already active today
const yesterday = yesterdayStr();
const isConsecutive = existing.lastActiveDate === yesterday;
const newStreak = isConsecutive ? existing.currentStreak + 1 : 1;
const newLongest = Math.max(existing.longestStreak, newStreak);
await db.table(TABLE).update(streakId, {
currentStreak: newStreak,
longestStreak: newLongest,
lastActiveDate: today,
});
}
function computeStatus(state: StreakState): StreakInfo['status'] {
const today = todayStr();
if (state.lastActiveDate === today) return 'active';
if (state.lastActiveDate === yesterdayStr()) return 'at_risk';
return 'broken';
}
// ── Event Bus Subscription ──────────────────────────
let unsubscribe: (() => void) | null = null;
export function startStreakTracker(): void {
if (unsubscribe) return;
unsubscribe = eventBus.onAny((event: DomainEvent) => {
for (const def of STREAK_DEFS) {
if (!def.triggerEvents.includes(event.type)) continue;
if (def.filter && !def.filter(event.payload as Record<string, unknown>)) continue;
markActive(def.id);
}
});
}
export function stopStreakTracker(): void {
unsubscribe?.();
unsubscribe = null;
}
// ── Seed defaults ───────────────────────────────────
async function ensureSeeded(): Promise<void> {
const count = await db.table(TABLE).count();
if (count > 0) return;
// Seed empty states so useStreaks() returns all definitions
for (const def of STREAK_DEFS) {
await db.table(TABLE).add({
id: def.id,
label: def.label,
moduleId: def.moduleId,
currentStreak: 0,
longestStreak: 0,
lastActiveDate: '',
});
}
}
// ── Read API ────────────────────────────────────────
async function buildAllStreaks(): Promise<StreakInfo[]> {
return Promise.all(streakDefs.map(computeStreak));
await ensureSeeded();
const states = await db.table<StreakState>(TABLE).toArray();
return states.map((s) => ({
id: s.id,
moduleId: s.moduleId,
label: s.label,
currentStreak:
s.lastActiveDate === todayStr() || s.lastActiveDate === yesterdayStr() ? s.currentStreak : 0, // Reset display if broken
longestStreak: s.longestStreak,
lastActiveDate: s.lastActiveDate || todayStr(),
status: s.lastActiveDate ? computeStatus(s) : 'broken',
}));
}
/**
* 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}
* ```
* Reactive streak list. Reads from `_streakState` table (fast, no scanning).
*/
export function useStreaks() {
return useLiveQueryWithDefault<StreakInfo[]>(buildAllStreaks, []);

View file

@ -0,0 +1,302 @@
<!--
GoalEditor — Modal for creating custom goals with metric + target.
-->
<script lang="ts">
import { X } from '@mana/shared-icons';
import { goalStore } from '$lib/companion/goals';
interface Props {
show: boolean;
onClose: () => void;
}
let { show, onClose }: Props = $props();
let title = $state('');
let eventType = $state('TaskCompleted');
let source = $state<'event_count' | 'event_sum'>('event_count');
let filterField = $state('');
let filterValue = $state('');
let sumField = $state('');
let targetValue = $state(5);
let period = $state<'day' | 'week' | 'month'>('day');
let comparison = $state<'gte' | 'lte'>('gte');
let moduleId = $state('todo');
let pending = $state(false);
const EVENT_OPTIONS = [
{ value: 'TaskCompleted', label: 'Tasks erledigt', module: 'todo' },
{ value: 'TaskCreated', label: 'Tasks erstellt', module: 'todo' },
{ value: 'DrinkLogged', label: 'Getraenk geloggt', module: 'drink' },
{ value: 'MealLogged', label: 'Mahlzeit geloggt', module: 'nutriphi' },
{ value: 'HabitLogged', label: 'Habit geloggt', module: 'habits' },
{ value: 'JournalEntryCreated', label: 'Journal-Eintrag', module: 'journal' },
{ value: 'NoteCreated', label: 'Notiz erstellt', module: 'notes' },
{ value: 'PlaceVisited', label: 'Ort besucht', module: 'places' },
{ value: 'WorkoutFinished', label: 'Workout beendet', module: 'body' },
{ value: 'MeditationCompleted', label: 'Meditation', module: 'meditate' },
{ value: 'SleepLogged', label: 'Schlaf geloggt', module: 'sleep' },
{ value: 'CalendarEventCreated', label: 'Termin erstellt', module: 'calendar' },
{ value: 'TransactionCreated', label: 'Transaktion', module: 'finance' },
];
function onEventTypeChange() {
const opt = EVENT_OPTIONS.find((o) => o.value === eventType);
if (opt) moduleId = opt.module;
}
async function handleSubmit() {
if (!title.trim() || pending) return;
pending = true;
try {
await goalStore.create({
title: title.trim(),
moduleId,
metric: {
source,
eventType,
filterField: filterField || undefined,
filterValue: filterValue || undefined,
sumField: source === 'event_sum' ? sumField || undefined : undefined,
},
target: { value: targetValue, period, comparison },
});
// Reset
title = '';
targetValue = 5;
onClose();
} finally {
pending = false;
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if show}
<div class="backdrop" onclick={onClose} role="presentation" tabindex="-1">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="editor" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
<div class="editor-header">
<h3>Eigenes Ziel erstellen</h3>
<button class="close-btn" onclick={onClose}><X size={16} /></button>
</div>
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
>
<label class="field">
<span class="label">Titel</span>
<input
type="text"
bind:value={title}
placeholder="z.B. 4x Sport pro Woche"
required
maxlength="60"
/>
</label>
<label class="field">
<span class="label">Was zaehlen?</span>
<select bind:value={eventType} onchange={onEventTypeChange}>
{#each EVENT_OPTIONS as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</label>
<label class="field">
<span class="label">Wie zaehlen?</span>
<select bind:value={source}>
<option value="event_count">Anzahl zaehlen</option>
<option value="event_sum">Wert summieren</option>
</select>
</label>
{#if source === 'event_sum'}
<label class="field">
<span class="label">Summen-Feld</span>
<input type="text" bind:value={sumField} placeholder="z.B. quantityMl, calories" />
</label>
{/if}
<div class="field-row">
<label class="field">
<span class="label">Filter (optional)</span>
<input type="text" bind:value={filterField} placeholder="Feld z.B. drinkType" />
</label>
<label class="field">
<span class="label">Wert</span>
<input type="text" bind:value={filterValue} placeholder="z.B. water" />
</label>
</div>
<div class="field-row">
<label class="field">
<span class="label">Ziel</span>
<div class="target-row">
<select bind:value={comparison}>
<option value="gte">Mindestens</option>
<option value="lte">Hoechstens</option>
</select>
<input type="number" bind:value={targetValue} min={1} max={10000} />
</div>
</label>
<label class="field">
<span class="label">Zeitraum</span>
<select bind:value={period}>
<option value="day">Pro Tag</option>
<option value="week">Pro Woche</option>
<option value="month">Pro Monat</option>
</select>
</label>
</div>
<div class="actions">
<button type="button" class="btn-cancel" onclick={onClose}>Abbrechen</button>
<button type="submit" class="btn-create" disabled={!title.trim() || pending}>
{pending ? '...' : 'Erstellen'}
</button>
</div>
</form>
</div>
</div>
{/if}
<style>
.backdrop {
position: fixed;
inset: 0;
z-index: 200;
background: hsl(0 0% 0% / 0.5);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.editor {
background: hsl(var(--color-card));
border-radius: 0.75rem;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.2);
max-width: 440px;
width: 100%;
padding: 1.25rem;
animation: pop 0.18s ease-out;
}
@keyframes pop {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
}
.editor-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.editor-header h3 {
font-size: 1rem;
font-weight: 600;
color: hsl(var(--color-foreground));
margin: 0;
}
.close-btn {
border: none;
background: none;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
padding: 0.25rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-bottom: 0.75rem;
}
.label {
font-size: 0.6875rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
}
.field input,
.field select {
padding: 0.4375rem 0.625rem;
border-radius: 0.5rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
outline: none;
}
.field input:focus,
.field select:focus {
border-color: hsl(var(--color-primary));
}
.field-row {
display: flex;
gap: 0.5rem;
}
.field-row .field {
flex: 1;
}
.target-row {
display: flex;
gap: 0.375rem;
}
.target-row select {
flex: 1;
}
.target-row input {
width: 80px;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.5rem;
}
.btn-cancel {
padding: 0.4375rem 0.75rem;
border-radius: 0.5rem;
border: none;
background: transparent;
color: hsl(var(--color-muted-foreground));
font-size: 0.8125rem;
cursor: pointer;
}
.btn-create {
padding: 0.4375rem 1rem;
border-radius: 0.5rem;
border: none;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
}
.btn-create:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-create:hover:not(:disabled) {
filter: brightness(1.08);
}
</style>

View file

@ -2,12 +2,14 @@
Goals — Goal cards with progress bars and template picker.
-->
<script lang="ts">
import { Target, Plus, Play, Pause, Trash } from '@mana/shared-icons';
import { Target, Plus, Play, Pause, Trash, PencilSimple } from '@mana/shared-icons';
import { goalStore, useAllGoals, GOAL_TEMPLATES } from '$lib/companion/goals';
import type { LocalGoal } from '$lib/companion/goals/types';
import GoalEditor from './GoalEditor.svelte';
const goals = useAllGoals();
let showTemplates = $state(false);
let showEditor = $state(false);
function progressPercent(goal: LocalGoal): number {
if (goal.target.value === 0) return 0;
@ -27,8 +29,11 @@
<div class="goals-page">
<div class="header">
<button class="add-btn" onclick={() => (showEditor = true)}>
<PencilSimple size={14} weight="bold" /> Eigenes
</button>
<button class="add-btn" onclick={() => (showTemplates = !showTemplates)}>
<Plus size={14} weight="bold" /> Ziel
<Plus size={14} weight="bold" /> Vorlage
</button>
</div>
@ -86,11 +91,15 @@
{/each}
{#if goals.value.length === 0 && !showTemplates}
<div class="empty">Keine Ziele aktiv. Tippe + um ein Ziel zu setzen.</div>
<div class="empty">
Keine Ziele aktiv. Waehle eine Vorlage oder erstelle ein eigenes Ziel.
</div>
{/if}
</div>
</div>
<GoalEditor show={showEditor} onClose={() => (showEditor = false)} />
<style>
.goals-page {
padding: 0.75rem;

View file

@ -8,6 +8,7 @@
import { startEventStore, stopEventStore } from '$lib/data/events/event-store';
import { initTools } from '$lib/data/tools/init';
import { startEventBridge, stopEventBridge } from '$lib/triggers/event-bridge';
import { startStreakTracker, stopStreakTracker } from '$lib/data/projections/streaks';
import KeyboardShortcutsModal from '$lib/components/KeyboardShortcutsModal.svelte';
import SessionWarning from '$lib/components/SessionWarning.svelte';
import EncryptionIntroBanner from '$lib/components/EncryptionIntroBanner.svelte';
@ -424,6 +425,7 @@
startEventStore();
initTools();
startEventBridge();
startStreakTracker();
await dashboardStore.initialize();
// Start the persistent LLM task queue. Idempotent — safe to call
@ -526,6 +528,7 @@
reminderScheduler.stop();
stopEventStore();
stopEventBridge();
stopStreakTracker();
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