From ed01d24f2d9c1804c9638f9d5cfa7ff4bfa56340 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 16 Apr 2026 15:01:12 +0200 Subject: [PATCH] feat(ai): add AI tools for myday, goals, mood, finance, and times Expand agent tool coverage from 28 to 47 tools across 16 modules: - myday: get_myday_summary (full daily context in one call) - goals: list_goals, get_goal_progress, create_goal, pause/resume/complete_goal - mood: log_mood, get_mood_today, get_mood_insights (trends + correlations) - finance: extend add_transaction, add get_month_summary + list_transactions - times: extend start/stop_timer, add get_timer_status, get_time_stats, list_projects All tools registered in both AI_TOOL_CATALOG (shared-ai) and webapp init. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mana/apps/web/src/lib/data/tools/init.ts | 6 + .../apps/web/src/lib/modules/finance/tools.ts | 138 +++++++- .../apps/web/src/lib/modules/goals/tools.ts | 253 +++++++++++++ .../apps/web/src/lib/modules/mood/tools.ts | 199 +++++++++++ .../apps/web/src/lib/modules/myday/tools.ts | 198 +++++++++++ .../apps/web/src/lib/modules/times/tools.ts | 177 +++++++++- packages/shared-ai/src/tools/schemas.ts | 334 ++++++++++++++++++ 7 files changed, 1299 insertions(+), 6 deletions(-) create mode 100644 apps/mana/apps/web/src/lib/modules/goals/tools.ts create mode 100644 apps/mana/apps/web/src/lib/modules/mood/tools.ts create mode 100644 apps/mana/apps/web/src/lib/modules/myday/tools.ts diff --git a/apps/mana/apps/web/src/lib/data/tools/init.ts b/apps/mana/apps/web/src/lib/data/tools/init.ts index aef2446eb..6b854d9d5 100644 --- a/apps/mana/apps/web/src/lib/data/tools/init.ts +++ b/apps/mana/apps/web/src/lib/data/tools/init.ts @@ -35,6 +35,9 @@ import { recipesTools } from '$lib/modules/recipes/tools'; import { questionsTools } from '$lib/modules/questions/tools'; import { meditateTools } from '$lib/modules/meditate/tools'; import { sleepTools } from '$lib/modules/sleep/tools'; +import { mydayTools } from '$lib/modules/myday/tools'; +import { goalsTools } from '$lib/modules/goals/tools'; +import { moodTools } from '$lib/modules/mood/tools'; let initialized = false; @@ -71,5 +74,8 @@ export function initTools(): void { registerTools(questionsTools); registerTools(meditateTools); registerTools(sleepTools); + registerTools(mydayTools); + registerTools(goalsTools); + registerTools(moodTools); initialized = true; } diff --git a/apps/mana/apps/web/src/lib/modules/finance/tools.ts b/apps/mana/apps/web/src/lib/modules/finance/tools.ts index c8d0344b1..f54205755 100644 --- a/apps/mana/apps/web/src/lib/modules/finance/tools.ts +++ b/apps/mana/apps/web/src/lib/modules/finance/tools.ts @@ -1,5 +1,13 @@ +/** + * Finance Tools — LLM-accessible operations for income/expense tracking. + */ + import type { ModuleTool } from '$lib/data/tools/types'; import { financeStore } from './stores/finance.svelte'; +import { transactionTable, categoryTable } from './collections'; +import { decryptRecords } from '$lib/data/crypto'; +import { toTransaction, toCategory, currentMonth, formatCurrency } from './queries'; +import type { LocalTransaction, LocalFinanceCategory, TransactionType } from './types'; export const financeTools: ModuleTool[] = [ { @@ -16,17 +24,143 @@ export const financeTools: ModuleTool[] = [ }, { name: 'amount', type: 'number', description: 'Betrag in Euro', required: true }, { name: 'description', type: 'string', description: 'Beschreibung', required: true }, + { + name: 'date', + type: 'string', + description: 'Datum (YYYY-MM-DD, Standard: heute)', + required: false, + }, ], async execute(params) { const tx = await financeStore.addTransaction({ - type: params.type as 'income' | 'expense', + type: params.type as TransactionType, amount: params.amount as number, description: params.description as string, + date: params.date as string | undefined, }); return { success: true, data: tx, - message: `${params.type === 'income' ? 'Einnahme' : 'Ausgabe'}: ${params.amount}€ (${params.description})`, + message: `${params.type === 'income' ? 'Einnahme' : 'Ausgabe'}: ${formatCurrency(params.amount as number)} (${params.description})`, + }; + }, + }, + + { + name: 'get_month_summary', + module: 'finance', + description: + 'Gibt die Finanz-Zusammenfassung fuer einen Monat zurueck: Einnahmen, Ausgaben, Bilanz, Ausgaben pro Kategorie.', + parameters: [ + { + name: 'month', + type: 'string', + description: 'Monat im Format YYYY-MM (Standard: aktueller Monat)', + required: false, + }, + ], + async execute(params) { + const month = (params.month as string) ?? currentMonth(); + + const [allTxs, allCats] = await Promise.all([ + transactionTable.toArray(), + categoryTable.toArray(), + ]); + + const visible = allTxs.filter((t) => !t.deletedAt); + const decrypted = await decryptRecords('transactions', visible); + const txs = decrypted.map(toTransaction); + const cats = allCats.filter((c) => !c.deletedAt).map(toCategory); + + const monthTxs = txs.filter((t) => t.date.startsWith(month)); + let income = 0; + let expenses = 0; + const byCategory = new Map(); + + for (const tx of monthTxs) { + if (tx.type === 'income') income += tx.amount; + else { + expenses += tx.amount; + if (tx.categoryId) { + byCategory.set(tx.categoryId, (byCategory.get(tx.categoryId) ?? 0) + tx.amount); + } + } + } + + const catBreakdown = [...byCategory.entries()] + .sort((a, b) => b[1] - a[1]) + .map(([catId, amount]) => { + const cat = cats.find((c) => c.id === catId); + return { category: cat ? `${cat.emoji} ${cat.name}` : 'Sonstiges', amount }; + }); + + return { + success: true, + data: { + month, + income, + expenses, + balance: income - expenses, + transactions: monthTxs.length, + byCategory: catBreakdown, + }, + message: `${month}: ${formatCurrency(income)} Einnahmen, ${formatCurrency(expenses)} Ausgaben, Bilanz: ${formatCurrency(income - expenses)}`, + }; + }, + }, + + { + name: 'list_transactions', + module: 'finance', + description: + 'Listet die letzten Transaktionen auf. Optional nach Typ (income/expense) und Monat filterbar.', + parameters: [ + { + name: 'type', + type: 'string', + description: 'Nur income oder expense zeigen', + required: false, + enum: ['income', 'expense'], + }, + { + name: 'month', + type: 'string', + description: 'Monat im Format YYYY-MM', + required: false, + }, + { + name: 'limit', + type: 'number', + description: 'Maximale Anzahl (Standard: 20)', + required: false, + }, + ], + async execute(params) { + const filterType = params.type as TransactionType | undefined; + const month = params.month as string | undefined; + const limit = (params.limit as number) ?? 20; + + const all = await transactionTable.toArray(); + const visible = all.filter((t) => !t.deletedAt); + const decrypted = await decryptRecords('transactions', visible); + let txs = decrypted.map(toTransaction); + + if (filterType) txs = txs.filter((t) => t.type === filterType); + if (month) txs = txs.filter((t) => t.date.startsWith(month)); + + txs.sort((a, b) => b.date.localeCompare(a.date) || b.createdAt.localeCompare(a.createdAt)); + const sliced = txs.slice(0, limit); + + return { + success: true, + data: sliced.map((t) => ({ + id: t.id, + type: t.type, + amount: t.amount, + description: t.description, + date: t.date, + })), + message: `${sliced.length} Transaktionen${filterType ? ` (${filterType})` : ''}${month ? ` in ${month}` : ''}`, }; }, }, diff --git a/apps/mana/apps/web/src/lib/modules/goals/tools.ts b/apps/mana/apps/web/src/lib/modules/goals/tools.ts new file mode 100644 index 000000000..aab04eca1 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/goals/tools.ts @@ -0,0 +1,253 @@ +/** + * Goals Tools — LLM-accessible operations for the goal system. + * + * Goals are event-driven: progress auto-increments when matching + * domain events fire (e.g. DrinkLogged, TaskCompleted). These tools + * let agents read progress, create new goals, and manage status. + */ + +import type { ModuleTool } from '$lib/data/tools/types'; +import { db } from '$lib/data/database'; +import { goalStore } from '$lib/companion/goals/store'; +import { GOAL_TEMPLATES, type LocalGoal } from '$lib/companion/goals/types'; + +const TABLE = 'companionGoals'; + +export const goalsTools: ModuleTool[] = [ + { + name: 'list_goals', + module: 'goals', + description: + 'Listet alle Ziele mit aktuellem Fortschritt auf. Zeigt Titel, Fortschritt, Zielwert, Zeitraum und Status.', + parameters: [ + { + name: 'filter', + type: 'string', + description: 'Welche Ziele zeigen', + required: false, + enum: ['active', 'paused', 'completed', 'all'], + }, + ], + async execute(params) { + const filter = (params.filter as string) ?? 'active'; + const all = await db.table(TABLE).toArray(); + const visible = all.filter((g) => !g.deletedAt); + + const filtered = filter === 'all' ? visible : visible.filter((g) => g.status === filter); + + const items = filtered.map((g) => ({ + id: g.id, + title: g.title, + description: g.description, + moduleId: g.moduleId, + status: g.status, + current: g.currentValue, + target: g.target.value, + period: g.target.period, + comparison: g.target.comparison, + percent: + g.target.comparison === 'gte' + ? Math.min(Math.round((g.currentValue / g.target.value) * 100), 100) + : g.currentValue <= g.target.value + ? 100 + : Math.max( + 0, + Math.round((1 - (g.currentValue - g.target.value) / g.target.value) * 100) + ), + })); + + return { + success: true, + data: items, + message: `${items.length} Ziele (${filter}): ${items.map((g) => `${g.title} ${g.current}/${g.target}`).join(', ')}`, + }; + }, + }, + + { + name: 'get_goal_progress', + module: 'goals', + description: + 'Gibt den detaillierten Fortschritt eines einzelnen Ziels zurueck, inklusive Metrik-Details und Periodeninfo.', + parameters: [{ name: 'goalId', type: 'string', description: 'ID des Ziels', required: true }], + async execute(params) { + const goalId = params.goalId as string; + const goal = await db.table(TABLE).get(goalId); + if (!goal || goal.deletedAt) { + return { success: false, message: `Ziel ${goalId} nicht gefunden` }; + } + + const reached = + goal.target.comparison === 'gte' + ? goal.currentValue >= goal.target.value + : goal.currentValue <= goal.target.value; + + return { + success: true, + data: { + id: goal.id, + title: goal.title, + description: goal.description, + moduleId: goal.moduleId, + status: goal.status, + current: goal.currentValue, + target: goal.target.value, + period: goal.target.period, + comparison: goal.target.comparison, + periodStart: goal.currentPeriodStart, + reached, + metric: goal.metric, + }, + message: `${goal.title}: ${goal.currentValue}/${goal.target.value} (${goal.target.period}) — ${reached ? 'erreicht' : 'offen'}`, + }; + }, + }, + + { + name: 'create_goal', + module: 'goals', + description: + 'Erstellt ein neues Ziel. Kann entweder ein Template verwenden (templateId) oder ein benutzerdefiniertes Ziel erstellen. Verfuegbare Templates: tpl-water-daily, tpl-tasks-daily, tpl-meals-daily, tpl-calories-daily, tpl-places-weekly, tpl-coffee-limit.', + parameters: [ + { + name: 'templateId', + type: 'string', + description: + 'ID eines Templates (z.B. "tpl-water-daily"). Wenn gesetzt, werden andere Felder ignoriert.', + required: false, + }, + { + name: 'title', + type: 'string', + description: 'Titel des Ziels (nur fuer benutzerdefinierte Ziele)', + required: false, + }, + { + name: 'description', + type: 'string', + description: 'Beschreibung', + required: false, + }, + { + name: 'targetValue', + type: 'number', + description: 'Zielwert (z.B. 8 fuer "8 Glaeser Wasser")', + required: false, + }, + { + name: 'period', + type: 'string', + description: 'Zeitraum', + required: false, + enum: ['day', 'week', 'month'], + }, + { + name: 'comparison', + type: 'string', + description: 'Vergleich: gte = mindestens, lte = hoechstens', + required: false, + enum: ['gte', 'lte'], + }, + { + name: 'eventType', + type: 'string', + description: + 'Domain-Event zum Zaehlen (z.B. "DrinkLogged", "TaskCompleted", "MealLogged", "WorkoutFinished")', + required: false, + }, + { + name: 'moduleId', + type: 'string', + description: 'Zugehoeriges Modul (z.B. "drink", "todo", "food", "body")', + required: false, + }, + ], + async execute(params) { + // Template-based creation + const templateId = params.templateId as string | undefined; + if (templateId) { + const template = GOAL_TEMPLATES.find((t) => t.id === templateId); + if (!template) { + return { success: false, message: `Template "${templateId}" nicht gefunden` }; + } + const goal = await goalStore.createFromTemplate(template); + return { + success: true, + data: { id: goal.id, title: goal.title }, + message: `Ziel "${goal.title}" aus Template erstellt`, + }; + } + + // Custom goal creation + const title = params.title as string | undefined; + if (!title) { + return { success: false, message: 'Entweder templateId oder title ist erforderlich' }; + } + + const eventType = params.eventType as string | undefined; + if (!eventType) { + return { + success: false, + message: 'eventType ist fuer benutzerdefinierte Ziele erforderlich', + }; + } + + const goal = await goalStore.create({ + title, + description: params.description as string | undefined, + moduleId: (params.moduleId as string) ?? 'general', + metric: { + source: 'event_count', + eventType, + }, + target: { + value: (params.targetValue as number) ?? 1, + period: (params.period as 'day' | 'week' | 'month') ?? 'day', + comparison: (params.comparison as 'gte' | 'lte') ?? 'gte', + }, + }); + + return { + success: true, + data: { id: goal.id, title: goal.title }, + message: `Ziel "${goal.title}" erstellt`, + }; + }, + }, + + { + name: 'pause_goal', + module: 'goals', + description: 'Pausiert ein aktives Ziel. Kann spaeter wieder fortgesetzt werden.', + parameters: [{ name: 'goalId', type: 'string', description: 'ID des Ziels', required: true }], + async execute(params) { + const goalId = params.goalId as string; + await goalStore.pause(goalId); + return { success: true, message: `Ziel ${goalId} pausiert` }; + }, + }, + + { + name: 'resume_goal', + module: 'goals', + description: 'Setzt ein pausiertes Ziel fort.', + parameters: [{ name: 'goalId', type: 'string', description: 'ID des Ziels', required: true }], + async execute(params) { + const goalId = params.goalId as string; + await goalStore.resume(goalId); + return { success: true, message: `Ziel ${goalId} fortgesetzt` }; + }, + }, + + { + name: 'complete_goal', + module: 'goals', + description: 'Markiert ein Ziel als abgeschlossen.', + parameters: [{ name: 'goalId', type: 'string', description: 'ID des Ziels', required: true }], + async execute(params) { + const goalId = params.goalId as string; + await goalStore.complete(goalId); + return { success: true, message: `Ziel ${goalId} abgeschlossen` }; + }, + }, +]; diff --git a/apps/mana/apps/web/src/lib/modules/mood/tools.ts b/apps/mana/apps/web/src/lib/modules/mood/tools.ts new file mode 100644 index 000000000..c7305f4db --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/mood/tools.ts @@ -0,0 +1,199 @@ +/** + * Mood Tools — LLM-accessible operations for mood tracking. + */ + +import type { ModuleTool } from '$lib/data/tools/types'; +import { moodStore } from './stores/mood.svelte'; +import { moodEntryTable } from './collections'; +import { decryptRecords } from '$lib/data/crypto'; +import { + getAvgLevel, + getTopEmotion, + getValenceRatio, + getActivityInsights, + toMoodEntry, +} from './queries'; +import { EMOTION_META, type CoreEmotion, type ActivityContext, type LocalMoodEntry } from './types'; + +function todayStr(): string { + return new Date().toISOString().split('T')[0]; +} + +export const moodTools: ModuleTool[] = [ + { + name: 'log_mood', + module: 'mood', + description: + 'Erfasst einen Mood-Check-in mit Level (1-10), primaerer Emotion und optionalem Kontext.', + parameters: [ + { + name: 'level', + type: 'number', + description: 'Stimmungs-Level von 1 (schlecht) bis 10 (super)', + required: true, + }, + { + name: 'emotion', + type: 'string', + description: 'Primaere Emotion', + required: true, + enum: [ + 'happy', + 'calm', + 'energized', + 'grateful', + 'excited', + 'loved', + 'hopeful', + 'neutral', + 'bored', + 'tired', + 'sad', + 'anxious', + 'angry', + 'stressed', + 'frustrated', + 'overwhelmed', + ], + }, + { + name: 'activity', + type: 'string', + description: 'Was machst du gerade?', + required: false, + enum: [ + 'work', + 'exercise', + 'social', + 'alone', + 'commute', + 'eating', + 'resting', + 'creative', + 'outdoors', + 'screen', + 'chores', + 'other', + ], + }, + { + name: 'notes', + type: 'string', + description: 'Optionale Notiz zum Check-in', + required: false, + }, + ], + async execute(params) { + const entry = await moodStore.logMood({ + level: params.level as number, + emotion: params.emotion as CoreEmotion, + activity: (params.activity as ActivityContext) ?? null, + notes: (params.notes as string) ?? '', + }); + const meta = EMOTION_META[params.emotion as CoreEmotion]; + return { + success: true, + data: entry, + message: `Mood geloggt: ${meta.emoji} ${meta.de} (Level ${params.level}/10)`, + }; + }, + }, + + { + name: 'get_mood_today', + module: 'mood', + description: 'Gibt alle heutigen Mood-Eintraege zurueck mit Durchschnitts-Level und Emotionen.', + parameters: [], + async execute() { + const today = todayStr(); + const all = await moodEntryTable.toArray(); + const todayEntries = all.filter((e) => !e.deletedAt && e.date === today); + const decrypted = await decryptRecords('moodEntries', todayEntries); + const entries = decrypted.map(toMoodEntry); + + if (entries.length === 0) { + return { + success: true, + data: { entries: [], avgLevel: 0 }, + message: 'Noch kein Mood-Eintrag heute', + }; + } + + const avgLevel = +(entries.reduce((s, e) => s + e.level, 0) / entries.length).toFixed(1); + const emotions = entries.map((e) => { + const meta = EMOTION_META[e.emotion]; + return { + emotion: e.emotion, + label: meta.de, + emoji: meta.emoji, + level: e.level, + time: e.time, + }; + }); + + return { + success: true, + data: { entries: emotions, avgLevel, count: entries.length }, + message: `${entries.length} Check-ins heute, Durchschnitt: ${avgLevel}/10`, + }; + }, + }, + + { + name: 'get_mood_insights', + module: 'mood', + description: + 'Gibt Mood-Trends und Muster zurueck: Durchschnitt der letzten 7/30 Tage, haeufigste Emotion, Positiv/Negativ-Verhaeltnis, und welche Aktivitaeten mit guter/schlechter Stimmung korrelieren.', + parameters: [ + { + name: 'days', + type: 'number', + description: 'Analyse-Zeitraum in Tagen (Standard: 7)', + required: false, + }, + ], + async execute(params) { + const days = (params.days as number) ?? 7; + const all = await moodEntryTable.toArray(); + const visible = all.filter((e) => !e.deletedAt); + const decrypted = await decryptRecords('moodEntries', visible); + const entries = decrypted.map(toMoodEntry); + + // Filter to requested time window + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - days); + const cutoffStr = cutoff.toISOString().split('T')[0]; + const windowEntries = entries.filter((e) => e.date >= cutoffStr); + + if (windowEntries.length === 0) { + return { + success: true, + data: null, + message: `Keine Mood-Daten in den letzten ${days} Tagen`, + }; + } + + const avgLevel = getAvgLevel(entries, days); + const topEmotion = getTopEmotion(windowEntries, windowEntries.length); + const valence = getValenceRatio(windowEntries); + const activities = getActivityInsights(windowEntries); + + const topEmotionMeta = topEmotion ? EMOTION_META[topEmotion] : null; + + return { + success: true, + data: { + period: `${days} Tage`, + totalEntries: windowEntries.length, + avgLevel, + topEmotion: topEmotion + ? { emotion: topEmotion, label: topEmotionMeta!.de, emoji: topEmotionMeta!.emoji } + : null, + valence, + activityCorrelations: activities.slice(0, 5), + }, + message: `${days}d: Ø ${avgLevel}/10, ${topEmotionMeta ? `meist ${topEmotionMeta.emoji} ${topEmotionMeta.de}` : '–'}, ${valence.positive}% positiv / ${valence.negative}% negativ`, + }; + }, + }, +]; diff --git a/apps/mana/apps/web/src/lib/modules/myday/tools.ts b/apps/mana/apps/web/src/lib/modules/myday/tools.ts new file mode 100644 index 000000000..bf80472e0 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/myday/tools.ts @@ -0,0 +1,198 @@ +/** + * MyDay Tools — LLM-accessible day summary. + * + * Single read-only tool that aggregates today's snapshot + streaks + * into one response. Gives the agent full daily context in one call. + */ + +import type { ModuleTool } from '$lib/data/tools/types'; +import { db } from '$lib/data/database'; +import { decryptRecords } from '$lib/data/crypto'; +import { DEFAULT_DAILY_GOAL_ML } from '$lib/modules/drink/types'; +import { DEFAULT_DAILY_VALUES } from '$lib/modules/food/constants'; +import type { LocalTask } from '$lib/modules/todo/types'; +import type { LocalDrinkEntry } from '$lib/modules/drink/types'; +import type { LocalMeal, LocalGoal as NutriGoal } from '$lib/modules/food/types'; +import type { LocalPlace } from '$lib/modules/places/types'; +import type { LocalTimeBlock } from '$lib/data/time-blocks/types'; +import type { LocalGoal } from '$lib/companion/goals/types'; + +function todayStr(): string { + return new Date().toISOString().split('T')[0]; +} + +export const mydayTools: ModuleTool[] = [ + { + name: 'get_myday_summary', + module: 'myday', + description: + 'Gibt eine komplette Tageszusammenfassung zurueck: Tasks, Termine, Trinken, Ernaehrung, Orte, Habits/Streaks und aktive Ziele. Nutze dieses Tool zuerst, um den vollen Tageskontext zu bekommen.', + parameters: [], + async execute() { + const today = todayStr(); + const now = new Date().toISOString(); + const todayStart = `${today}T00:00:00`; + const todayEnd = `${today}T23:59:59`; + + // ── Parallel queries ──────────────────────── + const [allTasks, blocks, allDrinks, allMeals, foodGoals, allPlaces, streakStates, goals] = + await Promise.all([ + db.table('tasks').toArray(), + db + .table('timeBlocks') + .where('startDate') + .between(todayStart, todayEnd + '\uffff') + .toArray(), + db.table('drinkEntries').toArray(), + db.table('meals').toArray(), + db.table('goals').toArray(), + db.table('places').toArray(), + db.table('_streakState').toArray(), + db.table('companionGoals').toArray(), + ]); + + // ── Filter + decrypt ──────────────────────── + const activeTasks = allTasks.filter((t) => !t.deletedAt); + const eventBlocks = blocks.filter( + (b) => !b.deletedAt && b.type === 'event' && b.sourceModule === 'calendar' + ); + const todayDrinks = allDrinks.filter((d) => !d.deletedAt && d.date === today); + const todayMeals = allMeals.filter((m) => !m.deletedAt && m.date === today); + + const [decTasks, decBlocks, decDrinks, decMeals] = await Promise.all([ + decryptRecords('tasks', activeTasks), + decryptRecords('timeBlocks', eventBlocks), + decryptRecords('drinkEntries', todayDrinks), + decryptRecords('meals', todayMeals), + ]); + + // ── Tasks ─────────────────────────────────── + const openTasks = decTasks.filter((t) => !t.isCompleted); + const completedCount = decTasks.filter((t) => t.isCompleted).length; + const overdue = openTasks.filter((t) => t.dueDate != null && (t.dueDate as string) < today); + const dueToday = openTasks.filter((t) => (t.dueDate as string) === today); + + // ── Events ────────────────────────────────── + const events = decBlocks + .sort((a, b) => (a.startDate as string).localeCompare(b.startDate as string)) + .map((b) => ({ + title: (b.title as string) ?? '', + startTime: b.startDate, + endTime: b.endDate ?? b.startDate, + isAllDay: b.allDay ?? false, + })); + const upcoming = events.filter((e) => e.startTime >= now); + + // ── Drinks ────────────────────────────────── + let waterMl = 0; + let coffeeMl = 0; + let coffeeCount = 0; + let totalMl = 0; + for (const d of decDrinks) { + const ml = d.quantityMl ?? 0; + totalMl += ml; + if (d.drinkType === 'water') waterMl += ml; + if (d.drinkType === 'coffee') { + coffeeMl += ml; + coffeeCount++; + } + } + + // ── Nutrition ─────────────────────────────── + let totalCalories = 0; + let totalProtein = 0; + for (const m of decMeals) { + const n = m.nutrition as { calories?: number; protein?: number } | null; + if (n) { + totalCalories += n.calories ?? 0; + totalProtein += n.protein ?? 0; + } + } + const activeGoal = foodGoals.find((g) => !g.deletedAt); + const calorieGoal = activeGoal?.dailyCalories ?? DEFAULT_DAILY_VALUES.calories; + + // ── Places ────────────────────────────────── + const visitedToday = allPlaces.filter( + (p) => !p.deletedAt && p.lastVisitedAt && (p.lastVisitedAt as string).startsWith(today) + ).length; + + // ── Streaks ───────────────────────────────── + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + const yesterdayS = yesterday.toISOString().split('T')[0]; + + const streaks = (streakStates as Array>) + .filter((s) => s.lastActiveDate === today || s.lastActiveDate === yesterdayS) + .map((s) => ({ + label: s.label as string, + streak: s.currentStreak as number, + status: s.lastActiveDate === today ? 'active' : 'at_risk', + })); + + // ── Active Goals ──────────────────────────── + const activeGoals = goals + .filter((g) => g.status === 'active' && !g.deletedAt) + .map((g) => ({ + title: g.title, + current: g.currentValue, + target: g.target.value, + period: g.target.period, + percent: Math.round((g.currentValue / g.target.value) * 100), + })); + + const summary = { + date: today, + tasks: { + open: openTasks.length, + completed: completedCount, + overdue: overdue.length, + dueToday: dueToday.slice(0, 10).map((t) => ({ + title: (t.title as string) ?? '', + priority: t.priority as string | undefined, + })), + }, + events: { + total: events.length, + upcoming: upcoming.slice(0, 5).map((e) => ({ + title: e.title, + startTime: e.startTime, + isAllDay: e.isAllDay, + })), + }, + drinks: { + water: { + ml: waterMl, + goal: DEFAULT_DAILY_GOAL_ML, + percent: Math.round((waterMl / DEFAULT_DAILY_GOAL_ML) * 100), + }, + coffee: { count: coffeeCount }, + total: { ml: totalMl, count: decDrinks.length }, + }, + nutrition: { + meals: decMeals.length, + calories: { actual: Math.round(totalCalories), goal: calorieGoal }, + }, + places: { visitedToday }, + streaks, + goals: activeGoals, + }; + + const parts: string[] = []; + parts.push( + `${today}: ${openTasks.length} offene Tasks (${completedCount} erledigt, ${overdue.length} ueberfaellig)` + ); + if (upcoming.length > 0) + parts.push(`${events.length} Termine (naechster: ${upcoming[0].title})`); + parts.push(`Wasser: ${waterMl}/${DEFAULT_DAILY_GOAL_ML}ml, ${coffeeCount} Kaffee`); + if (decMeals.length > 0) + parts.push(`${decMeals.length} Mahlzeiten, ${Math.round(totalCalories)} kcal`); + if (activeGoals.length > 0) parts.push(`${activeGoals.length} aktive Ziele`); + + return { + success: true, + data: summary, + message: parts.join(' · '), + }; + }, + }, +]; diff --git a/apps/mana/apps/web/src/lib/modules/times/tools.ts b/apps/mana/apps/web/src/lib/modules/times/tools.ts index 789bbe031..21c478033 100644 --- a/apps/mana/apps/web/src/lib/modules/times/tools.ts +++ b/apps/mana/apps/web/src/lib/modules/times/tools.ts @@ -1,10 +1,18 @@ +/** + * Times Tools — LLM-accessible operations for time tracking. + */ + import type { ModuleTool } from '$lib/data/tools/types'; +import { db } from '$lib/data/database'; +import { formatDurationCompact, toTimeEntry, toProject, toClient } from './queries'; +import type { LocalTimeEntry, LocalProject, LocalClient } from './types'; +import type { LocalTimeBlock } from '$lib/data/time-blocks/types'; export const timesTools: ModuleTool[] = [ { name: 'start_timer', module: 'times', - description: 'Startet einen Zeitmess-Timer', + description: 'Startet einen Zeitmess-Timer mit optionaler Beschreibung und Projekt.', parameters: [ { name: 'description', @@ -12,20 +20,30 @@ export const timesTools: ModuleTool[] = [ description: 'Beschreibung der Taetigkeit', required: false, }, + { + name: 'projectId', + type: 'string', + description: 'ID eines Projekts (aus list_projects)', + required: false, + }, ], async execute(params) { const { timerStore } = await import('./stores/timer.svelte'); - await timerStore.start({ description: params.description as string | undefined }); + await timerStore.start({ + description: params.description as string | undefined, + projectId: params.projectId as string | undefined, + }); return { success: true, message: `Timer gestartet${params.description ? `: "${params.description}"` : ''}`, }; }, }, + { name: 'stop_timer', module: 'times', - description: 'Stoppt den laufenden Timer', + description: 'Stoppt den laufenden Timer und speichert den Zeiteintrag.', parameters: [], async execute() { const { timerStore } = await import('./stores/timer.svelte'); @@ -34,7 +52,158 @@ export const timesTools: ModuleTool[] = [ return { success: true, data: entry, - message: `Timer gestoppt (${Math.round(entry.duration / 60)} min)`, + message: `Timer gestoppt (${formatDurationCompact(entry.duration)})`, + }; + }, + }, + + { + name: 'get_timer_status', + module: 'times', + description: 'Gibt den Status des laufenden Timers zurueck (ob aktiv, Dauer, Beschreibung).', + parameters: [], + async execute() { + const { timerStore } = await import('./stores/timer.svelte'); + if (!timerStore.isRunning) { + return { success: true, data: { running: false }, message: 'Kein Timer aktiv' }; + } + const entry = timerStore.runningEntry; + return { + success: true, + data: { + running: true, + elapsed: timerStore.elapsedSeconds, + elapsedFormatted: formatDurationCompact(timerStore.elapsedSeconds), + description: entry?.description ?? '', + projectId: entry?.projectId ?? null, + }, + message: `Timer laeuft: ${formatDurationCompact(timerStore.elapsedSeconds)}${entry?.description ? ` — "${entry.description}"` : ''}`, + }; + }, + }, + + { + name: 'get_time_stats', + module: 'times', + description: + 'Gibt Zeiterfassungs-Statistiken zurueck: Stunden heute, diese Woche, und Aufschluesselung nach Projekt.', + parameters: [ + { + name: 'period', + type: 'string', + description: 'Zeitraum (Standard: week)', + required: false, + enum: ['today', 'week', 'month'], + }, + ], + async execute(params) { + const period = (params.period as string) ?? 'week'; + const now = new Date(); + const todayStr = now.toISOString().split('T')[0]; + + // Determine date range + let fromDate: string; + if (period === 'today') { + fromDate = todayStr; + } else if (period === 'week') { + const d = new Date(now); + d.setDate(d.getDate() - d.getDay() + (d.getDay() === 0 ? -6 : 1)); // Monday + fromDate = d.toISOString().split('T')[0]; + } else { + fromDate = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`; + } + + // Fetch entries + blocks + projects + const [allEntries, allBlocks, allProjects, allClients] = await Promise.all([ + db.table('timeEntries').toArray(), + db.table('timeBlocks').toArray(), + db.table('timeProjects').toArray(), + db.table('timeClients').toArray(), + ]); + + const blocksById = new Map(allBlocks.map((b) => [b.id, b])); + const entries = allEntries + .filter((e) => !e.deletedAt) + .map((e) => toTimeEntry(e, blocksById.get(e.timeBlockId))) + .filter((e) => e.date >= fromDate && e.date <= todayStr); + + const projects = allProjects.filter((p) => !p.deletedAt).map(toProject); + const clients = allClients.filter((c) => !c.deletedAt).map(toClient); + + // Aggregate + const totalSeconds = entries.reduce((s, e) => s + e.duration, 0); + const billableSeconds = entries + .filter((e) => e.isBillable) + .reduce((s, e) => s + e.duration, 0); + + // By project + const byProject = new Map(); + for (const e of entries) { + const key = e.projectId ?? '_none'; + byProject.set(key, (byProject.get(key) ?? 0) + e.duration); + } + + const projectBreakdown = [...byProject.entries()] + .sort((a, b) => b[1] - a[1]) + .map(([projId, secs]) => { + const proj = projects.find((p) => p.id === projId); + const client = proj?.clientId ? clients.find((c) => c.id === proj.clientId) : null; + return { + project: proj?.name ?? 'Ohne Projekt', + client: client?.name ?? null, + duration: formatDurationCompact(secs), + seconds: secs, + }; + }); + + return { + success: true, + data: { + period, + from: fromDate, + to: todayStr, + entries: entries.length, + total: formatDurationCompact(totalSeconds), + totalSeconds, + billable: formatDurationCompact(billableSeconds), + billableSeconds, + byProject: projectBreakdown, + }, + message: `${period}: ${formatDurationCompact(totalSeconds)} gesamt (${formatDurationCompact(billableSeconds)} abrechenbar), ${entries.length} Eintraege`, + }; + }, + }, + + { + name: 'list_projects', + module: 'times', + description: 'Listet alle aktiven Zeiterfassungs-Projekte mit Kunden-Info auf.', + parameters: [], + async execute() { + const [allProjects, allClients] = await Promise.all([ + db.table('timeProjects').toArray(), + db.table('timeClients').toArray(), + ]); + + const clients = allClients.filter((c) => !c.deletedAt).map(toClient); + const projects = allProjects + .filter((p) => !p.deletedAt && !p.isArchived) + .map(toProject) + .map((p) => { + const client = p.clientId ? clients.find((c) => c.id === p.clientId) : null; + return { + id: p.id, + name: p.name, + client: client?.name ?? null, + isBillable: p.isBillable, + color: p.color, + }; + }); + + return { + success: true, + data: projects, + message: `${projects.length} aktive Projekte`, }; }, }, diff --git a/packages/shared-ai/src/tools/schemas.ts b/packages/shared-ai/src/tools/schemas.ts index 7c9956697..94a9091b6 100644 --- a/packages/shared-ai/src/tools/schemas.ts +++ b/packages/shared-ai/src/tools/schemas.ts @@ -488,6 +488,124 @@ export const AI_TOOL_CATALOG: readonly ToolSchema[] = [ parameters: [], }, + // ── MyDay ───────────────────────────────────────────────── + { + name: 'get_myday_summary', + module: 'myday', + description: + 'Gibt eine komplette Tageszusammenfassung zurueck: Tasks, Termine, Trinken, Ernaehrung, Orte, Streaks und aktive Ziele. Nutze dieses Tool zuerst, um den vollen Tageskontext zu bekommen.', + defaultPolicy: 'auto', + parameters: [], + }, + + // ── Goals ───────────────────────────────────────────────── + { + name: 'list_goals', + module: 'goals', + description: + 'Listet alle Ziele mit aktuellem Fortschritt auf. Zeigt Titel, Fortschritt, Zielwert, Zeitraum und Status.', + defaultPolicy: 'auto', + parameters: [ + { + name: 'filter', + type: 'string', + description: 'Welche Ziele zeigen', + required: false, + enum: ['active', 'paused', 'completed', 'all'], + }, + ], + }, + { + name: 'get_goal_progress', + module: 'goals', + description: + 'Gibt den detaillierten Fortschritt eines einzelnen Ziels zurueck, inklusive Metrik-Details und Periodeninfo.', + defaultPolicy: 'auto', + parameters: [{ name: 'goalId', type: 'string', description: 'ID des Ziels', required: true }], + }, + { + name: 'create_goal', + module: 'goals', + description: + 'Erstellt ein neues Ziel. Kann entweder ein Template verwenden (templateId) oder ein benutzerdefiniertes Ziel erstellen. Verfuegbare Templates: tpl-water-daily, tpl-tasks-daily, tpl-meals-daily, tpl-calories-daily, tpl-places-weekly, tpl-coffee-limit.', + defaultPolicy: 'propose', + parameters: [ + { + name: 'templateId', + type: 'string', + description: + 'ID eines Templates (z.B. "tpl-water-daily"). Wenn gesetzt, werden andere Felder ignoriert.', + required: false, + }, + { + name: 'title', + type: 'string', + description: 'Titel des Ziels (nur fuer benutzerdefinierte Ziele)', + required: false, + }, + { + name: 'description', + type: 'string', + description: 'Beschreibung', + required: false, + }, + { + name: 'targetValue', + type: 'number', + description: 'Zielwert (z.B. 8 fuer "8 Glaeser Wasser")', + required: false, + }, + { + name: 'period', + type: 'string', + description: 'Zeitraum', + required: false, + enum: ['day', 'week', 'month'], + }, + { + name: 'comparison', + type: 'string', + description: 'Vergleich: gte = mindestens, lte = hoechstens', + required: false, + enum: ['gte', 'lte'], + }, + { + name: 'eventType', + type: 'string', + description: + 'Domain-Event zum Zaehlen (z.B. "DrinkLogged", "TaskCompleted", "MealLogged", "WorkoutFinished")', + required: false, + }, + { + name: 'moduleId', + type: 'string', + description: 'Zugehoeriges Modul (z.B. "drink", "todo", "food", "body")', + required: false, + }, + ], + }, + { + name: 'pause_goal', + module: 'goals', + description: 'Pausiert ein aktives Ziel. Kann spaeter wieder fortgesetzt werden.', + defaultPolicy: 'propose', + parameters: [{ name: 'goalId', type: 'string', description: 'ID des Ziels', required: true }], + }, + { + name: 'resume_goal', + module: 'goals', + description: 'Setzt ein pausiertes Ziel fort.', + defaultPolicy: 'propose', + parameters: [{ name: 'goalId', type: 'string', description: 'ID des Ziels', required: true }], + }, + { + name: 'complete_goal', + module: 'goals', + description: 'Markiert ein Ziel als abgeschlossen.', + defaultPolicy: 'propose', + parameters: [{ name: 'goalId', type: 'string', description: 'ID des Ziels', required: true }], + }, + // ── Contacts ────────────────────────────────────────────── { name: 'create_contact', @@ -520,6 +638,222 @@ export const AI_TOOL_CATALOG: readonly ToolSchema[] = [ defaultPolicy: 'auto', parameters: [], }, + + // ── Mood ────────────────────────────────────────────────── + { + name: 'log_mood', + module: 'mood', + description: + 'Erfasst einen Mood-Check-in mit Level (1-10), primaerer Emotion und optionalem Kontext.', + defaultPolicy: 'propose', + parameters: [ + { + name: 'level', + type: 'number', + description: 'Stimmungs-Level von 1 (schlecht) bis 10 (super)', + required: true, + }, + { + name: 'emotion', + type: 'string', + description: 'Primaere Emotion', + required: true, + enum: [ + 'happy', + 'calm', + 'energized', + 'grateful', + 'excited', + 'loved', + 'hopeful', + 'neutral', + 'bored', + 'tired', + 'sad', + 'anxious', + 'angry', + 'stressed', + 'frustrated', + 'overwhelmed', + ], + }, + { + name: 'activity', + type: 'string', + description: 'Was machst du gerade?', + required: false, + enum: [ + 'work', + 'exercise', + 'social', + 'alone', + 'commute', + 'eating', + 'resting', + 'creative', + 'outdoors', + 'screen', + 'chores', + 'other', + ], + }, + { + name: 'notes', + type: 'string', + description: 'Optionale Notiz zum Check-in', + required: false, + }, + ], + }, + { + name: 'get_mood_today', + module: 'mood', + description: 'Gibt alle heutigen Mood-Eintraege zurueck mit Durchschnitts-Level und Emotionen.', + defaultPolicy: 'auto', + parameters: [], + }, + { + name: 'get_mood_insights', + module: 'mood', + description: + 'Gibt Mood-Trends und Muster zurueck: Durchschnitt der letzten 7/30 Tage, haeufigste Emotion, Positiv/Negativ-Verhaeltnis, und welche Aktivitaeten mit guter/schlechter Stimmung korrelieren.', + defaultPolicy: 'auto', + parameters: [ + { + name: 'days', + type: 'number', + description: 'Analyse-Zeitraum in Tagen (Standard: 7)', + required: false, + }, + ], + }, + + // ── Finance ─────────────────────────────────────────────── + { + name: 'add_transaction', + module: 'finance', + description: 'Erfasst eine Einnahme oder Ausgabe', + defaultPolicy: 'propose', + parameters: [ + { + name: 'type', + type: 'string', + description: 'Art', + required: true, + enum: ['income', 'expense'], + }, + { name: 'amount', type: 'number', description: 'Betrag in Euro', required: true }, + { name: 'description', type: 'string', description: 'Beschreibung', required: true }, + { + name: 'date', + type: 'string', + description: 'Datum (YYYY-MM-DD, Standard: heute)', + required: false, + }, + ], + }, + { + name: 'get_month_summary', + module: 'finance', + description: + 'Gibt die Finanz-Zusammenfassung fuer einen Monat zurueck: Einnahmen, Ausgaben, Bilanz, Ausgaben pro Kategorie.', + defaultPolicy: 'auto', + parameters: [ + { + name: 'month', + type: 'string', + description: 'Monat im Format YYYY-MM (Standard: aktueller Monat)', + required: false, + }, + ], + }, + { + name: 'list_transactions', + module: 'finance', + description: + 'Listet die letzten Transaktionen auf. Optional nach Typ (income/expense) und Monat filterbar.', + defaultPolicy: 'auto', + parameters: [ + { + name: 'type', + type: 'string', + description: 'Nur income oder expense zeigen', + required: false, + enum: ['income', 'expense'], + }, + { + name: 'month', + type: 'string', + description: 'Monat im Format YYYY-MM', + required: false, + }, + { + name: 'limit', + type: 'number', + description: 'Maximale Anzahl (Standard: 20)', + required: false, + }, + ], + }, + + // ── Times ───────────────────────────────────────────────── + { + name: 'start_timer', + module: 'times', + description: 'Startet einen Zeitmess-Timer mit optionaler Beschreibung und Projekt.', + defaultPolicy: 'propose', + parameters: [ + { + name: 'description', + type: 'string', + description: 'Beschreibung der Taetigkeit', + required: false, + }, + { + name: 'projectId', + type: 'string', + description: 'ID eines Projekts (aus list_projects)', + required: false, + }, + ], + }, + { + name: 'stop_timer', + module: 'times', + description: 'Stoppt den laufenden Timer und speichert den Zeiteintrag.', + defaultPolicy: 'propose', + parameters: [], + }, + { + name: 'get_timer_status', + module: 'times', + description: 'Gibt den Status des laufenden Timers zurueck (ob aktiv, Dauer, Beschreibung).', + defaultPolicy: 'auto', + parameters: [], + }, + { + name: 'get_time_stats', + module: 'times', + description: + 'Gibt Zeiterfassungs-Statistiken zurueck: Stunden heute, diese Woche, und Aufschluesselung nach Projekt.', + defaultPolicy: 'auto', + parameters: [ + { + name: 'period', + type: 'string', + description: 'Zeitraum (Standard: week)', + required: false, + enum: ['today', 'week', 'month'], + }, + ], + }, + { + name: 'list_projects', + module: 'times', + description: 'Listet alle aktiven Zeiterfassungs-Projekte mit Kunden-Info auf.', + defaultPolicy: 'auto', + parameters: [], + }, ]; // ═══════════════════════════════════════════════════════════════