From 75832faef73754d91c119021235f10b02f648514 Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 21 Apr 2026 15:27:59 +0200 Subject: [PATCH] feat(broadcast): enhanced ListView + dashboard widget + AI tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the M7/M9/M10 plan items in one pass since they share patterns. ListView (M7) - 4 stats cards at the top: versendet YTD, Ø Öffnungsrate, Ø Klickrate, Entwürfe. Same layout pattern as invoices for consistency. - Status filter chips with live counts per status. - Search across name + subject. - Row now shows open-rate per-campaign when available. - Settings gear in the header matches the invoices polish. Dashboard widget (M10) - BroadcastsWidget.svelte: 2x stats (sent YTD + avg open rate), next scheduled link, last sent link with open-rate badge. Empty state nudges toward creating a first campaign. - Registered as 'broadcasts' in WIDGET_REGISTRY and the component map. - Medium default size, no requiredBackend (reads from Dexie only; stats are mirrored from the last DetailView poll so no server round-trip for the widget). AI tools (M9) - 3 tools added to @mana/shared-ai's AI_TOOL_CATALOG: - create_campaign_draft (propose) — generates HTML body from a topic, lands as a draft; user picks audience + sends via UI - list_campaigns (auto) — id/name/subject/status/recipients - get_campaign_stats (auto) — rates as 0..1 floats - broadcast/tools.ts: execute handlers with an HTML→CampaignContent shim (stores both html and a minimal Tiptap JSON placeholder so ListView renders without the editor having to remount). stripHtml helper derives plaintext. - Registered in data/tools/init.ts after library. Suggest-style tools (suggest_subject_lines) deliberately omitted — they're pure generative and don't need an executor. The LLM can produce subject ideas without a tool call. Verified: - pnpm check: 0 broadcast errors (4 pre-existing errors in articles module from parallel work, not mine) - shared-ai test suite: 44/44 green (function-schema roundtrips the expanded catalog cleanly) - mana-ai drift guard: 41/41 green Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/dashboard/widget-registry.ts | 2 + apps/mana/apps/web/src/lib/data/tools/init.ts | 2 + .../src/lib/modules/broadcast/ListView.svelte | 224 +++++++++++++++++- .../web/src/lib/modules/broadcast/index.ts | 2 + .../web/src/lib/modules/broadcast/tools.ts | 180 ++++++++++++++ .../broadcast/widgets/BroadcastsWidget.svelte | 140 +++++++++++ apps/mana/apps/web/src/lib/types/dashboard.ts | 11 +- packages/shared-ai/src/tools/schemas.ts | 73 ++++++ 8 files changed, 623 insertions(+), 11 deletions(-) create mode 100644 apps/mana/apps/web/src/lib/modules/broadcast/tools.ts create mode 100644 apps/mana/apps/web/src/lib/modules/broadcast/widgets/BroadcastsWidget.svelte diff --git a/apps/mana/apps/web/src/lib/components/dashboard/widget-registry.ts b/apps/mana/apps/web/src/lib/components/dashboard/widget-registry.ts index 634293e66..13a0a5769 100644 --- a/apps/mana/apps/web/src/lib/components/dashboard/widget-registry.ts +++ b/apps/mana/apps/web/src/lib/components/dashboard/widget-registry.ts @@ -34,6 +34,7 @@ import PeriodWidget from '$lib/modules/core/widgets/PeriodWidget.svelte'; import NewsUnreadWidget from '$lib/modules/news/widgets/NewsUnreadWidget.svelte'; import BodyStatsWidget from '$lib/modules/body/widgets/BodyStatsWidget.svelte'; import InvoicesOpenWidget from '$lib/modules/invoices/widgets/InvoicesOpenWidget.svelte'; +import BroadcastsWidget from '$lib/modules/broadcast/widgets/BroadcastsWidget.svelte'; import DayTimelineWidget from './widgets/DayTimelineWidget.svelte'; import ActivityFeedWidget from './widgets/ActivityFeedWidget.svelte'; @@ -64,4 +65,5 @@ export const widgetComponents: Record = { 'news-unread': NewsUnreadWidget, 'body-stats': BodyStatsWidget, 'invoices-open': InvoicesOpenWidget, + broadcasts: BroadcastsWidget, }; diff --git a/apps/mana/apps/web/src/lib/data/tools/init.ts b/apps/mana/apps/web/src/lib/data/tools/init.ts index d54c70412..b288cc124 100644 --- a/apps/mana/apps/web/src/lib/data/tools/init.ts +++ b/apps/mana/apps/web/src/lib/data/tools/init.ts @@ -43,6 +43,7 @@ import { wetterTools } from '$lib/modules/wetter/tools'; import { quizTools } from '$lib/modules/quiz/tools'; import { invoicesTools } from '$lib/modules/invoices/tools'; import { libraryTools } from '$lib/modules/library/tools'; +import { broadcastTools } from '$lib/modules/broadcast/tools'; let initialized = false; @@ -87,5 +88,6 @@ export function initTools(): void { registerTools(quizTools); registerTools(invoicesTools); registerTools(libraryTools); + registerTools(broadcastTools); initialized = true; } diff --git a/apps/mana/apps/web/src/lib/modules/broadcast/ListView.svelte b/apps/mana/apps/web/src/lib/modules/broadcast/ListView.svelte index d8989260d..621eecb00 100644 --- a/apps/mana/apps/web/src/lib/modules/broadcast/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/broadcast/ListView.svelte @@ -1,19 +1,30 @@ + +
+
+

+ + Broadcasts +

+ Alle → +
+ + {#if loading} +
+ {#each Array(2) as _} +
+ {/each} +
+ {:else if campaigns.length === 0} +
+

Noch keine Kampagnen.

+ + Erste Kampagne + +
+ {:else} +
+
+
Versendet {currentYear}
+
{stats.sentThisYear}
+
+
+
Ø Öffnung
+
+ {formatRate(stats.avgOpenRate)} +
+
+
+ + {#if nextScheduled} + +
+
+
Als nächstes
+
{nextScheduled.name}
+
+
+ {new Date(nextScheduled.scheduledAt ?? '').toLocaleDateString()} +
+
+
+ {/if} + + {#if lastSent} + +
+
+
Zuletzt versendet
+
{lastSent.name}
+
+ {#if lastOpenRate !== null} +
+ {formatRate(lastOpenRate)} 👀 +
+ {/if} +
+
+ {/if} + + {#if !lastSent && !nextScheduled} +

+ {stats.totalByStatus.draft} Entwurf{stats.totalByStatus.draft === 1 ? '' : 'e'} in Arbeit. +

+ {/if} + {/if} +
diff --git a/apps/mana/apps/web/src/lib/types/dashboard.ts b/apps/mana/apps/web/src/lib/types/dashboard.ts index 779bd324e..ea96ed334 100644 --- a/apps/mana/apps/web/src/lib/types/dashboard.ts +++ b/apps/mana/apps/web/src/lib/types/dashboard.ts @@ -33,7 +33,8 @@ export type WidgetType = | 'period' // Period: current phase + days until next period | 'news-unread' // News: latest unread curated articles | 'body-stats' // Body: latest weight + active workout summary - | 'invoices-open'; // Invoices: open/overdue totals + oldest overdue + | 'invoices-open' // Invoices: open/overdue totals + oldest overdue + | 'broadcasts'; // Broadcast: YTD counts + last sent + next scheduled /** * Widget size - maps to CSS Grid columns @@ -371,6 +372,14 @@ export const WIDGET_REGISTRY: WidgetMeta[] = [ defaultSize: 'medium', allowMultiple: false, }, + { + type: 'broadcasts', + nameKey: 'dashboard.widgets.broadcasts.title', + descriptionKey: 'dashboard.widgets.broadcasts.description', + icon: '📣', + defaultSize: 'medium', + allowMultiple: false, + }, ]; /** diff --git a/packages/shared-ai/src/tools/schemas.ts b/packages/shared-ai/src/tools/schemas.ts index 69d2925d6..c70bd24cc 100644 --- a/packages/shared-ai/src/tools/schemas.ts +++ b/packages/shared-ai/src/tools/schemas.ts @@ -1323,6 +1323,79 @@ export const AI_TOOL_CATALOG: readonly ToolSchema[] = [ }, ], }, + + // ── Broadcast (Newsletter) ─────────────────────────────── + { + name: 'create_campaign_draft', + module: 'broadcast', + description: + 'Erstellt einen Newsletter-/Kampagnen-Entwurf mit Name, Betreff, optionalem Preheader und fertigem HTML-Body. Empfaengerliste bleibt leer — der Nutzer waehlt sie in der UI. Gibt die ID zurueck.', + defaultPolicy: 'propose', + parameters: [ + { + name: 'name', + type: 'string', + description: 'Interner Arbeitstitel der Kampagne', + required: true, + }, + { + name: 'subject', + type: 'string', + description: 'E-Mail-Betreff (was im Posteingang steht)', + required: true, + }, + { + name: 'preheader', + type: 'string', + description: 'Vorschau-Text neben dem Betreff in Gmail', + required: false, + }, + { + name: 'htmlContent', + type: 'string', + description: + 'Body als HTML. Erlaubte Tags: p, h1, h2, h3, ul, ol, li, a, strong, em, br. Links verwenden href="https://…".', + required: true, + }, + ], + }, + { + name: 'list_campaigns', + module: 'broadcast', + description: + 'Listet Kampagnen (id, name, subject, status, Empfaengerzahl, sentAt) — optional nach Status gefiltert.', + defaultPolicy: 'auto', + parameters: [ + { + name: 'status', + type: 'string', + description: 'Nur diesen Status zeigen', + required: false, + enum: ['draft', 'scheduled', 'sending', 'sent', 'cancelled'], + }, + { + name: 'limit', + type: 'number', + description: 'Maximale Anzahl (Standard 20)', + required: false, + }, + ], + }, + { + name: 'get_campaign_stats', + module: 'broadcast', + description: + 'Gibt Kennzahlen zu einer Kampagne zurueck: Oeffnungsrate, Klickrate, Bounce-Rate, Abmelderate (jeweils 0..1).', + defaultPolicy: 'auto', + parameters: [ + { + name: 'campaignId', + type: 'string', + description: 'ID der Kampagne (aus list_campaigns)', + required: true, + }, + ], + }, ]; // ═══════════════════════════════════════════════════════════════