mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
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:
parent
677f6b799d
commit
399e927c00
5 changed files with 465 additions and 108 deletions
|
|
@ -440,6 +440,7 @@ db.version(14).stores({
|
||||||
rituals: 'id, status, createdAt',
|
rituals: 'id, status, createdAt',
|
||||||
ritualSteps: 'id, ritualId, order, [ritualId+order]',
|
ritualSteps: 'id, ritualId, order, [ritualId+order]',
|
||||||
ritualLogs: '++id, ritualId, date, [ritualId+date]',
|
ritualLogs: '++id, ritualId, date, [ritualId+date]',
|
||||||
|
_streakState: 'id, lastActiveDate',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Schema version 15 — adds the Mood module (multi-daily mood tracking with
|
// Schema version 15 — adds the Mood module (multi-daily mood tracking with
|
||||||
|
|
|
||||||
|
|
@ -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
|
* Persistent state in `_streakState` table. Updated incrementally
|
||||||
* counts" (e.g. water goal reached, at least 1 task completed, etc.).
|
* via event bus subscription instead of scanning 90 days of history.
|
||||||
* The streak engine then looks backwards through the event store to
|
*
|
||||||
* compute the current streak length.
|
* 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:
|
* Status:
|
||||||
* active — today or yesterday was active
|
* active — today is active
|
||||||
* at_risk — yesterday was NOT active, but the day before was
|
* at_risk — today not yet active, but yesterday was
|
||||||
* broken — more than 1 day gap
|
* broken — gap > 1 day
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { db } from '../database';
|
import { db } from '../database';
|
||||||
import { decryptRecords } from '../crypto';
|
|
||||||
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
||||||
import { DEFAULT_DAILY_GOAL_ML } from '$lib/modules/drink/types';
|
import { eventBus } from '../events/event-bus';
|
||||||
import type { LocalTask } from '$lib/modules/todo/types';
|
import type { DomainEvent } from '../events/types';
|
||||||
import type { LocalDrinkEntry } from '$lib/modules/drink/types';
|
|
||||||
import type { LocalMeal } from '$lib/modules/nutriphi/types';
|
|
||||||
import type { StreakInfo } from './types';
|
import type { StreakInfo } from './types';
|
||||||
|
|
||||||
// ── Helpers ─────────────────────────────────────────
|
// ── Persistent State ────────────────────────────────
|
||||||
|
|
||||||
function dateStr(d: Date): string {
|
interface StreakState {
|
||||||
return d.toISOString().split('T')[0];
|
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();
|
const d = new Date();
|
||||||
d.setDate(d.getDate() - n);
|
d.setDate(d.getDate() - 1);
|
||||||
return dateStr(d);
|
return d.toISOString().split('T')[0];
|
||||||
}
|
|
||||||
|
|
||||||
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 ──────────────────────────────
|
// ── Streak Definitions ──────────────────────────────
|
||||||
|
|
@ -51,109 +49,153 @@ interface StreakDef {
|
||||||
id: string;
|
id: string;
|
||||||
moduleId: string;
|
moduleId: string;
|
||||||
label: string;
|
label: string;
|
||||||
/** Check if a given date "counts" as active. */
|
/** Domain event types that count as "active" for this streak */
|
||||||
checkDate: (date: string) => Promise<boolean>;
|
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',
|
id: 'streak-water-goal',
|
||||||
moduleId: 'drink',
|
moduleId: 'drink',
|
||||||
label: 'Wasser-Ziel',
|
label: 'Wasser-Ziel',
|
||||||
async checkDate(date: string) {
|
triggerEvents: ['DrinkLogged'],
|
||||||
const entries = await db.table<LocalDrinkEntry>('drinkEntries').toArray();
|
filter: (p) => p.drinkType === 'water',
|
||||||
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',
|
id: 'streak-tasks-completed',
|
||||||
moduleId: 'todo',
|
moduleId: 'todo',
|
||||||
label: 'Tasks erledigt',
|
label: 'Tasks erledigt',
|
||||||
async checkDate(date: string) {
|
triggerEvents: ['TaskCompleted'],
|
||||||
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',
|
id: 'streak-meals-logged',
|
||||||
moduleId: 'nutriphi',
|
moduleId: 'nutriphi',
|
||||||
label: 'Mahlzeiten getrackt',
|
label: 'Mahlzeiten getrackt',
|
||||||
async checkDate(date: string) {
|
triggerEvents: ['MealLogged', 'MealFromPhotoLogged'],
|
||||||
const meals = await db.table<LocalMeal>('meals').toArray();
|
},
|
||||||
return meals.some((m) => !m.deletedAt && m.date === date);
|
{
|
||||||
},
|
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> {
|
if (!existing) {
|
||||||
const today = dateStr(new Date());
|
// First ever activation — seed from definition
|
||||||
let lastActiveDate = '';
|
const def = STREAK_DEFS.find((d) => d.id === streakId);
|
||||||
let currentStreak = 0;
|
if (!def) return;
|
||||||
let longestStreak = 0;
|
await db.table(TABLE).add({
|
||||||
let runningStreak = 0;
|
id: streakId,
|
||||||
let streakBroken = false;
|
label: def.label,
|
||||||
|
moduleId: def.moduleId,
|
||||||
for (let i = 0; i < MAX_LOOKBACK; i++) {
|
currentStreak: 1,
|
||||||
const date = daysAgo(i);
|
longestStreak: 1,
|
||||||
const active = await def.checkDate(date);
|
lastActiveDate: today,
|
||||||
|
});
|
||||||
if (active) {
|
return;
|
||||||
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 {
|
if (existing.lastActiveDate === today) return; // Already active today
|
||||||
id: def.id,
|
|
||||||
moduleId: def.moduleId,
|
const yesterday = yesterdayStr();
|
||||||
label: def.label,
|
const isConsecutive = existing.lastActiveDate === yesterday;
|
||||||
currentStreak,
|
const newStreak = isConsecutive ? existing.currentStreak + 1 : 1;
|
||||||
longestStreak,
|
const newLongest = Math.max(existing.longestStreak, newStreak);
|
||||||
lastActiveDate: lastActiveDate || today,
|
|
||||||
status: lastActiveDate ? streakStatus(lastActiveDate, today) : 'broken',
|
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[]> {
|
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.
|
* Reactive streak list. Reads from `_streakState` table (fast, no scanning).
|
||||||
*
|
|
||||||
* ```svelte
|
|
||||||
* const streaks = useStreaks();
|
|
||||||
* {#each streaks.value as s}
|
|
||||||
* <p>{s.label}: {s.currentStreak} Tage ({s.status})</p>
|
|
||||||
* {/each}
|
|
||||||
* ```
|
|
||||||
*/
|
*/
|
||||||
export function useStreaks() {
|
export function useStreaks() {
|
||||||
return useLiveQueryWithDefault<StreakInfo[]>(buildAllStreaks, []);
|
return useLiveQueryWithDefault<StreakInfo[]>(buildAllStreaks, []);
|
||||||
|
|
|
||||||
302
apps/mana/apps/web/src/lib/modules/goals/GoalEditor.svelte
Normal file
302
apps/mana/apps/web/src/lib/modules/goals/GoalEditor.svelte
Normal 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>
|
||||||
|
|
@ -2,12 +2,14 @@
|
||||||
Goals — Goal cards with progress bars and template picker.
|
Goals — Goal cards with progress bars and template picker.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<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 { goalStore, useAllGoals, GOAL_TEMPLATES } from '$lib/companion/goals';
|
||||||
import type { LocalGoal } from '$lib/companion/goals/types';
|
import type { LocalGoal } from '$lib/companion/goals/types';
|
||||||
|
import GoalEditor from './GoalEditor.svelte';
|
||||||
|
|
||||||
const goals = useAllGoals();
|
const goals = useAllGoals();
|
||||||
let showTemplates = $state(false);
|
let showTemplates = $state(false);
|
||||||
|
let showEditor = $state(false);
|
||||||
|
|
||||||
function progressPercent(goal: LocalGoal): number {
|
function progressPercent(goal: LocalGoal): number {
|
||||||
if (goal.target.value === 0) return 0;
|
if (goal.target.value === 0) return 0;
|
||||||
|
|
@ -27,8 +29,11 @@
|
||||||
|
|
||||||
<div class="goals-page">
|
<div class="goals-page">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
|
<button class="add-btn" onclick={() => (showEditor = true)}>
|
||||||
|
<PencilSimple size={14} weight="bold" /> Eigenes
|
||||||
|
</button>
|
||||||
<button class="add-btn" onclick={() => (showTemplates = !showTemplates)}>
|
<button class="add-btn" onclick={() => (showTemplates = !showTemplates)}>
|
||||||
<Plus size={14} weight="bold" /> Ziel
|
<Plus size={14} weight="bold" /> Vorlage
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -86,11 +91,15 @@
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#if goals.value.length === 0 && !showTemplates}
|
{#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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<GoalEditor show={showEditor} onClose={() => (showEditor = false)} />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.goals-page {
|
.goals-page {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
import { startEventStore, stopEventStore } from '$lib/data/events/event-store';
|
import { startEventStore, stopEventStore } from '$lib/data/events/event-store';
|
||||||
import { initTools } from '$lib/data/tools/init';
|
import { initTools } from '$lib/data/tools/init';
|
||||||
import { startEventBridge, stopEventBridge } from '$lib/triggers/event-bridge';
|
import { startEventBridge, stopEventBridge } from '$lib/triggers/event-bridge';
|
||||||
|
import { startStreakTracker, stopStreakTracker } from '$lib/data/projections/streaks';
|
||||||
import KeyboardShortcutsModal from '$lib/components/KeyboardShortcutsModal.svelte';
|
import KeyboardShortcutsModal from '$lib/components/KeyboardShortcutsModal.svelte';
|
||||||
import SessionWarning from '$lib/components/SessionWarning.svelte';
|
import SessionWarning from '$lib/components/SessionWarning.svelte';
|
||||||
import EncryptionIntroBanner from '$lib/components/EncryptionIntroBanner.svelte';
|
import EncryptionIntroBanner from '$lib/components/EncryptionIntroBanner.svelte';
|
||||||
|
|
@ -424,6 +425,7 @@
|
||||||
startEventStore();
|
startEventStore();
|
||||||
initTools();
|
initTools();
|
||||||
startEventBridge();
|
startEventBridge();
|
||||||
|
startStreakTracker();
|
||||||
await dashboardStore.initialize();
|
await dashboardStore.initialize();
|
||||||
|
|
||||||
// Start the persistent LLM task queue. Idempotent — safe to call
|
// Start the persistent LLM task queue. Idempotent — safe to call
|
||||||
|
|
@ -526,6 +528,7 @@
|
||||||
reminderScheduler.stop();
|
reminderScheduler.stop();
|
||||||
stopEventStore();
|
stopEventStore();
|
||||||
stopEventBridge();
|
stopEventBridge();
|
||||||
|
stopStreakTracker();
|
||||||
guestMode?.destroy();
|
guestMode?.destroy();
|
||||||
// Fire-and-forget — we don't need to await; the in-flight task
|
// Fire-and-forget — we don't need to await; the in-flight task
|
||||||
// will finish in the background and the next page session will
|
// will finish in the background and the next page session will
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue