feat(manacore/web): proactive automation suggestions (discovery + inline)

Two suggestion systems for cross-module automations:

1. Discovery: Automations ListView shows suggestion cards when it
   detects name overlaps between modules (e.g. "Basketball" habit +
   calendar events containing "Basketball"). Accept or dismiss.

2. Inline: When creating an event/task whose title matches a habit name,
   a toast appears: "Log habit 'Basketball' automatically?" with
   one-click activation. Auto-dismisses after 8s.

Both use simple case-insensitive string matching (min 4 chars).
Dismissed suggestions are persisted in localStorage.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-03 21:00:23 +02:00
parent 0f38a567fe
commit 2dd0812757
7 changed files with 589 additions and 3 deletions

View file

@ -0,0 +1,149 @@
<!--
SuggestionToast — Global inline suggestion for cross-module automations.
Listens for 'mana:automation-suggest' events and shows a dismissable toast.
-->
<script lang="ts">
import { onMount } from 'svelte';
import { automationsStore } from '$lib/modules/automations/stores/automations.svelte';
import { dismissSuggestion } from '$lib/triggers/inline-suggest';
import type { AutomationSuggestion } from '$lib/triggers/suggestions';
let suggestion = $state<AutomationSuggestion | null>(null);
let visible = $state(false);
onMount(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent<AutomationSuggestion>).detail;
suggestion = detail;
visible = true;
// Auto-dismiss after 8 seconds
setTimeout(() => {
if (visible && suggestion?.id === detail.id) visible = false;
}, 8000);
};
window.addEventListener('mana:automation-suggest', handler);
return () => window.removeEventListener('mana:automation-suggest', handler);
});
async function accept() {
if (!suggestion) return;
await automationsStore.create({
name: suggestion.name,
sourceApp: suggestion.sourceApp,
sourceCollection: suggestion.sourceCollection,
sourceOp: suggestion.sourceOp,
conditionField: suggestion.conditionField,
conditionOp: suggestion.conditionOp,
conditionValue: suggestion.conditionValue,
targetApp: suggestion.targetApp,
targetAction: suggestion.targetAction,
targetParams: suggestion.targetParams,
});
visible = false;
}
function dismiss() {
if (suggestion) dismissSuggestion(suggestion.id);
visible = false;
}
</script>
{#if visible && suggestion}
<div class="toast" role="alert">
<div class="toast-content">
<span class="toast-icon">&#9889;</span>
<span class="toast-text">{suggestion.description}</span>
</div>
<div class="toast-actions">
<button class="toast-accept" onclick={accept}>Aktivieren</button>
<button class="toast-dismiss" onclick={dismiss}>&times;</button>
</div>
</div>
{/if}
<style>
.toast {
position: fixed;
bottom: 1rem;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 1rem;
border-radius: 0.75rem;
background: rgba(30, 30, 40, 0.95);
border: 1px solid rgba(139, 92, 246, 0.3);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
backdrop-filter: blur(12px);
max-width: min(90vw, 480px);
animation: slide-up 0.3s ease-out;
}
.toast-content {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
min-width: 0;
}
.toast-icon {
font-size: 1rem;
flex-shrink: 0;
}
.toast-text {
font-size: 0.75rem;
color: #e5e7eb;
line-height: 1.3;
}
.toast-actions {
display: flex;
align-items: center;
gap: 0.375rem;
flex-shrink: 0;
}
.toast-accept {
padding: 0.25rem 0.625rem;
border-radius: 0.375rem;
border: none;
background: #8b5cf6;
color: white;
font-size: 0.6875rem;
font-weight: 600;
cursor: pointer;
transition: filter 0.15s;
white-space: nowrap;
}
.toast-accept:hover {
filter: brightness(1.15);
}
.toast-dismiss {
border: none;
background: transparent;
color: #6b7280;
font-size: 1rem;
cursor: pointer;
padding: 0.125rem 0.25rem;
line-height: 1;
}
.toast-dismiss:hover {
color: #ef4444;
}
@keyframes slide-up {
from {
opacity: 0;
transform: translateX(-50%) translateY(1rem);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
</style>

View file

@ -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<string, string> = {
'\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<string, unknown>) => {
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) {

View file

@ -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<LocalAutomation[]>([]);
let habits = $state<{ id: string; title: string; emoji: string }[]>([]);
let habits = $state<{ id: string; title: string; icon: string }[]>([]);
let suggestions = $state<AutomationSuggestion[]>([]);
$effect(() => {
const sub = liveQuery(async () => {
@ -40,7 +43,7 @@
.map((h: Record<string, unknown>) => ({
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}
</div>
<!-- Suggestions -->
{#if suggestions.length > 0}
<div class="suggestions-section">
<span class="section-label">Vorschlaege</span>
{#each suggestions as sug (sug.id)}
<div class="suggestion-card">
<div class="suggestion-info">
<span class="suggestion-name">{sug.name}</span>
<span class="suggestion-desc">{sug.description}</span>
</div>
<div class="suggestion-actions">
<button class="sug-accept" onclick={() => acceptSuggestion(sug)}>Aktivieren</button>
<button class="sug-dismiss" onclick={() => handleDismiss(sug.id)}>&times;</button>
</div>
</div>
{/each}
</div>
{/if}
<!-- Create Form -->
{#if showCreate}
<form class="create-form" onsubmit={handleCreate}>
@ -211,7 +270,7 @@
>
<option value="">Habit wählen...</option>
{#each habits as h}
<option value={h.id}>{h.emoji} {h.title}</option>
<option value={h.id}>{h.title}</option>
{/each}
</select>
{: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;

View file

@ -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';

View file

@ -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<string> {
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<string, unknown>
): Promise<AutomationSuggestion | null> {
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<string, unknown>) => !h.deletedAt && !h.isArchived)
.map((h: Record<string, unknown>) => ({
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<string, unknown>) => !a.deletedAt && a.enabled));
const alreadyAutomated = existingAutos.some(
(a: Record<string, unknown>) =>
a.sourceApp === appId &&
a.sourceCollection === collection &&
a.targetAction === 'logHabit' &&
(a.targetParams as Record<string, string>)?.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 },
};
}

View file

@ -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<string, string>;
}
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<AutomationSuggestion[]> {
const suggestions: AutomationSuggestion[] = [];
// Load habits
const habits = await db
.table('habits')
.toArray()
.then((all) =>
all
.filter((h: Record<string, unknown>) => !h.deletedAt && !h.isArchived)
.map((h: Record<string, unknown>) => ({
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<string, unknown>) => !a.deletedAt));
function automationExists(
sourceApp: string,
sourceCollection: string,
conditionValue: string,
targetAction: string,
habitId: string
): boolean {
return existingAutos.some(
(a: Record<string, unknown>) =>
a.sourceApp === sourceApp &&
a.sourceCollection === sourceCollection &&
a.targetAction === targetAction &&
(a.targetParams as Record<string, string>)?.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<string, unknown>) => !e.deletedAt && (e.startDate as string) >= thirtyDaysAgo
)
);
for (const habit of habits) {
const matchingEvents = events.filter((e: Record<string, unknown>) =>
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<string, unknown>) => !t.deletedAt));
for (const habit of habits) {
const matchingTasks = tasks.filter((t: Record<string, unknown>) =>
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;
}

View file

@ -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 @@
</script>
{@render children()}
<SuggestionToast />