From 4b67316343225692ddfe31a54376be5f8f7823cd Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 15 Apr 2026 00:44:59 +0200 Subject: [PATCH] feat(ai): add tasks + calendar events as Mission inputs (webapp side) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mission-Input-Picker now surfaces open tasks + upcoming calendar events alongside notes / kontext / goals. When the foreground runner runs, the corresponding resolvers decrypt the records client-side and hand real content (title + due date / event time + description) into the Planner prompt. - `tasksResolver` + `tasksIndexer` — reads unencrypted subset + decrypts via `decryptRecords('tasks', …)`. Picker shows only OPEN tasks (not completed ones) to keep the list relevant. Resolver output is `[status]{ · fällig date}{ \n description}` — terse by design. - `calendarResolver` + `calendarIndexer` — similarly decrypts events; picker prioritizes upcoming events (sorted by startIso), shows near-term times as "bald: YYYY-MM-DDTHH:MM" to make recency obvious - Both tables are encrypted client-side — server-side mana-ai resolvers remain intentionally absent (per the privacy contract in `services/mana-ai/src/db/resolvers/types.ts`) With this, a user can create a Mission like "plan my week" and link a few tasks + calendar events as context; the in-browser planner sees the full picture (decrypted), while the off-tab runner still plans from objective + concept only for those missions. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../lib/data/ai/missions/default-resolvers.ts | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/apps/mana/apps/web/src/lib/data/ai/missions/default-resolvers.ts b/apps/mana/apps/web/src/lib/data/ai/missions/default-resolvers.ts index df686d42e..3757add4b 100644 --- a/apps/mana/apps/web/src/lib/data/ai/missions/default-resolvers.ts +++ b/apps/mana/apps/web/src/lib/data/ai/missions/default-resolvers.ts @@ -118,6 +118,105 @@ const goalsIndexer: InputIndexer = async () => { })); }; +// ── tasks (todo module, encrypted) ───────────────────────── + +interface TaskLike { + id: string; + title?: string; + description?: string; + dueDate?: string; + isCompleted?: boolean; + deletedAt?: string; +} + +const tasksResolver: InputResolver = async (ref) => { + const local = await db.table(ref.table).get(ref.id); + if (!local || local.deletedAt) return null; + const [decrypted] = await decryptRecords(ref.table, [local]); + const status = decrypted.isCompleted ? 'erledigt' : 'offen'; + const due = decrypted.dueDate ? ` · fällig ${decrypted.dueDate}` : ''; + const body = decrypted.description ? `\n${decrypted.description}` : ''; + return { + id: ref.id, + module: ref.module, + table: ref.table, + title: decrypted.title, + content: `[${status}]${due}${body}`, + }; +}; + +const tasksIndexer: InputIndexer = async () => { + const all = await db.table('tasks').toArray(); + const visible = all.filter((t) => !t.deletedAt && !t.isCompleted); + const decrypted = await decryptRecords('tasks', visible); + return decrypted + .map((t) => ({ + module: 'todo', + table: 'tasks', + id: t.id, + label: (t.title && t.title.trim()) || '(ohne Titel)', + hint: t.dueDate ? `fällig ${t.dueDate}` : undefined, + })) + .slice(0, 200); +}; + +// ── calendar events (encrypted) ──────────────────────────── + +interface CalEventLike { + id: string; + title?: string; + description?: string; + location?: string; + startIso?: string; + endIso?: string; + deletedAt?: string; +} + +const calendarResolver: InputResolver = async (ref) => { + const local = await db.table(ref.table).get(ref.id); + if (!local || local.deletedAt) return null; + const [decrypted] = await decryptRecords(ref.table, [local]); + const when = decrypted.startIso + ? decrypted.endIso + ? `${decrypted.startIso} – ${decrypted.endIso}` + : decrypted.startIso + : ''; + const where = decrypted.location ? ` @ ${decrypted.location}` : ''; + const body = decrypted.description ? `\n${decrypted.description}` : ''; + return { + id: ref.id, + module: ref.module, + table: ref.table, + title: decrypted.title, + content: `${when}${where}${body}`, + }; +}; + +const calendarIndexer: InputIndexer = async () => { + const all = await db.table('events').toArray(); + // Show upcoming events (next 30 days) first; cap at 200 total. + const now = Date.now(); + const horizon = now + 30 * 24 * 60 * 60_000; + const upcoming = all + .filter((e) => !e.deletedAt) + .filter((e) => !e.startIso || new Date(e.startIso).getTime() >= now - 24 * 60 * 60_000) + .sort((a, b) => (a.startIso ?? '').localeCompare(b.startIso ?? '')); + const decrypted = await decryptRecords('events', upcoming); + return decrypted + .map((e) => ({ + module: 'calendar', + table: 'events', + id: e.id, + label: (e.title && e.title.trim()) || '(ohne Titel)', + hint: e.startIso + ? new Date(e.startIso).getTime() < horizon + ? `bald: ${e.startIso.slice(0, 16)}` + : e.startIso.slice(0, 10) + : undefined, + })) + .slice(0, 200); +}; + let registered = false; /** Register the default resolvers + indexers once. Idempotent. */ @@ -126,8 +225,12 @@ export function registerDefaultInputResolvers(): void { registerInputResolver('notes', notesResolver); registerInputResolver('kontext', kontextResolver); registerInputResolver('goals', goalsResolver); + registerInputResolver('todo', tasksResolver); + registerInputResolver('calendar', calendarResolver); registerInputIndexer('notes', notesIndexer); registerInputIndexer('kontext', kontextIndexer); registerInputIndexer('goals', goalsIndexer); + registerInputIndexer('todo', tasksIndexer); + registerInputIndexer('calendar', calendarIndexer); registered = true; }