mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:01:09 +02:00
feat(broadcast): enhanced ListView + dashboard widget + AI tools
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) <noreply@anthropic.com>
This commit is contained in:
parent
c5a76d726c
commit
75832faef7
8 changed files with 623 additions and 11 deletions
|
|
@ -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<WidgetType, Component> = {
|
|||
'news-unread': NewsUnreadWidget,
|
||||
'body-stats': BodyStatsWidget,
|
||||
'invoices-open': InvoicesOpenWidget,
|
||||
broadcasts: BroadcastsWidget,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,30 @@
|
|||
<!--
|
||||
Broadcast — ListView (M2)
|
||||
Real campaign list + working "+ Neue Kampagne" entry point. Stats
|
||||
cards, filter chips, and search land in M7.
|
||||
Broadcast — ListView (M7)
|
||||
Stats cards + status filter chips + search + row navigation.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { useAllCampaigns } from './queries';
|
||||
import { useAllCampaigns, computeStats, searchCampaigns, formatRate } from './queries';
|
||||
import { STATUS_LABELS, STATUS_COLORS } from './constants';
|
||||
import type { CampaignStatus } from './types';
|
||||
|
||||
const campaigns$ = useAllCampaigns();
|
||||
const campaigns = $derived(campaigns$.value ?? []);
|
||||
|
||||
let activeStatus = $state<CampaignStatus | 'all'>('all');
|
||||
let searchQuery = $state('');
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const stats = $derived(computeStats(campaigns, currentYear));
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
let out = campaigns;
|
||||
if (activeStatus !== 'all') out = out.filter((c) => c.status === activeStatus);
|
||||
if (searchQuery.trim()) out = searchCampaigns(out, searchQuery.trim());
|
||||
return out;
|
||||
});
|
||||
|
||||
function openCampaign(id: string, status: string) {
|
||||
// Drafts open in the compose flow for editing; everything else
|
||||
// opens the read-only DetailView with stats.
|
||||
if (status === 'draft') goto(`/broadcasts/${id}/edit`);
|
||||
else goto(`/broadcasts/${id}`);
|
||||
}
|
||||
|
|
@ -29,9 +40,73 @@
|
|||
<h1>Broadcasts</h1>
|
||||
<p class="subtitle">Newsletter und Kampagnen an deine Kontakte</p>
|
||||
</div>
|
||||
<button class="btn-primary" type="button" onclick={onNewCampaign}>+ Neue Kampagne</button>
|
||||
<div class="head-actions">
|
||||
<button
|
||||
class="btn-icon"
|
||||
type="button"
|
||||
title="Einstellungen"
|
||||
aria-label="Einstellungen"
|
||||
onclick={() => goto('/broadcasts/settings')}
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
<button class="btn-primary" type="button" onclick={onNewCampaign}> + Neue Kampagne </button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if campaigns.length > 0}
|
||||
<section class="stats">
|
||||
<div class="stat">
|
||||
<div class="stat-label">Versendet {currentYear}</div>
|
||||
<div class="stat-value">{stats.sentThisYear}</div>
|
||||
<div class="stat-sub">Kampagnen</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Ø Öffnungsrate</div>
|
||||
<div class="stat-value">{formatRate(stats.avgOpenRate)}</div>
|
||||
<div class="stat-sub">über alle Kampagnen</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Ø Klickrate</div>
|
||||
<div class="stat-value">{formatRate(stats.avgClickRate)}</div>
|
||||
<div class="stat-sub">über alle Kampagnen</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Entwürfe</div>
|
||||
<div class="stat-value">{stats.totalByStatus.draft}</div>
|
||||
<div class="stat-sub">in Arbeit</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="filters">
|
||||
<div class="chips">
|
||||
<button
|
||||
class="chip"
|
||||
class:active={activeStatus === 'all'}
|
||||
onclick={() => (activeStatus = 'all')}
|
||||
>
|
||||
Alle <span class="count">{campaigns.length}</span>
|
||||
</button>
|
||||
{#each ['draft', 'scheduled', 'sending', 'sent', 'cancelled'] as status (status)}
|
||||
<button
|
||||
class="chip"
|
||||
class:active={activeStatus === status}
|
||||
onclick={() => (activeStatus = status as CampaignStatus)}
|
||||
>
|
||||
{STATUS_LABELS[status as CampaignStatus].de}
|
||||
<span class="count">{stats.totalByStatus[status as CampaignStatus]}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<input
|
||||
class="search"
|
||||
type="search"
|
||||
placeholder="Suchen (Name oder Betreff)"
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if campaigns.length === 0}
|
||||
<div class="empty">
|
||||
<div class="empty-icon">📣</div>
|
||||
|
|
@ -42,9 +117,17 @@
|
|||
</p>
|
||||
<button class="btn-primary" onclick={onNewCampaign}>Erste Kampagne</button>
|
||||
</div>
|
||||
{:else if filtered.length === 0}
|
||||
<div class="empty">
|
||||
<p>Keine Kampagnen gefunden.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="list" role="list">
|
||||
{#each campaigns as campaign (campaign.id)}
|
||||
{#each filtered as campaign (campaign.id)}
|
||||
{@const openRate =
|
||||
campaign.stats && campaign.stats.sent > 0
|
||||
? campaign.stats.opened / campaign.stats.sent
|
||||
: null}
|
||||
<li>
|
||||
<button class="row" onclick={() => openCampaign(campaign.id, campaign.status)}>
|
||||
<span class="subject">
|
||||
|
|
@ -56,6 +139,13 @@
|
|||
<span class="recipient-count">
|
||||
{campaign.audience?.estimatedCount ?? 0} Empfänger
|
||||
</span>
|
||||
{#if campaign.status === 'sent' && openRate !== null}
|
||||
<span class="open-rate" title="Öffnungsrate">
|
||||
{formatRate(openRate)} 👀
|
||||
</span>
|
||||
{:else}
|
||||
<span class="open-rate empty-rate">—</span>
|
||||
{/if}
|
||||
<span class="status" style="--dot: {STATUS_COLORS[campaign.status]}">
|
||||
<span class="dot"></span>
|
||||
{STATUS_LABELS[campaign.status].de}
|
||||
|
|
@ -70,7 +160,7 @@
|
|||
<style>
|
||||
.broadcast-shell {
|
||||
padding: 1.5rem;
|
||||
max-width: 1000px;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
|
@ -79,6 +169,8 @@
|
|||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 1.5rem;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.head h1 {
|
||||
|
|
@ -93,6 +185,12 @@
|
|||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.head-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
|
|
@ -104,6 +202,102 @@
|
|||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
background: white;
|
||||
border: 1px solid var(--color-border, #e2e8f0);
|
||||
border-radius: 0.4rem;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.stat {
|
||||
padding: 0.9rem 1rem;
|
||||
background: var(--color-surface, #fff);
|
||||
border: 1px solid var(--color-border, #e2e8f0);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted, #64748b);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.stat-sub {
|
||||
margin-top: 0.15rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted, #64748b);
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
background: white;
|
||||
border: 1px solid var(--color-border, #e2e8f0);
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.chip.active {
|
||||
background: #4338ca;
|
||||
color: white;
|
||||
border-color: #4338ca;
|
||||
}
|
||||
|
||||
.chip .count {
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
padding: 0 0.4rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.chip.active .count {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.search {
|
||||
padding: 0.45rem 0.75rem;
|
||||
border: 1px solid var(--color-border, #e2e8f0);
|
||||
border-radius: 0.4rem;
|
||||
font-size: 0.9rem;
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 4rem 1rem;
|
||||
|
|
@ -138,7 +332,7 @@
|
|||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 9rem;
|
||||
grid-template-columns: 1fr auto 6rem 9rem;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
|
@ -179,6 +373,16 @@
|
|||
color: var(--color-text-muted, #64748b);
|
||||
}
|
||||
|
||||
.open-rate {
|
||||
font-size: 0.85rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.empty-rate {
|
||||
color: var(--color-text-muted, #94a3b8);
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ export { broadcastSettingsStore, ensureSettings } from './stores/settings.svelte
|
|||
export { renderEmailHtml } from './render/email-html';
|
||||
export { renderPlainText } from './render/plain-text';
|
||||
|
||||
export { broadcastTools } from './tools';
|
||||
|
||||
export {
|
||||
STATUS_LABELS,
|
||||
STATUS_COLORS,
|
||||
|
|
|
|||
180
apps/mana/apps/web/src/lib/modules/broadcast/tools.ts
Normal file
180
apps/mana/apps/web/src/lib/modules/broadcast/tools.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
/**
|
||||
* Broadcast Tools — LLM-accessible operations.
|
||||
*
|
||||
* Schema definitions live in @mana/shared-ai's AI_TOOL_CATALOG; this
|
||||
* file wires execute fns to the local store + query layer.
|
||||
*
|
||||
* Mission example:
|
||||
* "Schreib jeden Freitag einen Newsletter-Entwurf aus meinen Notizen
|
||||
* der Woche" → auto-list_notes + propose create_campaign_draft.
|
||||
*/
|
||||
|
||||
import type { ModuleTool } from '$lib/data/tools/types';
|
||||
import { campaignTable } from './collections';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import { broadcastCampaignsStore } from './stores/campaigns.svelte';
|
||||
import { toCampaign, formatRate } from './queries';
|
||||
import type { LocalCampaign, CampaignStatus, CampaignContent } from './types';
|
||||
|
||||
/**
|
||||
* Pragmatic HTML → Tiptap-JSON shim. The planner produces flat HTML
|
||||
* (the tool schema explicitly limits which tags are allowed), and
|
||||
* Tiptap's Editor accepts HTML at init time via setContent(html).
|
||||
* We persist a minimal valid Tiptap doc that the editor will replace
|
||||
* on first open via its `content: html` init path.
|
||||
*
|
||||
* Storing both html and tiptap means the ListView + preview render
|
||||
* without the editor having to remount.
|
||||
*/
|
||||
function htmlToCampaignContent(html: string): CampaignContent {
|
||||
return {
|
||||
// Tiptap-JSON placeholder — editor re-parses from html on open.
|
||||
// Valid Tiptap doc structure so runtime doesn't choke on null.
|
||||
tiptap: { type: 'doc', content: [{ type: 'paragraph' }] },
|
||||
html,
|
||||
plainText: stripHtml(html),
|
||||
};
|
||||
}
|
||||
|
||||
/** Crude but adequate: remove tags + collapse whitespace. */
|
||||
function stripHtml(html: string): string {
|
||||
return html
|
||||
.replace(/<\/(p|div|h[1-6]|li|br)>/gi, '\n')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
async function listDecryptedCampaigns(): Promise<ReturnType<typeof toCampaign>[]> {
|
||||
const rows = await campaignTable.toArray();
|
||||
const visible = rows.filter((r) => !r.deletedAt);
|
||||
const decrypted = (await decryptRecords('broadcastCampaigns', visible)) as LocalCampaign[];
|
||||
return decrypted.map(toCampaign);
|
||||
}
|
||||
|
||||
export const broadcastTools: ModuleTool[] = [
|
||||
{
|
||||
name: 'create_campaign_draft',
|
||||
module: 'broadcast',
|
||||
description:
|
||||
'Erstellt einen Newsletter-/Kampagnen-Entwurf mit Name, Betreff, optionalem Preheader und fertigem HTML-Body.',
|
||||
parameters: [
|
||||
{ name: 'name', type: 'string', description: 'Arbeitstitel', required: true },
|
||||
{ name: 'subject', type: 'string', description: 'E-Mail-Betreff', required: true },
|
||||
{ name: 'preheader', type: 'string', description: 'Preheader', required: false },
|
||||
{
|
||||
name: 'htmlContent',
|
||||
type: 'string',
|
||||
description: 'HTML-Body (p, h1-3, ul, ol, li, a, strong, em, br)',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
async execute(params) {
|
||||
const name = String(params.name ?? '').trim() || 'Neue Kampagne';
|
||||
const subject = String(params.subject ?? '').trim();
|
||||
if (!subject) return { success: false, message: 'Betreff fehlt.' };
|
||||
const htmlContent = String(params.htmlContent ?? '');
|
||||
if (!htmlContent.trim()) return { success: false, message: 'Inhalt fehlt.' };
|
||||
|
||||
const id = await broadcastCampaignsStore.createCampaign({
|
||||
name,
|
||||
subject,
|
||||
preheader: (params.preheader as string | undefined) || null,
|
||||
content: htmlToCampaignContent(htmlContent),
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
data: { id, name, subject },
|
||||
message: `Entwurf „${name}" angelegt. Empfänger in der UI wählen, dann versenden.`,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'list_campaigns',
|
||||
module: 'broadcast',
|
||||
description: 'Listet Kampagnen (id, name, subject, status, Empfängerzahl, sentAt).',
|
||||
parameters: [
|
||||
{
|
||||
name: 'status',
|
||||
type: 'string',
|
||||
description: 'Filter auf Status',
|
||||
required: false,
|
||||
enum: ['draft', 'scheduled', 'sending', 'sent', 'cancelled'],
|
||||
},
|
||||
{ name: 'limit', type: 'number', description: 'Maximale Anzahl', required: false },
|
||||
],
|
||||
async execute(params) {
|
||||
const all = await listDecryptedCampaigns();
|
||||
const status = params.status as CampaignStatus | undefined;
|
||||
const filtered = status ? all.filter((c) => c.status === status) : all;
|
||||
const limit = Number(params.limit ?? 20);
|
||||
const slice = filtered.slice(0, Math.max(1, limit));
|
||||
return {
|
||||
success: true,
|
||||
data: slice.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
subject: c.subject,
|
||||
status: c.status,
|
||||
recipients: c.audience?.estimatedCount ?? 0,
|
||||
sentAt: c.sentAt,
|
||||
})),
|
||||
message: `${slice.length} Kampagne${slice.length === 1 ? '' : 'n'}${status ? ` im Status ${status}` : ''}.`,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'get_campaign_stats',
|
||||
module: 'broadcast',
|
||||
description: 'Gibt Raten zu einer Kampagne zurück: Öffnungs-, Klick-, Bounce- und Abmelderate.',
|
||||
parameters: [{ name: 'campaignId', type: 'string', description: 'ID', required: true }],
|
||||
async execute(params) {
|
||||
const id = String(params.campaignId ?? '').trim();
|
||||
if (!id) return { success: false, message: 'campaignId fehlt.' };
|
||||
const all = await listDecryptedCampaigns();
|
||||
const campaign = all.find((c) => c.id === id);
|
||||
if (!campaign) return { success: false, message: `Kampagne ${id} nicht gefunden.` };
|
||||
const s = campaign.stats;
|
||||
if (!s || s.sent === 0) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
campaignId: id,
|
||||
name: campaign.name,
|
||||
status: campaign.status,
|
||||
stats: null,
|
||||
},
|
||||
message: `Kampagne „${campaign.name}" hat noch keine Sendestatistik.`,
|
||||
};
|
||||
}
|
||||
const openRate = s.opened / s.sent;
|
||||
const clickRate = s.clicked / s.sent;
|
||||
const bounceRate = s.bounced / s.sent;
|
||||
const unsubRate = s.unsubscribed / s.sent;
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
campaignId: id,
|
||||
name: campaign.name,
|
||||
status: campaign.status,
|
||||
stats: {
|
||||
totalRecipients: s.totalRecipients,
|
||||
sent: s.sent,
|
||||
openRate,
|
||||
clickRate,
|
||||
bounceRate,
|
||||
unsubscribeRate: unsubRate,
|
||||
},
|
||||
},
|
||||
message: `„${campaign.name}": ${s.sent} versendet, ${formatRate(openRate)} geöffnet, ${formatRate(clickRate)} geklickt.`,
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* BroadcastsWidget — YTD Counts + letzte versendete Kampagne +
|
||||
* nächste geplante.
|
||||
*
|
||||
* Rein lokal aus Dexie; keine Server-Rundreise (Stats im liveQuery
|
||||
* sind aus dem letzten DetailView-Poll heraus gespiegelt).
|
||||
*/
|
||||
|
||||
import { liveQuery } from 'dexie';
|
||||
import { campaignTable } from '$lib/modules/broadcast/collections';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import { toCampaign, computeStats, formatRate } from '$lib/modules/broadcast/queries';
|
||||
import type { Campaign, LocalCampaign } from '$lib/modules/broadcast/types';
|
||||
|
||||
let campaigns = $state<Campaign[]>([]);
|
||||
let loading = $state(true);
|
||||
|
||||
$effect(() => {
|
||||
const sub = liveQuery(async () => {
|
||||
const rows = await campaignTable.toArray();
|
||||
const visible = rows.filter((r) => !r.deletedAt);
|
||||
const decrypted = (await decryptRecords('broadcastCampaigns', visible)) as LocalCampaign[];
|
||||
return decrypted.map(toCampaign);
|
||||
}).subscribe({
|
||||
next: (result) => {
|
||||
campaigns = result;
|
||||
loading = false;
|
||||
},
|
||||
error: () => {
|
||||
loading = false;
|
||||
},
|
||||
});
|
||||
return () => sub.unsubscribe();
|
||||
});
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const stats = $derived(computeStats(campaigns, currentYear));
|
||||
|
||||
/** Latest sent campaign — top of mind for "how did my last one do?". */
|
||||
const lastSent = $derived(
|
||||
campaigns
|
||||
.filter((c) => c.status === 'sent')
|
||||
.sort((a, b) => (b.sentAt ?? '').localeCompare(a.sentAt ?? ''))[0]
|
||||
);
|
||||
|
||||
/** Next scheduled — "what's about to go out". */
|
||||
const nextScheduled = $derived(
|
||||
campaigns
|
||||
.filter((c) => c.status === 'scheduled' && c.scheduledAt)
|
||||
.sort((a, b) => (a.scheduledAt ?? '').localeCompare(b.scheduledAt ?? ''))[0]
|
||||
);
|
||||
|
||||
const lastOpenRate = $derived(
|
||||
lastSent?.stats && lastSent.stats.sent > 0 ? lastSent.stats.opened / lastSent.stats.sent : null
|
||||
);
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h3 class="flex items-center gap-2 text-lg font-semibold">
|
||||
<span aria-hidden="true">📣</span>
|
||||
Broadcasts
|
||||
</h3>
|
||||
<a href="/broadcasts" class="text-xs text-muted-foreground hover:text-foreground"> Alle → </a>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="space-y-2">
|
||||
{#each Array(2) as _}
|
||||
<div class="h-10 animate-pulse rounded bg-surface-hover"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if campaigns.length === 0}
|
||||
<div class="py-4 text-center">
|
||||
<p class="text-sm text-muted-foreground">Noch keine Kampagnen.</p>
|
||||
<a
|
||||
href="/broadcasts/new"
|
||||
class="mt-3 inline-block rounded-lg bg-primary/10 px-4 py-2 text-sm font-medium text-primary hover:bg-primary/20"
|
||||
>
|
||||
Erste Kampagne
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mb-3 grid grid-cols-2 gap-2">
|
||||
<div class="rounded-lg bg-surface-hover p-2.5">
|
||||
<div class="text-xs text-muted-foreground">Versendet {currentYear}</div>
|
||||
<div class="text-lg font-semibold tabular-nums">{stats.sentThisYear}</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-surface-hover p-2.5">
|
||||
<div class="text-xs text-muted-foreground">Ø Öffnung</div>
|
||||
<div class="text-lg font-semibold tabular-nums">
|
||||
{formatRate(stats.avgOpenRate)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if nextScheduled}
|
||||
<a
|
||||
href="/broadcasts/{nextScheduled.id}"
|
||||
class="mb-2 block rounded-lg p-2 transition-colors hover:bg-surface-hover"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-xs text-muted-foreground">Als nächstes</div>
|
||||
<div class="truncate text-sm font-medium">{nextScheduled.name}</div>
|
||||
</div>
|
||||
<div class="ml-2 text-xs text-muted-foreground">
|
||||
{new Date(nextScheduled.scheduledAt ?? '').toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if lastSent}
|
||||
<a
|
||||
href="/broadcasts/{lastSent.id}"
|
||||
class="block rounded-lg p-2 transition-colors hover:bg-surface-hover"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-xs text-muted-foreground">Zuletzt versendet</div>
|
||||
<div class="truncate text-sm font-medium">{lastSent.name}</div>
|
||||
</div>
|
||||
{#if lastOpenRate !== null}
|
||||
<div class="ml-2 text-sm font-medium tabular-nums">
|
||||
{formatRate(lastOpenRate)} 👀
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if !lastSent && !nextScheduled}
|
||||
<p class="py-4 text-center text-sm text-muted-foreground">
|
||||
{stats.totalByStatus.draft} Entwurf{stats.totalByStatus.draft === 1 ? '' : 'e'} in Arbeit.
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue