feat(manacore/web): add cross-module trigger registry for automations

New trigger system that listens to Dexie write operations and fires
configurable actions in other modules. Automations are stored in
IndexedDB and loaded at app startup.

Example: calendar event "Basketball" created → habit "Basketball" logged.

Architecture: Dexie hooks → fire() → registry → condition check → action
Actions are async fire-and-forget with loop prevention.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-03 20:38:41 +02:00
parent 2241663823
commit ee03782fde
7 changed files with 236 additions and 1 deletions

View file

@ -9,6 +9,7 @@
import Dexie, { type EntityTable } from 'dexie';
import { trackFirstContent } from '$lib/stores/funnel-tracking';
import { fire as fireTrigger } from '$lib/triggers/registry';
// ─── Database ──────────────────────────────────────────────
@ -22,6 +23,7 @@ db.version(1).stores({
// ─── Core / ManaCore (appId: 'manacore') ───
userSettings: 'id, key',
dashboardConfigs: 'id',
automations: 'id, sourceApp, targetApp, enabled, [sourceApp+sourceCollection]',
// ─── Todo (appId: 'todo') ───
tasks:
@ -206,7 +208,7 @@ db.version(1).stores({
// The SyncEngine uses this to group pending changes and push to /sync/{appId}.
export const SYNC_APP_MAP: Record<string, string[]> = {
manacore: ['userSettings', 'dashboardConfigs'],
manacore: ['userSettings', 'dashboardConfigs', 'automations'],
todo: ['tasks', 'todoProjects', 'taskLabels', 'reminders', 'boardViews'],
calendar: ['calendars', 'events', 'eventTags'],
contacts: ['contacts', 'contactTags'],
@ -370,6 +372,7 @@ for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) {
createdAt: now,
});
trackFirstContent(appId);
fireTrigger(appId, tableName, 'insert', { ...obj });
});
table.hook('updating', function (modifications, primKey) {
@ -389,6 +392,8 @@ for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) {
deletedAt: (modifications as Record<string, unknown>).deletedAt as string | undefined,
createdAt: now,
});
const op = (modifications as Record<string, unknown>).deletedAt ? 'delete' : 'update';
fireTrigger(appId, tableName, op, modifications as Record<string, unknown>);
});
}
}

View file

@ -0,0 +1,41 @@
/**
* Trigger Action Catalog
*
* Maps appId actionName handler function.
* All imports are lazy to avoid circular dependencies.
*/
export type ActionHandler = (
params: Record<string, string>,
sourceData: Record<string, unknown>
) => Promise<void>;
export const ACTIONS: Record<string, Record<string, ActionHandler>> = {
habits: {
logHabit: async (params) => {
const { habitsStore } = await import('$lib/modules/habits/stores/habits.svelte');
await habitsStore.logHabit(params.habitId, 'Auto: Trigger');
},
},
todo: {
createTask: async (params, source) => {
const { tasksStore } = await import('$lib/modules/todo/stores/tasks.svelte');
await tasksStore.createTask({
title: params.title || (source.title as string) || 'Triggered Task',
});
},
},
notes: {
createNote: async (params, source) => {
const { notesStore } = await import('$lib/modules/notes/stores/notes.svelte');
await notesStore.createNote({
title: params.title || (source.title as string) || 'Triggered Note',
content: params.content || '',
});
},
},
};
export function getAction(appId: string, actionName: string): ActionHandler | undefined {
return ACTIONS[appId]?.[actionName];
}

View file

@ -0,0 +1,33 @@
/**
* Trigger Condition Evaluator
*
* Evaluates whether a data record matches a condition rule.
*/
export type ConditionOp = 'contains' | 'equals' | 'startsWith' | 'matches';
export function evaluateCondition(
data: Record<string, unknown>,
field: string,
op: ConditionOp,
value: string
): boolean {
const fieldValue = String(data[field] ?? '').toLowerCase();
const checkValue = value.toLowerCase();
switch (op) {
case 'contains':
return fieldValue.includes(checkValue);
case 'equals':
return fieldValue === checkValue;
case 'startsWith':
return fieldValue.startsWith(checkValue);
case 'matches':
try {
return new RegExp(checkValue, 'i').test(String(data[field] ?? ''));
} catch {
return false;
}
default:
return false;
}
}

View file

@ -0,0 +1,10 @@
/**
* Trigger system barrel exports.
*/
export { register, unregister, unregisterAll, fire, getRegisteredTriggers } from './registry';
export type { RegisteredTrigger } from './registry';
export { evaluateCondition } from './conditions';
export type { ConditionOp } from './conditions';
export { ACTIONS, getAction } from './actions';
export { loadAutomations } from './loader';

View file

@ -0,0 +1,65 @@
/**
* Automation Loader Reads automation rules from IndexedDB and registers triggers.
*
* Called once at app startup. Watches for changes to re-register.
*/
import { db } from '$lib/data/database';
import { register, unregisterAll } from './registry';
import { evaluateCondition, type ConditionOp } from './conditions';
import { getAction } from './actions';
export interface LocalAutomation {
id: string;
name: string;
enabled: boolean;
sourceApp: string;
sourceCollection: string;
sourceOp: string;
conditionField?: string;
conditionOp?: ConditionOp;
conditionValue?: string;
targetApp: string;
targetAction: string;
targetParams?: Record<string, string>;
deletedAt?: string;
createdAt?: string;
updatedAt?: string;
}
/**
* Load all enabled automations from IndexedDB and register them as triggers.
*/
export async function loadAutomations(): Promise<void> {
unregisterAll();
const all = await db.table<LocalAutomation>('automations').toArray();
const active = all.filter((a) => a.enabled && !a.deletedAt);
for (const auto of active) {
const actionFn = getAction(auto.targetApp, auto.targetAction);
if (!actionFn) {
console.warn(`[Triggers] Unknown action: ${auto.targetApp}.${auto.targetAction}`);
continue;
}
register({
id: auto.id,
sourceApp: auto.sourceApp,
sourceCollection: auto.sourceCollection,
sourceOp: auto.sourceOp,
condition:
auto.conditionField && auto.conditionOp && auto.conditionValue
? (data) =>
evaluateCondition(data, auto.conditionField!, auto.conditionOp!, auto.conditionValue!)
: undefined,
action: async (data) => {
await actionFn(auto.targetParams ?? {}, data);
},
});
}
if (active.length > 0) {
console.log(`[Triggers] Loaded ${active.length} automation(s)`);
}
}

View file

@ -0,0 +1,77 @@
/**
* Trigger Registry Cross-Module Automation Engine
*
* Listens to Dexie write operations and fires matching triggers.
* Triggers are evaluated synchronously, actions run async (fire-and-forget).
*/
type TriggerHandler = (data: Record<string, unknown>) => Promise<void> | void;
export interface RegisteredTrigger {
id: string;
sourceApp: string;
sourceCollection: string;
sourceOp: string;
condition?: (data: Record<string, unknown>) => boolean;
action: TriggerHandler;
}
const triggers: RegisteredTrigger[] = [];
/** Prevent triggers from firing triggers (infinite loops). */
let _firingTriggers = false;
export function register(trigger: RegisteredTrigger): void {
// Avoid duplicates
if (triggers.some((t) => t.id === trigger.id)) return;
triggers.push(trigger);
}
export function unregister(id: string): void {
const idx = triggers.findIndex((t) => t.id === id);
if (idx !== -1) triggers.splice(idx, 1);
}
export function unregisterAll(): void {
triggers.length = 0;
}
/**
* Fire matching triggers for a database write.
* Called from Dexie hooks in database.ts.
*/
export function fire(
appId: string,
collection: string,
op: string,
data: Record<string, unknown>
): void {
if (_firingTriggers) return;
const matching = triggers.filter(
(t) => t.sourceApp === appId && t.sourceCollection === collection && t.sourceOp === op
);
if (matching.length === 0) return;
_firingTriggers = true;
for (const trigger of matching) {
try {
if (trigger.condition && !trigger.condition(data)) continue;
// Fire-and-forget — don't block the Dexie hook
Promise.resolve(trigger.action(data)).catch((err) => {
console.error(`[Trigger] Action failed for ${trigger.id}:`, err);
});
} catch (err) {
console.error(`[Trigger] Error evaluating ${trigger.id}:`, err);
}
}
_firingTriggers = false;
}
export function getRegisteredTriggers(): readonly RegisteredTrigger[] {
return triggers;
}

View file

@ -3,6 +3,7 @@
import { onMount } from 'svelte';
import { theme } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth.svelte';
import { loadAutomations } from '$lib/triggers';
let { children } = $props();
@ -13,6 +14,9 @@
// Initialize auth
await authStore.initialize();
// Load cross-module automation triggers
await loadAutomations();
return () => {
cleanupTheme();
};