diff --git a/apps/manacore/apps/web/src/lib/components/SuggestionToast.svelte b/apps/manacore/apps/web/src/lib/components/SuggestionToast.svelte new file mode 100644 index 000000000..3e76fa2bf --- /dev/null +++ b/apps/manacore/apps/web/src/lib/components/SuggestionToast.svelte @@ -0,0 +1,149 @@ + + + +{#if visible && suggestion} + +{/if} + + diff --git a/apps/manacore/apps/web/src/lib/data/database.ts b/apps/manacore/apps/web/src/lib/data/database.ts index f61ff2f7f..a7e5a0b5d 100644 --- a/apps/manacore/apps/web/src/lib/data/database.ts +++ b/apps/manacore/apps/web/src/lib/data/database.ts @@ -10,6 +10,7 @@ import Dexie, { type EntityTable } from 'dexie'; import { trackFirstContent } from '$lib/stores/funnel-tracking'; import { fire as fireTrigger } from '$lib/triggers/registry'; +import { checkInlineSuggestion } from '$lib/triggers/inline-suggest'; // ─── Database ────────────────────────────────────────────── @@ -203,6 +204,44 @@ db.version(1).stores({ manaLinks: 'id, sourceAppId, sourceRecordId, targetAppId, targetRecordId', }); +// ─── Schema Migrations ──────────────────────────────────────── +// Version 2: Habits emoji → icon field migration + +const EMOJI_TO_ICON: Record = { + '\u2615': 'coffee', + '\ud83d\udeb6': 'person-simple-walk', + '\ud83c\udfc3': 'person-simple-run', + '\ud83e\uddd8': 'person-simple-tai-chi', + '\ud83d\udca7': 'drop', + '\ud83c\udf4e': 'apple-logo', + '\ud83d\udcda': 'book-open', + '\ud83d\udcaa': 'barbell', + '\ud83d\udecc': 'bed', + '\ud83c\udfb5': 'music-note', + '\ud83d\udc8a': 'pill', + '\ud83c\udf7a': 'beer-stein', + '\ud83c\udf55': 'pizza', + '\ud83d\udeb4': 'bicycle', + '\ud83d\udcdd': 'pencil-simple', + '\ud83e\uddfc': 'tooth', + '\u2b50': 'star', + '\ud83d\ude2e\u200d\ud83d\udca8': 'wind', +}; + +db.version(2) + .stores({}) + .upgrade((tx) => { + return tx + .table('habits') + .toCollection() + .modify((habit: Record) => { + if (habit.emoji !== undefined && habit.icon === undefined) { + habit.icon = EMOJI_TO_ICON[habit.emoji as string] ?? 'star'; + delete habit.emoji; + } + }); + }); + // ─── Sync App Map ────────────────────────────────────────── // Maps each table to its appId for sync routing. // The SyncEngine uses this to group pending changes and push to /sync/{appId}. @@ -373,6 +412,9 @@ for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) { }); trackFirstContent(appId); fireTrigger(appId, tableName, 'insert', { ...obj }); + checkInlineSuggestion(appId, tableName, { ...obj }).then((sug) => { + if (sug) window.dispatchEvent(new CustomEvent('mana:automation-suggest', { detail: sug })); + }); }); table.hook('updating', function (modifications, primKey) { diff --git a/apps/manacore/apps/web/src/lib/modules/automations/ListView.svelte b/apps/manacore/apps/web/src/lib/modules/automations/ListView.svelte index 1445abca1..358d5eb98 100644 --- a/apps/manacore/apps/web/src/lib/modules/automations/ListView.svelte +++ b/apps/manacore/apps/web/src/lib/modules/automations/ListView.svelte @@ -12,12 +12,15 @@ import type { ConditionOp } from '$lib/triggers/conditions'; import type { ViewProps } from '$lib/app-registry'; import { Trash } from '@manacore/shared-icons'; + import { generateSuggestions, dismissSuggestion, isSuggestionDismissed } from '$lib/triggers'; + import type { AutomationSuggestion } from '$lib/triggers'; let { navigate, goBack, params }: ViewProps = $props(); // ─── Data ──────────────────────────────────────────────── let automations = $state([]); - let habits = $state<{ id: string; title: string; emoji: string }[]>([]); + let habits = $state<{ id: string; title: string; icon: string }[]>([]); + let suggestions = $state([]); $effect(() => { const sub = liveQuery(async () => { @@ -40,7 +43,7 @@ .map((h: Record) => ({ id: h.id as string, title: h.title as string, - emoji: h.emoji as string, + icon: (h.icon ?? h.emoji ?? 'star') as string, })); }).subscribe((val) => { habits = val ?? []; @@ -48,6 +51,43 @@ return () => sub.unsubscribe(); }); + // Load suggestions + async function refreshSuggestions() { + const all = await generateSuggestions(); + suggestions = all.filter((s) => !isSuggestionDismissed(s.id)); + } + + $effect(() => { + refreshSuggestions(); + }); + + // Refresh suggestions when automations change + $effect(() => { + automations; // track + refreshSuggestions(); + }); + + async function acceptSuggestion(sug: AutomationSuggestion) { + await automationsStore.create({ + name: sug.name, + sourceApp: sug.sourceApp, + sourceCollection: sug.sourceCollection, + sourceOp: sug.sourceOp, + conditionField: sug.conditionField, + conditionOp: sug.conditionOp, + conditionValue: sug.conditionValue, + targetApp: sug.targetApp, + targetAction: sug.targetAction, + targetParams: sug.targetParams, + }); + suggestions = suggestions.filter((s) => s.id !== sug.id); + } + + function handleDismiss(id: string) { + dismissSuggestion(id); + suggestions = suggestions.filter((s) => s.id !== id); + } + // ─── Create Form ───────────────────────────────────────── let showCreate = $state(false); let newName = $state(''); @@ -129,6 +169,25 @@ {/if} + + {#if suggestions.length > 0} +
+ + {#each suggestions as sug (sug.id)} +
+
+ {sug.name} + {sug.description} +
+
+ + +
+
+ {/each} +
+ {/if} + {#if showCreate}
@@ -211,7 +270,7 @@ > {#each habits as h} - + {/each} {:else} @@ -287,6 +346,84 @@ padding: 0.5rem; } + /* ── Suggestions ──────────────────────── */ + .suggestions-section { + display: flex; + flex-direction: column; + gap: 0.375rem; + } + + .section-label { + font-size: 0.625rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #f59e0b; + } + + .suggestion-card { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.625rem; + border-radius: 0.5rem; + background: rgba(245, 158, 11, 0.06); + border: 1px solid rgba(245, 158, 11, 0.15); + } + + .suggestion-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.125rem; + min-width: 0; + } + + .suggestion-name { + font-size: 0.75rem; + font-weight: 600; + color: var(--color-foreground); + } + + .suggestion-desc { + font-size: 0.6875rem; + color: var(--color-muted-foreground); + } + + .suggestion-actions { + display: flex; + align-items: center; + gap: 0.25rem; + flex-shrink: 0; + } + + .sug-accept { + padding: 0.25rem 0.5rem; + border-radius: 0.375rem; + border: none; + background: #8b5cf6; + color: white; + font-size: 0.625rem; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + } + .sug-accept:hover { + filter: brightness(1.1); + } + + .sug-dismiss { + border: none; + background: transparent; + color: var(--color-muted-foreground); + font-size: 0.875rem; + cursor: pointer; + padding: 0 0.25rem; + } + .sug-dismiss:hover { + color: #ef4444; + } + .header { display: flex; align-items: center; diff --git a/apps/manacore/apps/web/src/lib/triggers/index.ts b/apps/manacore/apps/web/src/lib/triggers/index.ts index 4092ed91d..40a2e287a 100644 --- a/apps/manacore/apps/web/src/lib/triggers/index.ts +++ b/apps/manacore/apps/web/src/lib/triggers/index.ts @@ -8,3 +8,6 @@ export { evaluateCondition } from './conditions'; export type { ConditionOp } from './conditions'; export { ACTIONS, getAction } from './actions'; export { loadAutomations } from './loader'; +export { generateSuggestions } from './suggestions'; +export type { AutomationSuggestion } from './suggestions'; +export { checkInlineSuggestion, dismissSuggestion, isSuggestionDismissed } from './inline-suggest'; diff --git a/apps/manacore/apps/web/src/lib/triggers/inline-suggest.ts b/apps/manacore/apps/web/src/lib/triggers/inline-suggest.ts new file mode 100644 index 000000000..596bc97de --- /dev/null +++ b/apps/manacore/apps/web/src/lib/triggers/inline-suggest.ts @@ -0,0 +1,104 @@ +/** + * Inline Suggestion — Checks if a newly created record matches + * a known entity in another module and suggests an automation. + * + * Called from Dexie hooks. Emits a CustomEvent that the UI catches. + */ + +import { db } from '$lib/data/database'; +import type { AutomationSuggestion } from './suggestions'; + +const MIN_MATCH_LENGTH = 4; +const DISMISSED_KEY = 'mana:dismissed-suggestions'; + +function getDismissed(): Set { + try { + return new Set(JSON.parse(localStorage.getItem(DISMISSED_KEY) ?? '[]')); + } catch { + return new Set(); + } +} + +export function dismissSuggestion(id: string): void { + const dismissed = getDismissed(); + dismissed.add(id); + localStorage.setItem(DISMISSED_KEY, JSON.stringify([...dismissed])); +} + +export function isSuggestionDismissed(id: string): boolean { + return getDismissed().has(id); +} + +/** + * Check if a newly created record should trigger an inline suggestion. + * Returns null if no match or suggestion already dismissed/automated. + */ +export async function checkInlineSuggestion( + appId: string, + collection: string, + data: Record +): Promise { + const title = String(data.title ?? data.name ?? '').trim(); + if (title.length < MIN_MATCH_LENGTH) return null; + + // Only check specific source combinations + const isEvent = appId === 'calendar' && collection === 'events'; + const isTask = appId === 'todo' && collection === 'tasks'; + if (!isEvent && !isTask) return null; + + // Load habits to match against + const habits = await db + .table('habits') + .toArray() + .then((all) => + all + .filter((h: Record) => !h.deletedAt && !h.isArchived) + .map((h: Record) => ({ + id: h.id as string, + title: h.title as string, + })) + ); + + // Find matching habit + const matchedHabit = habits.find( + (h) => h.title.length >= MIN_MATCH_LENGTH && title.toLowerCase().includes(h.title.toLowerCase()) + ); + if (!matchedHabit) return null; + + const sugId = `inline-${appId}-habit-${matchedHabit.id}`; + + // Skip if dismissed + if (isSuggestionDismissed(sugId)) return null; + + // Skip if automation already exists for this pair + const existingAutos = await db + .table('automations') + .toArray() + .then((all) => all.filter((a: Record) => !a.deletedAt && a.enabled)); + + const alreadyAutomated = existingAutos.some( + (a: Record) => + a.sourceApp === appId && + a.sourceCollection === collection && + a.targetAction === 'logHabit' && + (a.targetParams as Record)?.habitId === matchedHabit.id + ); + if (alreadyAutomated) return null; + + const sourceLabel = isEvent ? 'Kalender-Event' : 'Aufgabe'; + + return { + id: sugId, + name: `${sourceLabel} → ${matchedHabit.title}`, + description: `"${matchedHabit.title}" automatisch als Habit loggen wenn ${sourceLabel} erstellt wird?`, + sourceApp: appId, + sourceCollection: collection, + sourceOp: 'insert', + conditionField: 'title', + conditionOp: 'contains', + conditionValue: matchedHabit.title, + targetApp: 'habits', + targetAction: 'logHabit', + targetParams: { habitId: matchedHabit.id }, + }; +} diff --git a/apps/manacore/apps/web/src/lib/triggers/suggestions.ts b/apps/manacore/apps/web/src/lib/triggers/suggestions.ts new file mode 100644 index 000000000..6360b5e56 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/triggers/suggestions.ts @@ -0,0 +1,149 @@ +/** + * Suggestion Engine — Discovers potential automations by matching + * entity names across modules (Habits ↔ Events, Tasks, Places). + */ + +import { db } from '$lib/data/database'; +import type { ConditionOp } from './conditions'; + +export interface AutomationSuggestion { + id: string; + name: string; + description: string; + sourceApp: string; + sourceCollection: string; + sourceOp: 'insert'; + conditionField: string; + conditionOp: ConditionOp; + conditionValue: string; + targetApp: string; + targetAction: string; + targetParams: Record; +} + +const MIN_MATCH_LENGTH = 4; + +function titleContains(text: string, keyword: string): boolean { + if (keyword.length < MIN_MATCH_LENGTH) return false; + return text.toLowerCase().includes(keyword.toLowerCase()); +} + +/** + * Generate automation suggestions by cross-matching entity names. + * Excludes suggestions that already have a matching automation. + */ +export async function generateSuggestions(): Promise { + const suggestions: AutomationSuggestion[] = []; + + // Load habits + const habits = await db + .table('habits') + .toArray() + .then((all) => + all + .filter((h: Record) => !h.deletedAt && !h.isArchived) + .map((h: Record) => ({ + id: h.id as string, + title: h.title as string, + icon: (h.icon as string) ?? 'star', + })) + ); + + if (habits.length === 0) return suggestions; + + // Load existing automations to avoid duplicate suggestions + const existingAutos = await db + .table('automations') + .toArray() + .then((all) => all.filter((a: Record) => !a.deletedAt)); + + function automationExists( + sourceApp: string, + sourceCollection: string, + conditionValue: string, + targetAction: string, + habitId: string + ): boolean { + return existingAutos.some( + (a: Record) => + a.sourceApp === sourceApp && + a.sourceCollection === sourceCollection && + a.targetAction === targetAction && + (a.targetParams as Record)?.habitId === habitId && + String(a.conditionValue ?? '') + .toLowerCase() + .includes(conditionValue.toLowerCase()) + ); + } + + // ─── Events ↔ Habits ──────────────────────────────────── + const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); + const events = await db + .table('events') + .toArray() + .then((all) => + all.filter( + (e: Record) => !e.deletedAt && (e.startDate as string) >= thirtyDaysAgo + ) + ); + + for (const habit of habits) { + const matchingEvents = events.filter((e: Record) => + titleContains(String(e.title ?? ''), habit.title) + ); + + if ( + matchingEvents.length > 0 && + !automationExists('calendar', 'events', habit.title, 'logHabit', habit.id) + ) { + suggestions.push({ + id: `sug-cal-habit-${habit.id}`, + name: `Kalender → ${habit.title}`, + description: `Wenn ein Event mit "${habit.title}" erstellt wird, Habit automatisch loggen`, + sourceApp: 'calendar', + sourceCollection: 'events', + sourceOp: 'insert', + conditionField: 'title', + conditionOp: 'contains', + conditionValue: habit.title, + targetApp: 'habits', + targetAction: 'logHabit', + targetParams: { habitId: habit.id }, + }); + } + } + + // ─── Tasks ↔ Habits ───────────────────────────────────── + const tasks = await db + .table('tasks') + .toArray() + .then((all) => all.filter((t: Record) => !t.deletedAt)); + + for (const habit of habits) { + const matchingTasks = tasks.filter((t: Record) => + titleContains(String(t.title ?? ''), habit.title) + ); + + if ( + matchingTasks.length > 0 && + !automationExists('todo', 'tasks', habit.title, 'logHabit', habit.id) + ) { + suggestions.push({ + id: `sug-todo-habit-${habit.id}`, + name: `Todo → ${habit.title}`, + description: `Wenn eine Aufgabe mit "${habit.title}" erstellt wird, Habit automatisch loggen`, + sourceApp: 'todo', + sourceCollection: 'tasks', + sourceOp: 'insert', + conditionField: 'title', + conditionOp: 'contains', + conditionValue: habit.title, + targetApp: 'habits', + targetAction: 'logHabit', + targetParams: { habitId: habit.id }, + }); + } + } + + return suggestions; +} diff --git a/apps/manacore/apps/web/src/routes/+layout.svelte b/apps/manacore/apps/web/src/routes/+layout.svelte index 9d7f9307b..90c03a089 100644 --- a/apps/manacore/apps/web/src/routes/+layout.svelte +++ b/apps/manacore/apps/web/src/routes/+layout.svelte @@ -4,6 +4,7 @@ import { theme } from '$lib/stores/theme'; import { authStore } from '$lib/stores/auth.svelte'; import { loadAutomations } from '$lib/triggers'; + import SuggestionToast from '$lib/components/SuggestionToast.svelte'; let { children } = $props(); @@ -24,3 +25,4 @@ {@render children()} +