Complete Rybbit campaign aggregation
This commit is contained in:
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user