mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:01:08 +02:00
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:
parent
0f38a567fe
commit
2dd0812757
7 changed files with 589 additions and 3 deletions
149
apps/manacore/apps/web/src/lib/components/SuggestionToast.svelte
Normal file
149
apps/manacore/apps/web/src/lib/components/SuggestionToast.svelte
Normal 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">⚡</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}>×</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>
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)}>×</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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
104
apps/manacore/apps/web/src/lib/triggers/inline-suggest.ts
Normal file
104
apps/manacore/apps/web/src/lib/triggers/inline-suggest.ts
Normal 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 },
|
||||
};
|
||||
}
|
||||
149
apps/manacore/apps/web/src/lib/triggers/suggestions.ts
Normal file
149
apps/manacore/apps/web/src/lib/triggers/suggestions.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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 />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue