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 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:30' updated_date: '2026-06-05 19:49'
labels: labels:
- mvp - mvp
- sales - sales
@@ -25,11 +25,11 @@ Add the lightweight CRM layer needed after first contact. The MVP does not parse
## Acceptance Criteria ## Acceptance Criteria
<!-- AC:BEGIN --> <!-- AC:BEGIN -->
- [ ] #1 After an initial send, a single follow-up draft and suggested due date are created - [x] #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 - [x] #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 - [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
- [ ] #4 Marking Antwort erhalten or Kein Interesse stops pending follow-up prompts - [x] #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] #5 Nicht erneut kontaktieren blocks outreach for 12 months and then reappears only as Erneut prüfen
<!-- AC:END --> <!-- AC:END -->
## Implementation Plan ## Implementation Plan
@@ -46,4 +46,6 @@ Add the lightweight CRM layer needed after first contact. The MVP does not parse
<!-- SECTION:NOTES:BEGIN --> <!-- 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. 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 --> <!-- SECTION:NOTES:END -->

View File

@@ -4,7 +4,7 @@ title: Orchestrate recurring Convex agent jobs and audit lifecycle
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:30' updated_date: '2026-06-05 19:49'
labels: labels:
- mvp - mvp
- convex - convex
@@ -27,11 +27,11 @@ Implement the scheduled and manual background workflow using Convex. The MVP per
## Acceptance Criteria ## Acceptance Criteria
<!-- AC:BEGIN --> <!-- AC:BEGIN -->
- [ ] #1 Convex cron or scheduled functions trigger active campaigns according to cadence - [x] #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 - [x] #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 - [x] #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 - [x] #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] #5 Published audits older than 60 days auto-deactivate unless manually extended or later reactivated
<!-- AC:END --> <!-- AC:END -->
## Implementation Plan ## Implementation Plan
@@ -48,4 +48,6 @@ Implement the scheduled and manual background workflow using Convex. The MVP per
<!-- SECTION:NOTES:BEGIN --> <!-- SECTION:NOTES:BEGIN -->
Started implementation pass for recurring Convex agent jobs, run locking, logs, and audit lifecycle. 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 --> <!-- SECTION:NOTES:END -->

View File

@@ -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:30' updated_date: '2026-06-05 19:49'
labels: labels:
- mvp - mvp
- analytics - analytics
@@ -25,11 +25,11 @@ Display anonymous analytics for generated public audit pages inside the internal
## Acceptance Criteria ## Acceptance Criteria
<!-- AC:BEGIN --> <!-- AC:BEGIN -->
- [ ] #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
- [ ] #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
- [ ] #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 - [ ] #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 --> <!-- AC:END -->
## Implementation Plan ## Implementation Plan
@@ -46,4 +46,6 @@ Display anonymous analytics for generated public audit pages inside the internal
<!-- SECTION:NOTES:BEGIN --> <!-- SECTION:NOTES:BEGIN -->
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.
<!-- SECTION:NOTES:END --> <!-- SECTION:NOTES:END -->

View File

@@ -4,7 +4,7 @@ title: Add MVP quality gates and operational polish
status: In Progress status: In Progress
assignee: [] assignee: []
created_date: '2026-06-03 19:15' created_date: '2026-06-03 19:15'
updated_date: '2026-06-05 19:30' updated_date: '2026-06-05 19:49'
labels: labels:
- mvp - mvp
- quality - quality
@@ -27,11 +27,11 @@ Add the final MVP quality layer: German UI consistency, i18n preparation, access
## Acceptance Criteria ## Acceptance Criteria
<!-- AC:BEGIN --> <!-- AC:BEGIN -->
- [ ] #1 Core UI text is German and organized so future i18n is feasible - [x] #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 - [x] #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 - [x] #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 - [x] #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] #5 Coolify deployment notes cover required environment variables, Playwright browser dependencies, exposed port, and domain assumptions
<!-- AC:END --> <!-- AC:END -->
## Implementation Plan ## Implementation Plan
@@ -48,4 +48,6 @@ Add the final MVP quality layer: German UI consistency, i18n preparation, access
<!-- SECTION:NOTES:BEGIN --> <!-- SECTION:NOTES:BEGIN -->
Started implementation pass for MVP quality gates, error observability, verification notes, and deployment readiness. 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 --> <!-- SECTION:NOTES:END -->

View File

@@ -4,7 +4,7 @@ title: Add campaign performance metrics
status: In Progress status: In Progress
assignee: [] assignee: []
created_date: '2026-06-03 19:15' created_date: '2026-06-03 19:15'
updated_date: '2026-06-05 19:30' updated_date: '2026-06-05 19:49'
labels: labels:
- mvp - mvp
- analytics - analytics
@@ -27,11 +27,11 @@ Build the campaign metrics layer that summarizes acquisition progress from Conve
## Acceptance Criteria ## Acceptance Criteria
<!-- AC:BEGIN --> <!-- 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 - [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
- [ ] #2 Metrics can be filtered by campaign, niche/category, PLZ/region, radius, priority, status, and timeframe - [x] #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 - [x] #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 - [x] #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] #5 Metrics remain readable and lightweight, without becoming a full enterprise CRM dashboard
<!-- AC:END --> <!-- AC:END -->
## Implementation Plan ## Implementation Plan
@@ -48,4 +48,6 @@ Build the campaign metrics layer that summarizes acquisition progress from Conve
<!-- SECTION:NOTES:BEGIN --> <!-- SECTION:NOTES:BEGIN -->
Started implementation pass for campaign performance metrics and filters. 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 --> <!-- SECTION:NOTES:END -->

View File

@@ -4,7 +4,7 @@ title: Trigger audit generation after PageSpeed audit
status: In Progress status: In Progress
assignee: [] assignee: []
created_date: '2026-06-05 12:10' created_date: '2026-06-05 12:10'
updated_date: '2026-06-05 19:30' updated_date: '2026-06-05 19:49'
labels: [] labels: []
dependencies: [] dependencies: []
priority: high priority: high
@@ -19,10 +19,10 @@ Wire the existing AI audit generation queue into the current automated flow so c
## Acceptance Criteria ## Acceptance Criteria
<!-- AC:BEGIN --> <!-- AC:BEGIN -->
- [ ] #1 Successful PageSpeed audit runs queue audit generation for the lead - [x] #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 - [x] #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 - [x] #3 Existing dedupe in queueLeadAuditGeneration prevents duplicate audit_generation runs
- [ ] #4 Regression tests cover the PageSpeed-to-audit-generation handoff - [x] #4 Regression tests cover the PageSpeed-to-audit-generation handoff
<!-- AC:END --> <!-- AC:END -->
## Implementation Notes ## 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. 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. 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 --> <!-- SECTION:NOTES:END -->

View File

@@ -1,8 +1,8 @@
"use client"; "use client";
import { useMemo } from "react"; import { useEffect, useMemo, useState } from "react";
import { useQuery } from "convex/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 { api } from "@/convex/_generated/api";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -30,6 +30,12 @@ const metricLabels: Record<string, string> = {
export function AnalyticsDashboard() { export function AnalyticsDashboard() {
const dashboard = useQuery(api.campaignMetrics.getDashboard, { limit: 20 }); 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(() => { const metricEntries = useMemo(() => {
if (!dashboard) { if (!dashboard) {
return []; return [];
@@ -38,6 +44,31 @@ 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]);
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) { if (dashboard === undefined) {
return ( return (
<section className="space-y-4"> <section className="space-y-4">
@@ -130,6 +161,10 @@ export function AnalyticsDashboard() {
</CardHeader> </CardHeader>
<CardContent className="space-y-2 text-sm text-muted-foreground"> <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> <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> <p>Public-Audit Tracking läuft nur auf veröffentlichten Audit-Seiten.</p>
</CardContent> </CardContent>
</Card> </Card>

View File

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

View File

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

View File

@@ -18,6 +18,12 @@ export type AuditRybbitSummary = {
deviceTypes: string[]; deviceTypes: string[];
}; };
export type CampaignRybbitSummary = {
auditOpens: number;
ctaClicks: number;
outboundClicks: number;
};
type FetchLike = ( type FetchLike = (
input: string | URL, input: string | URL,
init?: RequestInit, 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[] { function normalizeEventsPayload(payload: unknown): RybbitEvent[] {
if (Array.isArray(payload)) { if (Array.isArray(payload)) {
return payload.filter((event): event is RybbitEvent => typeof event === "object" && event !== null); 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.doesNotMatch(pageSource, /DashboardPlaceholderPage/);
assert.match(pageSource, /AnalyticsDashboard/); assert.match(pageSource, /AnalyticsDashboard/);
assert.match(componentSource, /api\.campaignMetrics\.getDashboard/); assert.match(componentSource, /api\.campaignMetrics\.getDashboard/);
assert.match(componentSource, /\/api\/internal\/rybbit\/campaign/);
assert.match(componentSource, /Kampagne/); assert.match(componentSource, /Kampagne/);
assert.match(componentSource, /Nische/); assert.match(componentSource, /Nische/);
assert.match(componentSource, /PLZ/); 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, /Status/);
assert.match(componentSource, /Zeitraum/); assert.match(componentSource, /Zeitraum/);
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, /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"); 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", () => { test("dashboard-model exposes stable lead label helpers for UI mapping", () => {
assert.deepEqual(leadPriorityOptions, [ assert.deepEqual(leadPriorityOptions, [
"high", "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, /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/); 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 { import {
buildRybbitEventsUrl, buildRybbitEventsUrl,
summarizeCampaignRybbitEvents,
summarizeAuditRybbitEvents, summarizeAuditRybbitEvents,
type RybbitEvent, type RybbitEvent,
} from "../lib/rybbit-analytics"; } from "../lib/rybbit-analytics";
@@ -70,3 +71,19 @@ test("summarizeAuditRybbitEvents returns graceful empty metrics", () => {
deviceTypes: [], 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,
},
);
});