Complete Rybbit campaign aggregation
This commit is contained in:
@@ -4,7 +4,7 @@ title: Add Rybbit audit analytics dashboard
|
|||||||
status: In Progress
|
status: In Progress
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-03 19:14'
|
created_date: '2026-06-03 19:14'
|
||||||
updated_date: '2026-06-05 19:49'
|
updated_date: '2026-06-05 19:50'
|
||||||
labels:
|
labels:
|
||||||
- mvp
|
- mvp
|
||||||
- analytics
|
- 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] #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] #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
|
- [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
|
- [x] #5 Rybbit API failures are shown gracefully and do not break the rest of the dashboard
|
||||||
<!-- AC:END -->
|
<!-- 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.
|
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.
|
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 -->
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ export function AnalyticsDashboard() {
|
|||||||
auditOpens: number;
|
auditOpens: number;
|
||||||
ctaClicks: number;
|
ctaClicks: number;
|
||||||
outboundClicks: number;
|
outboundClicks: number;
|
||||||
|
byPath?: Record<string, {
|
||||||
|
auditOpens: number;
|
||||||
|
ctaClicks: number;
|
||||||
|
outboundClicks: number;
|
||||||
|
}>;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [rybbitError, setRybbitError] = useState<string | null>(null);
|
const [rybbitError, setRybbitError] = useState<string | null>(null);
|
||||||
const metricEntries = useMemo(() => {
|
const metricEntries = useMemo(() => {
|
||||||
@@ -43,6 +48,37 @@ export function AnalyticsDashboard() {
|
|||||||
|
|
||||||
return Object.entries(dashboard.metrics).filter(([key]) => key in metricLabels);
|
return Object.entries(dashboard.metrics).filter(([key]) => key in metricLabels);
|
||||||
}, [dashboard]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
@@ -165,6 +201,15 @@ export function AnalyticsDashboard() {
|
|||||||
<p>Audit-Öffnungen: {rybbitData?.auditOpens ?? dashboard.metrics.rybbitAuditOpens}</p>
|
<p>Audit-Öffnungen: {rybbitData?.auditOpens ?? dashboard.metrics.rybbitAuditOpens}</p>
|
||||||
<p>CTA-Klicks: {rybbitData?.ctaClicks ?? dashboard.metrics.rybbitCtaClicks}</p>
|
<p>CTA-Klicks: {rybbitData?.ctaClicks ?? dashboard.metrics.rybbitCtaClicks}</p>
|
||||||
<p>Website-Link-Klicks: {rybbitData?.outboundClicks ?? 0}</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>
|
<p>Public-Audit Tracking läuft nur auf veröffentlichten Audit-Seiten.</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -101,6 +101,20 @@ export const getDashboard = query({
|
|||||||
niches: [...new Set(leads.map((lead) => lead.niche).filter(Boolean))].sort(),
|
niches: [...new Set(leads.map((lead) => lead.niche).filter(Boolean))].sort(),
|
||||||
postalCodes: [...new Set(leads.map((lead) => lead.postalCode).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: {
|
metrics: {
|
||||||
foundLeads: filteredLeads.length,
|
foundLeads: filteredLeads.length,
|
||||||
leadsWithContact: filteredLeads.filter((lead) => Boolean(lead.email || lead.phone)).length,
|
leadsWithContact: filteredLeads.filter((lead) => Boolean(lead.email || lead.phone)).length,
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ export type CampaignRybbitSummary = {
|
|||||||
auditOpens: number;
|
auditOpens: number;
|
||||||
ctaClicks: number;
|
ctaClicks: number;
|
||||||
outboundClicks: number;
|
outboundClicks: number;
|
||||||
|
byPath: Record<string, {
|
||||||
|
auditOpens: number;
|
||||||
|
ctaClicks: number;
|
||||||
|
outboundClicks: number;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type FetchLike = (
|
type FetchLike = (
|
||||||
@@ -127,6 +132,25 @@ export function summarizeCampaignRybbitEvents(
|
|||||||
events: RybbitEvent[],
|
events: RybbitEvent[],
|
||||||
): CampaignRybbitSummary {
|
): CampaignRybbitSummary {
|
||||||
const auditEvents = events.filter((event) => eventPath(event).startsWith("/audit/"));
|
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 {
|
return {
|
||||||
auditOpens: auditEvents.filter((event) => event.type === "pageview").length,
|
auditOpens: auditEvents.filter((event) => event.type === "pageview").length,
|
||||||
@@ -137,6 +161,7 @@ export function summarizeCampaignRybbitEvents(
|
|||||||
return event.type === "outbound_link" ||
|
return event.type === "outbound_link" ||
|
||||||
eventName(event) === "audit_website_link_click";
|
eventName(event) === "audit_website_link_click";
|
||||||
}).length,
|
}).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, 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", () => {
|
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, /Rybbit-Daten konnten nicht geladen werden/);
|
||||||
assert.match(componentSource, /Audit-Öffnungen/);
|
assert.match(componentSource, /Audit-Öffnungen/);
|
||||||
assert.match(componentSource, /CTA-Klicks/);
|
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,
|
auditOpens: 1,
|
||||||
ctaClicks: 1,
|
ctaClicks: 1,
|
||||||
outboundClicks: 1,
|
outboundClicks: 1,
|
||||||
|
byPath: {
|
||||||
|
"/audit/a": {
|
||||||
|
auditOpens: 1,
|
||||||
|
ctaClicks: 1,
|
||||||
|
outboundClicks: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user