mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:41:08 +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',
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 (!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,
|
||||
moduleId: def.moduleId,
|
||||
currentStreak: 1,
|
||||
longestStreak: 1,
|
||||
lastActiveDate: today,
|
||||
});
|
||||
return;
|
||||
}
|
||||
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',
|
||||
};
|
||||
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, []);
|
||||
|
|
|
|||
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.
|
||||
-->
|
||||
<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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue