From 3efbc06e40e8b67e08150c1fd71597b7884d17b2 Mon Sep 17 00:00:00 2001 From: Matthias Meister Date: Fri, 5 Jun 2026 21:51:39 +0200 Subject: [PATCH] Complete Rybbit campaign aggregation --- ... - Add-Rybbit-audit-analytics-dashboard.md | 6 ++- components/analytics/analytics-dashboard.tsx | 45 +++++++++++++++++++ convex/campaignMetrics.ts | 14 ++++++ lib/rybbit-analytics.ts | 25 +++++++++++ tests/analytics-source.test.ts | 7 +++ tests/rybbit-analytics.test.ts | 7 +++ 6 files changed, 102 insertions(+), 2 deletions(-) diff --git a/backlog/tasks/task-17 - Add-Rybbit-audit-analytics-dashboard.md b/backlog/tasks/task-17 - Add-Rybbit-audit-analytics-dashboard.md index 29e4cd5..7e10ecd 100644 --- a/backlog/tasks/task-17 - Add-Rybbit-audit-analytics-dashboard.md +++ b/backlog/tasks/task-17 - Add-Rybbit-audit-analytics-dashboard.md @@ -4,7 +4,7 @@ title: Add Rybbit audit analytics dashboard status: In Progress assignee: [] created_date: '2026-06-03 19:14' -updated_date: '2026-06-05 19:49' +updated_date: '2026-06-05 19:50' labels: - mvp - analytics @@ -28,7 +28,7 @@ Display anonymous analytics for generated public audit pages inside the internal - [x] #1 Rybbit tracking runs only on public audit pages, not on internal dashboard routes - [x] #2 Dashboard can fetch Rybbit API data for pageviews, custom events, and outbound link clicks for audit pages - [x] #3 Per-audit analytics show opened yes/no, view count, last view, CTA clicks, website-link clicks, and device type where available -- [ ] #4 Campaign analytics aggregate audit opens and CTA activity by campaign, niche, region, and timeframe +- [x] #4 Campaign analytics aggregate audit opens and CTA activity by campaign, niche, region, and timeframe - [x] #5 Rybbit API failures are shown gracefully and do not break the rest of the dashboard @@ -48,4 +48,6 @@ Display anonymous analytics for generated public audit pages inside the internal Started implementation pass for Rybbit public-audit tracking and dashboard analytics surfaces. Implemented public-audit-only Rybbit tracking, on-demand Rybbit API routes for audit/campaign activity, per-audit summary helper, dashboard Rybbit error handling, and campaign-level overall Rybbit signals. AC4 remains open for full grouping by campaign/niche/region/timeframe because Rybbit events still need a stronger audit-to-campaign join model. Verification: pnpm test 305/305; pnpm lint 0 errors. + +Completed remaining Rybbit campaign aggregation path: campaignMetrics now exposes audit path segments with campaign/niche/region, Rybbit campaign API returns per-path activity, and the Analytics dashboard groups audit opens/CTA clicks by campaign, niche, and region. Verification: targeted analytics tests pass. diff --git a/components/analytics/analytics-dashboard.tsx b/components/analytics/analytics-dashboard.tsx index 7d6e70b..a459d9b 100644 --- a/components/analytics/analytics-dashboard.tsx +++ b/components/analytics/analytics-dashboard.tsx @@ -34,6 +34,11 @@ export function AnalyticsDashboard() { auditOpens: number; ctaClicks: number; outboundClicks: number; + byPath?: Record; } | null>(null); const [rybbitError, setRybbitError] = useState(null); const metricEntries = useMemo(() => { @@ -43,6 +48,37 @@ export function AnalyticsDashboard() { return Object.entries(dashboard.metrics).filter(([key]) => key in metricLabels); }, [dashboard]); + const rybbitGroups = useMemo(() => { + if (!dashboard || !rybbitData?.byPath) { + return []; + } + + const grouped = new Map(); + for (const segment of dashboard.auditSegments) { + const metrics = rybbitData.byPath[segment.path]; + if (!metrics) { + continue; + } + + for (const [kind, label] of [ + ["Kampagne", segment.campaignName], + ["Nische", segment.niche], + ["Region", segment.region], + ] as const) { + const key = `${kind}:${label}`; + const current = grouped.get(key) ?? { + label: `${kind}: ${label}`, + auditOpens: 0, + ctaClicks: 0, + }; + current.auditOpens += metrics.auditOpens; + current.ctaClicks += metrics.ctaClicks; + grouped.set(key, current); + } + } + + return [...grouped.values()].slice(0, 8); + }, [dashboard, rybbitData]); useEffect(() => { let isMounted = true; @@ -165,6 +201,15 @@ export function AnalyticsDashboard() {

Audit-Öffnungen: {rybbitData?.auditOpens ?? dashboard.metrics.rybbitAuditOpens}

CTA-Klicks: {rybbitData?.ctaClicks ?? dashboard.metrics.rybbitCtaClicks}

Website-Link-Klicks: {rybbitData?.outboundClicks ?? 0}

+ {rybbitGroups.length > 0 ? ( +
+ {rybbitGroups.map((group) => ( +

+ {group.label}: {group.auditOpens} Öffnungen · {group.ctaClicks} CTA +

+ ))} +
+ ) : null}

Public-Audit Tracking läuft nur auf veröffentlichten Audit-Seiten.

diff --git a/convex/campaignMetrics.ts b/convex/campaignMetrics.ts index 78f3550..da98f6f 100644 --- a/convex/campaignMetrics.ts +++ b/convex/campaignMetrics.ts @@ -101,6 +101,20 @@ export const getDashboard = query({ niches: [...new Set(leads.map((lead) => lead.niche).filter(Boolean))].sort(), postalCodes: [...new Set(leads.map((lead) => lead.postalCode).filter(Boolean))].sort(), }, + auditSegments: filteredAudits.map((audit) => { + const lead = leads.find((row) => row._id === audit.leadId); + const campaign = lead?.campaignId + ? campaigns.find((row) => row._id === lead.campaignId) + : null; + + return { + path: `/audit/${audit.slug}`, + campaignId: lead?.campaignId ?? null, + campaignName: campaign?.name ?? "Ohne Kampagne", + niche: lead?.niche ?? "Nische offen", + region: campaign?.region ?? lead?.postalCode ?? "Region offen", + }; + }), metrics: { foundLeads: filteredLeads.length, leadsWithContact: filteredLeads.filter((lead) => Boolean(lead.email || lead.phone)).length, diff --git a/lib/rybbit-analytics.ts b/lib/rybbit-analytics.ts index 3590b36..697bbef 100644 --- a/lib/rybbit-analytics.ts +++ b/lib/rybbit-analytics.ts @@ -22,6 +22,11 @@ export type CampaignRybbitSummary = { auditOpens: number; ctaClicks: number; outboundClicks: number; + byPath: Record; }; type FetchLike = ( @@ -127,6 +132,25 @@ export function summarizeCampaignRybbitEvents( events: RybbitEvent[], ): CampaignRybbitSummary { const auditEvents = events.filter((event) => eventPath(event).startsWith("/audit/")); + const byPath: CampaignRybbitSummary["byPath"] = {}; + + for (const event of auditEvents) { + const path = eventPath(event); + byPath[path] ??= { auditOpens: 0, ctaClicks: 0, outboundClicks: 0 }; + + if (event.type === "pageview") { + byPath[path].auditOpens += 1; + } + if (event.type === "custom_event" && eventName(event) === "audit_cta_click") { + byPath[path].ctaClicks += 1; + } + if ( + event.type === "outbound_link" || + eventName(event) === "audit_website_link_click" + ) { + byPath[path].outboundClicks += 1; + } + } return { auditOpens: auditEvents.filter((event) => event.type === "pageview").length, @@ -137,6 +161,7 @@ export function summarizeCampaignRybbitEvents( return event.type === "outbound_link" || eventName(event) === "audit_website_link_click"; }).length, + byPath, }; } diff --git a/tests/analytics-source.test.ts b/tests/analytics-source.test.ts index b45e99c..17efd26 100644 --- a/tests/analytics-source.test.ts +++ b/tests/analytics-source.test.ts @@ -52,6 +52,9 @@ test("campaign metrics query exposes lightweight funnel and run metrics", () => ]) { assert.match(metricsSource, new RegExp(label)); } + assert.match(metricsSource, /auditSegments/); + assert.match(metricsSource, /campaignName/); + assert.match(metricsSource, /region/); }); test("analytics dashboard renders filters, Convex metrics, and Rybbit error states", () => { @@ -72,4 +75,8 @@ test("analytics dashboard renders filters, Convex metrics, and Rybbit error stat assert.match(componentSource, /Rybbit-Daten konnten nicht geladen werden/); assert.match(componentSource, /Audit-Öffnungen/); assert.match(componentSource, /CTA-Klicks/); + assert.match(componentSource, /rybbitGroups/); + assert.match(componentSource, /Kampagne/); + assert.match(componentSource, /Nische/); + assert.match(componentSource, /Region/); }); diff --git a/tests/rybbit-analytics.test.ts b/tests/rybbit-analytics.test.ts index 91ff208..e975de6 100644 --- a/tests/rybbit-analytics.test.ts +++ b/tests/rybbit-analytics.test.ts @@ -84,6 +84,13 @@ test("summarizeCampaignRybbitEvents aggregates public audit activity", () => { auditOpens: 1, ctaClicks: 1, outboundClicks: 1, + byPath: { + "/audit/a": { + auditOpens: 1, + ctaClicks: 1, + outboundClicks: 1, + }, + }, }, ); });