Finalize metrics verification and backlog updates

This commit is contained in:
2026-06-05 21:49:57 +02:00
parent d3928d61c4
commit f069b74b08
15 changed files with 240 additions and 36 deletions

View File

@@ -0,0 +1,18 @@
import { fetchRybbitCampaignAnalytics } from "@/lib/rybbit-analytics";
export async function GET(request: Request) {
const url = new URL(request.url);
const result = await fetchRybbitCampaignAnalytics({
apiUrl: process.env.RYBBIT_API_URL,
apiKey: process.env.RYBBIT_API_KEY,
siteId: process.env.NEXT_PUBLIC_RYBBIT_SITE_ID,
startDate: url.searchParams.get("startDate") ?? undefined,
endDate: url.searchParams.get("endDate") ?? undefined,
});
if (!result.ok) {
return Response.json({ ok: false, error: result.error, data: result.data });
}
return Response.json({ ok: true, data: result.data });
}

View File

@@ -4,7 +4,7 @@ title: Add follow-up and manual sales status tracking
status: In Progress
assignee: []
created_date: '2026-06-03 19:14'
updated_date: '2026-06-05 19:30'
updated_date: '2026-06-05 19:49'
labels:
- mvp
- sales
@@ -25,11 +25,11 @@ Add the lightweight CRM layer needed after first contact. The MVP does not parse
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 After an initial send, a single follow-up draft and suggested due date are created
- [ ] #2 Follow-up sending requires manual review and approval, just like the first email
- [ ] #3 Manual statuses exist for Antwort erhalten, Kein Interesse, Später wieder melden, Gespräch vereinbart, Angebot angefragt, Angebot gesendet, Auftrag gewonnen, Auftrag verloren, Nicht weiter verfolgen, Follow-up geplant, and Follow-up gesendet
- [ ] #4 Marking Antwort erhalten or Kein Interesse stops pending follow-up prompts
- [ ] #5 Nicht erneut kontaktieren blocks outreach for 12 months and then reappears only as Erneut prüfen
- [x] #1 After an initial send, a single follow-up draft and suggested due date are created
- [x] #2 Follow-up sending requires manual review and approval, just like the first email
- [x] #3 Manual statuses exist for Antwort erhalten, Kein Interesse, Später wieder melden, Gespräch vereinbart, Angebot angefragt, Angebot gesendet, Auftrag gewonnen, Auftrag verloren, Nicht weiter verfolgen, Follow-up geplant, and Follow-up gesendet
- [x] #4 Marking Antwort erhalten or Kein Interesse stops pending follow-up prompts
- [x] #5 Nicht erneut kontaktieren blocks outreach for 12 months and then reappears only as Erneut prüfen
<!-- AC:END -->
## Implementation Plan
@@ -46,4 +46,6 @@ Add the lightweight CRM layer needed after first contact. The MVP does not parse
<!-- SECTION:NOTES:BEGIN -->
Started implementation pass for tasks 15-19 and 27. TASK-27 note says it is superseded by TASK-13, so this pass will verify the existing PageSpeed-to-audit-generation handoff rather than implement it separately.
Implemented and verified follow-up draft creation after send, manual approval boundaries for follow-up records, manual sales status labels/mutation, reply/no-interest suppression, and 12-month do-not-contact recheck visibility. Verification: pnpm test 305/305; pnpm lint 0 errors.
<!-- SECTION:NOTES:END -->

View File

@@ -4,7 +4,7 @@ title: Orchestrate recurring Convex agent jobs and audit lifecycle
status: In Progress
assignee: []
created_date: '2026-06-03 19:14'
updated_date: '2026-06-05 19:30'
updated_date: '2026-06-05 19:49'
labels:
- mvp
- convex
@@ -27,11 +27,11 @@ Implement the scheduled and manual background workflow using Convex. The MVP per
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Convex cron or scheduled functions trigger active campaigns according to cadence
- [ ] #2 Jetzt ausführen starts a campaign run immediately only when no other agent run is active
- [ ] #3 Cron skips or queues safely when an agent run is already active, with visible run logs
- [ ] #4 Published audits older than 30 days create dashboard notifications asking whether to keep active
- [ ] #5 Published audits older than 60 days auto-deactivate unless manually extended or later reactivated
- [x] #1 Convex cron or scheduled functions trigger active campaigns according to cadence
- [x] #2 Jetzt ausführen starts a campaign run immediately only when no other agent run is active
- [x] #3 Cron skips or queues safely when an agent run is already active, with visible run logs
- [x] #4 Published audits older than 30 days create dashboard notifications asking whether to keep active
- [x] #5 Published audits older than 60 days auto-deactivate unless manually extended or later reactivated
<!-- AC:END -->
## Implementation Plan
@@ -48,4 +48,6 @@ Implement the scheduled and manual background workflow using Convex. The MVP per
<!-- SECTION:NOTES:BEGIN -->
Started implementation pass for recurring Convex agent jobs, run locking, logs, and audit lifecycle.
Implemented and verified Convex crons, due-campaign runner, single-active-run guard, visible campaign run logs, and audit lifecycle notification/deactivation controls. Verification: pnpm test 305/305; pnpm lint 0 errors.
<!-- SECTION:NOTES:END -->

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:30'
updated_date: '2026-06-05 19:49'
labels:
- mvp
- analytics
@@ -25,11 +25,11 @@ Display anonymous analytics for generated public audit pages inside the internal
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Rybbit tracking runs only on public audit pages, not on internal dashboard routes
- [ ] #2 Dashboard can fetch Rybbit API data for pageviews, custom events, and outbound link clicks for audit pages
- [ ] #3 Per-audit analytics show opened yes/no, view count, last view, CTA clicks, website-link clicks, and device type where available
- [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
- [ ] #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 -->
## Implementation Plan
@@ -46,4 +46,6 @@ Display anonymous analytics for generated public audit pages inside the internal
<!-- SECTION:NOTES:BEGIN -->
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.
<!-- SECTION:NOTES:END -->

View File

@@ -4,7 +4,7 @@ title: Add MVP quality gates and operational polish
status: In Progress
assignee: []
created_date: '2026-06-03 19:15'
updated_date: '2026-06-05 19:30'
updated_date: '2026-06-05 19:49'
labels:
- mvp
- quality
@@ -27,11 +27,11 @@ Add the final MVP quality layer: German UI consistency, i18n preparation, access
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Core UI text is German and organized so future i18n is feasible
- [ ] #2 No secrets are stored in source code, dashboard-editable records, logs, prompts, or raw LLM history
- [ ] #3 Dashboard surfaces integration errors for Google, PageSpeed, OpenRouter, Playwright, SMTP, Convex jobs, and Rybbit
- [ ] #4 Critical user flows have basic tests or repeatable verification notes: login, campaign run, audit generation, approval, send, follow-up, analytics
- [ ] #5 Coolify deployment notes cover required environment variables, Playwright browser dependencies, exposed port, and domain assumptions
- [x] #1 Core UI text is German and organized so future i18n is feasible
- [x] #2 No secrets are stored in source code, dashboard-editable records, logs, prompts, or raw LLM history
- [x] #3 Dashboard surfaces integration errors for Google, PageSpeed, OpenRouter, Playwright, SMTP, Convex jobs, and Rybbit
- [x] #4 Critical user flows have basic tests or repeatable verification notes: login, campaign run, audit generation, approval, send, follow-up, analytics
- [x] #5 Coolify deployment notes cover required environment variables, Playwright browser dependencies, exposed port, and domain assumptions
<!-- AC:END -->
## Implementation Plan
@@ -48,4 +48,6 @@ Add the final MVP quality layer: German UI consistency, i18n preparation, access
<!-- SECTION:NOTES:BEGIN -->
Started implementation pass for MVP quality gates, error observability, verification notes, and deployment readiness.
Implemented and verified German operational readiness surfaces, secret-safe integration status rows, verification notes for critical flows, and Coolify deployment notes. Verification: pnpm test 305/305; pnpm lint 0 errors.
<!-- SECTION:NOTES:END -->

View File

@@ -4,7 +4,7 @@ title: Add campaign performance metrics
status: In Progress
assignee: []
created_date: '2026-06-03 19:15'
updated_date: '2026-06-05 19:30'
updated_date: '2026-06-05 19:49'
labels:
- mvp
- analytics
@@ -27,11 +27,11 @@ Build the campaign metrics layer that summarizes acquisition progress from Conve
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Campaign dashboard shows found leads, leads with contact, Kontakt fehlt, audits created, approvals open, emails sent, follow-ups planned/sent, responses, conversations, offers, wins, and losses
- [ ] #2 Metrics can be filtered by campaign, niche/category, PLZ/region, radius, priority, status, and timeframe
- [ ] #3 Campaign run detail shows new leads, skipped duplicates, blacklisted/skipped leads, errors, and audits generated
- [ ] #4 Rybbit-derived audit opens and CTA clicks are shown alongside Convex sales funnel metrics
- [ ] #5 Metrics remain readable and lightweight, without becoming a full enterprise CRM dashboard
- [x] #1 Campaign dashboard shows found leads, leads with contact, Kontakt fehlt, audits created, approvals open, emails sent, follow-ups planned/sent, responses, conversations, offers, wins, and losses
- [x] #2 Metrics can be filtered by campaign, niche/category, PLZ/region, radius, priority, status, and timeframe
- [x] #3 Campaign run detail shows new leads, skipped duplicates, blacklisted/skipped leads, errors, and audits generated
- [x] #4 Rybbit-derived audit opens and CTA clicks are shown alongside Convex sales funnel metrics
- [x] #5 Metrics remain readable and lightweight, without becoming a full enterprise CRM dashboard
<!-- AC:END -->
## Implementation Plan
@@ -48,4 +48,6 @@ Build the campaign metrics layer that summarizes acquisition progress from Conve
<!-- SECTION:NOTES:BEGIN -->
Started implementation pass for campaign performance metrics and filters.
Implemented and verified lightweight campaign metrics query/dashboard, filter contract, run detail rows, and Rybbit-derived audit opens/CTA clicks alongside Convex metrics. Verification: pnpm test 305/305; pnpm lint 0 errors.
<!-- SECTION:NOTES:END -->

View File

@@ -4,7 +4,7 @@ title: Trigger audit generation after PageSpeed audit
status: In Progress
assignee: []
created_date: '2026-06-05 12:10'
updated_date: '2026-06-05 19:30'
updated_date: '2026-06-05 19:49'
labels: []
dependencies: []
priority: high
@@ -19,10 +19,10 @@ Wire the existing AI audit generation queue into the current automated flow so c
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Successful PageSpeed audit runs queue audit generation for the lead
- [ ] #2 Failed PageSpeed audit runs still queue audit generation when a lead was started so partial evidence can produce an audit
- [ ] #3 Existing dedupe in queueLeadAuditGeneration prevents duplicate audit_generation runs
- [ ] #4 Regression tests cover the PageSpeed-to-audit-generation handoff
- [x] #1 Successful PageSpeed audit runs queue audit generation for the lead
- [x] #2 Failed PageSpeed audit runs still queue audit generation when a lead was started so partial evidence can produce an audit
- [x] #3 Existing dedupe in queueLeadAuditGeneration prevents duplicate audit_generation runs
- [x] #4 Regression tests cover the PageSpeed-to-audit-generation handoff
<!-- AC:END -->
## Implementation Notes
@@ -31,4 +31,6 @@ Wire the existing AI audit generation queue into the current automated flow so c
Created accidentally while implementing the PageSpeed-to-audit-generation handoff. Superseded by TASK-13 because the handoff is a prerequisite for the audit/outreach review workspace. Do not implement separately.
Started verification pass. Implementation notes say TASK-27 is superseded by TASK-13, so only regression coverage and existing handoff will be checked.
Verified existing PageSpeed-to-audit-generation handoff in pageSpeedAction. Successful and failure paths queue audit generation for the started lead, queue failures are warning-logged, existing queueLeadAuditGeneration dedupe remains in place, and regression source tests pass. Verification: pnpm test 305/305; pnpm lint 0 errors.
<!-- SECTION:NOTES:END -->

View File

@@ -1,8 +1,8 @@
"use client";
import { useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "convex/react";
import { Activity, BarChart3, Filter, MousePointerClick } from "lucide-react";
import { Activity, Filter, MousePointerClick } from "lucide-react";
import { api } from "@/convex/_generated/api";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -30,6 +30,12 @@ const metricLabels: Record<string, string> = {
export function AnalyticsDashboard() {
const dashboard = useQuery(api.campaignMetrics.getDashboard, { limit: 20 });
const [rybbitData, setRybbitData] = useState<{
auditOpens: number;
ctaClicks: number;
outboundClicks: number;
} | null>(null);
const [rybbitError, setRybbitError] = useState<string | null>(null);
const metricEntries = useMemo(() => {
if (!dashboard) {
return [];
@@ -38,6 +44,31 @@ export function AnalyticsDashboard() {
return Object.entries(dashboard.metrics).filter(([key]) => key in metricLabels);
}, [dashboard]);
useEffect(() => {
let isMounted = true;
fetch("/api/internal/rybbit/campaign")
.then(async (response) => {
const payload = await response.json();
if (!isMounted) {
return;
}
if (!payload.ok) {
setRybbitError("Rybbit-Daten konnten nicht geladen werden.");
}
setRybbitData(payload.data ?? null);
})
.catch(() => {
if (isMounted) {
setRybbitError("Rybbit-Daten konnten nicht geladen werden.");
}
});
return () => {
isMounted = false;
};
}, []);
if (dashboard === undefined) {
return (
<section className="space-y-4">
@@ -130,6 +161,10 @@ export function AnalyticsDashboard() {
</CardHeader>
<CardContent className="space-y-2 text-sm text-muted-foreground">
<p>Rybbit-Daten konnten nicht geladen werden, wenn API-URL, Site-ID oder API-Key fehlen.</p>
{rybbitError ? <p className="text-destructive">{rybbitError}</p> : null}
<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>
<p>Public-Audit Tracking läuft nur auf veröffentlichten Audit-Seiten.</p>
</CardContent>
</Card>

View File

@@ -392,6 +392,7 @@ export const listFunnel = query({
sendStatus: latestOutreach.sendStatus,
responseStatus: latestOutreach.responseStatus,
salesStatus: latestOutreach.salesStatus,
doNotContactUntil: latestOutreach.doNotContactUntil ?? null,
}
: null,
};

View File

@@ -86,6 +86,7 @@ export type LeadFunnelOutreach = {
sendStatus?: OutreachSendStatus | null;
responseStatus?: OutreachResponseStatus | null;
salesStatus?: OutreachSalesStatus | null;
doNotContactUntil?: number | null;
};
export type LeadFunnelInput = {
@@ -103,6 +104,7 @@ export type LeadFunnelInput = {
contactPerson?: string | null;
websiteDomain?: string | null;
outreach?: LeadFunnelOutreach | null;
now?: number;
};
export type LeadFunnelCard = {
@@ -303,6 +305,14 @@ function getLeadNextAction(lead: LeadFunnelInput): string {
const stageId = getLeadFunnelStageId(lead);
if (stageId === "deferred") {
if (
lead.outreach?.salesStatus === "do_not_pursue" &&
typeof lead.outreach.doNotContactUntil === "number" &&
(lead.now ?? Date.now()) >= lead.outreach.doNotContactUntil
) {
return "Erneut prüfen";
}
return "Zurückstellung prüfen";
}

View File

@@ -18,6 +18,12 @@ export type AuditRybbitSummary = {
deviceTypes: string[];
};
export type CampaignRybbitSummary = {
auditOpens: number;
ctaClicks: number;
outboundClicks: number;
};
type FetchLike = (
input: string | URL,
init?: RequestInit,
@@ -117,6 +123,23 @@ export function summarizeAuditRybbitEvents(
};
}
export function summarizeCampaignRybbitEvents(
events: RybbitEvent[],
): CampaignRybbitSummary {
const auditEvents = events.filter((event) => eventPath(event).startsWith("/audit/"));
return {
auditOpens: auditEvents.filter((event) => event.type === "pageview").length,
ctaClicks: auditEvents.filter((event) => {
return event.type === "custom_event" && eventName(event) === "audit_cta_click";
}).length,
outboundClicks: auditEvents.filter((event) => {
return event.type === "outbound_link" ||
eventName(event) === "audit_website_link_click";
}).length,
};
}
function normalizeEventsPayload(payload: unknown): RybbitEvent[] {
if (Array.isArray(payload)) {
return payload.filter((event): event is RybbitEvent => typeof event === "object" && event !== null);
@@ -193,3 +216,58 @@ export async function fetchRybbitAuditAnalytics(input: {
};
}
}
export async function fetchRybbitCampaignAnalytics(input: {
apiUrl?: string;
apiKey?: string;
siteId?: string;
startDate?: string;
endDate?: string;
fetchImpl?: FetchLike;
}) {
if (!input.apiUrl || !input.apiKey || !input.siteId) {
return {
ok: false as const,
error: "Rybbit ist nicht vollständig konfiguriert.",
data: summarizeCampaignRybbitEvents([]),
};
}
try {
const response = await (input.fetchImpl ?? fetch)(
buildRybbitEventsUrl({
apiUrl: input.apiUrl,
siteId: input.siteId,
startDate: input.startDate,
endDate: input.endDate,
}),
{
headers: {
Authorization: `Bearer ${input.apiKey}`,
},
},
);
if (!response.ok) {
const body = await response.text();
return {
ok: false as const,
error: `Rybbit API Fehler ${response.status}: ${body.slice(0, 160)}`,
data: summarizeCampaignRybbitEvents([]),
};
}
return {
ok: true as const,
data: summarizeCampaignRybbitEvents(
normalizeEventsPayload(await response.json()),
),
};
} catch (error) {
return {
ok: false as const,
error: error instanceof Error ? error.message : String(error),
data: summarizeCampaignRybbitEvents([]),
};
}
}

View File

@@ -61,6 +61,7 @@ test("analytics dashboard renders filters, Convex metrics, and Rybbit error stat
assert.doesNotMatch(pageSource, /DashboardPlaceholderPage/);
assert.match(pageSource, /AnalyticsDashboard/);
assert.match(componentSource, /api\.campaignMetrics\.getDashboard/);
assert.match(componentSource, /\/api\/internal\/rybbit\/campaign/);
assert.match(componentSource, /Kampagne/);
assert.match(componentSource, /Nische/);
assert.match(componentSource, /PLZ/);
@@ -69,4 +70,6 @@ test("analytics dashboard renders filters, Convex metrics, and Rybbit error stat
assert.match(componentSource, /Status/);
assert.match(componentSource, /Zeitraum/);
assert.match(componentSource, /Rybbit-Daten konnten nicht geladen werden/);
assert.match(componentSource, /Audit-Öffnungen/);
assert.match(componentSource, /CTA-Klicks/);
});

View File

@@ -192,6 +192,27 @@ test("toLeadFunnelCard maps blocked priority to deferred stage with blocker labe
assert.equal(card.nextAction, "Zurückstellung prüfen");
});
test("toLeadFunnelCard shows do-not-contact rechecks after the block window", () => {
const card = toLeadFunnelCard({
id: "lead-recheck",
companyName: "Agentur Recheck",
priority: "medium",
contactStatus: "do_not_contact",
blacklistStatus: "clear",
outreach: {
approvalStatus: "approved",
sendStatus: "sent",
responseStatus: "no_interest",
salesStatus: "do_not_pursue",
doNotContactUntil: Date.UTC(2026, 0, 1),
},
now: Date.UTC(2026, 0, 2),
});
assert.equal(card.stageId, "deferred");
assert.equal(card.nextAction, "Erneut prüfen");
});
test("dashboard-model exposes stable lead label helpers for UI mapping", () => {
assert.deepEqual(leadPriorityOptions, [
"high",

View File

@@ -32,3 +32,12 @@ test("manual sales status mutation updates lead suppression states", () => {
assert.match(outreachSource, /not_interested[\s\S]*outreachPatch\.responseStatus\s*=\s*"no_interest"/);
assert.match(outreachSource, /do_not_pursue[\s\S]*outreachPatch\.doNotContactUntil\s*=\s*now\s*\+\s*DO_NOT_CONTACT_RECHECK_MS/);
});
test("lead funnel query exposes do-not-contact recheck dates", () => {
const leadsSource = readFileSync(
join(process.cwd(), "convex", "leads.ts"),
"utf8",
);
assert.match(leadsSource, /doNotContactUntil:\s*latestOutreach\.doNotContactUntil/);
});

View File

@@ -3,6 +3,7 @@ import test from "node:test";
import {
buildRybbitEventsUrl,
summarizeCampaignRybbitEvents,
summarizeAuditRybbitEvents,
type RybbitEvent,
} from "../lib/rybbit-analytics";
@@ -70,3 +71,19 @@ test("summarizeAuditRybbitEvents returns graceful empty metrics", () => {
deviceTypes: [],
});
});
test("summarizeCampaignRybbitEvents aggregates public audit activity", () => {
assert.deepEqual(
summarizeCampaignRybbitEvents([
{ type: "pageview", pathname: "/audit/a" },
{ type: "pageview", pathname: "/dashboard" },
{ type: "custom_event", event_name: "audit_cta_click", pathname: "/audit/a" },
{ type: "outbound_link", pathname: "/audit/a" },
]),
{
auditOpens: 1,
ctaClicks: 1,
outboundClicks: 1,
},
);
});