Complete Rybbit campaign aggregation

This commit is contained in:
2026-06-05 21:51:39 +02:00
parent f069b74b08
commit 3efbc06e40
6 changed files with 102 additions and 2 deletions

View File

@@ -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
<!-- AC:END -->
@@ -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.
<!-- SECTION:NOTES:END -->

View File

@@ -34,6 +34,11 @@ export function AnalyticsDashboard() {
auditOpens: number;
ctaClicks: number;
outboundClicks: number;
byPath?: Record<string, {
auditOpens: number;
ctaClicks: number;
outboundClicks: number;
}>;
} | null>(null);
const [rybbitError, setRybbitError] = useState<string | null>(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<string, { label: string; auditOpens: number; ctaClicks: number }>();
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() {
<p>Audit-Öffnungen: {rybbitData?.auditOpens ?? dashboard.metrics.rybbitAuditOpens}</p>
<p>CTA-Klicks: {rybbitData?.ctaClicks ?? dashboard.metrics.rybbitCtaClicks}</p>
<p>Website-Link-Klicks: {rybbitData?.outboundClicks ?? 0}</p>
{rybbitGroups.length > 0 ? (
<div className="space-y-1 pt-2">
{rybbitGroups.map((group) => (
<p key={group.label}>
{group.label}: {group.auditOpens} Öffnungen · {group.ctaClicks} CTA
</p>
))}
</div>
) : null}
<p>Public-Audit Tracking läuft nur auf veröffentlichten Audit-Seiten.</p>
</CardContent>
</Card>

View File

@@ -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,

View File

@@ -22,6 +22,11 @@ export type CampaignRybbitSummary = {
auditOpens: number;
ctaClicks: number;
outboundClicks: number;
byPath: Record<string, {
auditOpens: number;
ctaClicks: number;
outboundClicks: number;
}>;
};
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,
};
}

View File

@@ -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/);
});

View File

@@ -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,
},
},
},
);
});