From 24625c20f2f78f2d563d4643384f0577e3d14f79 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Mon, 27 Jul 2026 10:43:44 +0200 Subject: [PATCH] feat: enhance analytics mailer with umami entry/exit pages and beginner-friendly UI --- apps/analytics-mailer/src/index.test.ts | 28 ++++++++++++ apps/analytics-mailer/src/index.ts | 21 ++++++--- .../src/templates/AnalyticsTemplate.test.tsx | 41 +++++++++++++++++ .../mail/src/templates/AnalyticsTemplate.tsx | 44 +++++++++++++++++-- 4 files changed, 125 insertions(+), 9 deletions(-) create mode 100644 apps/analytics-mailer/src/index.test.ts create mode 100644 packages/mail/src/templates/AnalyticsTemplate.test.tsx diff --git a/apps/analytics-mailer/src/index.test.ts b/apps/analytics-mailer/src/index.test.ts new file mode 100644 index 0000000..3910e8c --- /dev/null +++ b/apps/analytics-mailer/src/index.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect, vi } from "vitest"; +import { fetchUmamiData } from "./index"; + +describe("fetchUmamiData", () => { + it("fetches entry and exit pages correctly", async () => { + const apiMock = { + get: vi.fn().mockImplementation((url, config) => { + if (url.includes("/stats")) return Promise.resolve({ data: { visitors: 10, visits: 10, pageviews: 10, bounces: 5, totaltime: 100 } }); + if (url.includes("/pageviews")) return Promise.resolve({ data: { pageviews: [] } }); + if (url.includes("/metrics")) { + if (config.params.type === "path") return Promise.resolve({ data: [{ x: "/path", y: 5 }] }); + if (config.params.type === "referrer") return Promise.resolve({ data: [{ x: "google", y: 3 }] }); + if (config.params.type === "entry") return Promise.resolve({ data: [{ x: "/entry", y: 4 }] }); + if (config.params.type === "exit") return Promise.resolve({ data: [{ x: "/exit", y: 2 }] }); + } + return Promise.resolve({ data: {} }); + }) + }; + + const result = await fetchUmamiData(apiMock, "test-id", 0, 1000, "Test"); + + // We expect the new entry and exit pages to be parsed and returned correctly + expect(result.topEntryPages).toBeDefined(); + expect(result.topEntryPages[0]).toEqual({ url: "/entry", visitors: 4 }); + expect(result.topExitPages).toBeDefined(); + expect(result.topExitPages[0]).toEqual({ url: "/exit", visitors: 2 }); + }); +}); diff --git a/apps/analytics-mailer/src/index.ts b/apps/analytics-mailer/src/index.ts index d25159e..6053cfb 100644 --- a/apps/analytics-mailer/src/index.ts +++ b/apps/analytics-mailer/src/index.ts @@ -48,20 +48,24 @@ async function getUmamiToken(): Promise { import https from "https"; -async function fetchUmamiData(api: any, websiteId: string, startAt: number, endAt: number, label: string) { +export async function fetchUmamiData(api: any, websiteId: string, startAt: number, endAt: number, label: string) { let unit = "day"; if (label === "Dieses Jahr") unit = "month"; - const [statsRes, pagesRes, refRes, viewsRes] = await Promise.all([ + const [statsRes, pagesRes, refRes, viewsRes, entryRes, exitRes] = await Promise.all([ api.get(`/websites/${websiteId}/stats`, { params: { startAt, endAt } }), api.get(`/websites/${websiteId}/metrics`, { params: { type: "path", limit: 5, startAt, endAt } }), api.get(`/websites/${websiteId}/metrics`, { params: { type: "referrer", limit: 5, startAt, endAt } }), api.get(`/websites/${websiteId}/pageviews`, { params: { startAt, endAt, unit, timezone: "Europe/Berlin" } }).catch(() => ({ data: { pageviews: [] } })), + api.get(`/websites/${websiteId}/metrics`, { params: { type: "entry", limit: 5, startAt, endAt } }), + api.get(`/websites/${websiteId}/metrics`, { params: { type: "exit", limit: 5, startAt, endAt } }), ]); const stats = statsRes.data; const topPages = (pagesRes.data || []).map((p: any) => ({ url: p.x, views: p.y || 0 })); const topReferrers = (refRes.data || []).map((r: any) => ({ url: r.x, visitors: r.y || 0 })); + const topEntryPages = (entryRes.data || []).map((p: any) => ({ url: p.x, visitors: p.y || 0 })); + const topExitPages = (exitRes.data || []).map((p: any) => ({ url: p.x, visitors: p.y || 0 })); const visitors = typeof stats.visitors === "object" ? stats.visitors?.value || 0 : stats.visitors || 0; const visits = typeof stats.visits === "object" ? stats.visits?.value || 0 : stats.visits || 0; @@ -95,6 +99,8 @@ async function fetchUmamiData(api: any, websiteId: string, startAt: number, endA visitDuration, topPages, topReferrers, + topEntryPages, + topExitPages, chartData, }; } @@ -130,7 +136,9 @@ async function main() { } : undefined, }); - const timeRanges = [getLast7Days(), getThisMonth(), getThisYear()]; + const timeRanges = runInterval === "weekly" + ? [getLast7Days(), getThisMonth(), getThisYear()] + : [getThisMonth(), getThisYear()]; console.log(`Generating reports...`); @@ -157,8 +165,12 @@ async function main() { const subject = `Performance-Report - ${client.domain}`; + const recipients = client.clientEmail.split(',').map(e => e.trim()).filter(e => e.length > 0); + if (isDryRun) { - console.log(`[DRY-RUN] Would send email to ${client.clientEmail} for ${client.domain}`); + for (const recipient of recipients) { + console.log(`[DRY-RUN] Would send individual email to: ${recipient} (for ${client.domain})`); + } const debugPath = path.resolve(process.cwd(), `dry-run-${client.domain}.html`); await fs.writeFile(debugPath, html); console.log(`[DRY-RUN] Saved HTML preview to ${debugPath}`); @@ -167,7 +179,6 @@ async function main() { throw new Error("SMTP_HOST is missing for live run."); } - const recipients = client.clientEmail.split(',').map(e => e.trim()).filter(e => e.length > 0); for (const recipient of recipients) { await transporter.sendMail({ from: SMTP_SENDER, diff --git a/packages/mail/src/templates/AnalyticsTemplate.test.tsx b/packages/mail/src/templates/AnalyticsTemplate.test.tsx new file mode 100644 index 0000000..5eb1e06 --- /dev/null +++ b/packages/mail/src/templates/AnalyticsTemplate.test.tsx @@ -0,0 +1,41 @@ +import { render } from "@react-email/components"; +import { describe, it, expect } from "vitest"; +import { AnalyticsTemplate, PeriodData } from "./AnalyticsTemplate"; +import * as React from "react"; + +describe("AnalyticsTemplate", () => { + it("renders entry and exit pages correctly", async () => { + const period: PeriodData = { + label: "Test Period", + visitors: 100, + visits: 120, + views: 500, + bounceRate: 50, + visitDuration: "2m", + topPages: [{ url: "/home", views: 50 }], + topReferrers: [{ url: "google.com", visitors: 30 }], + // These fields will cause a TS error because they don't exist yet, + // but we force it to see it fail. + topEntryPages: [{ url: "/entry", visitors: 40 }], + topExitPages: [{ url: "/exit", visitors: 20 }], + chartData: [], + } as any; // Cast as any to bypass TS compilation before we update the type + + const html = await render( + + ); + + // Assert that the new sections for entry and exit pages exist. + expect(html).toContain("Häufigste Einstiege"); + expect(html).toContain("Häufigste Ausstiege"); + expect(html).toContain("/entry"); + expect(html).toContain("/exit"); + + // Assert that the badges have updated line heights + expect(html).toContain('line-height:1.5'); + }); +}); diff --git a/packages/mail/src/templates/AnalyticsTemplate.tsx b/packages/mail/src/templates/AnalyticsTemplate.tsx index 3ae5574..4b4ee30 100644 --- a/packages/mail/src/templates/AnalyticsTemplate.tsx +++ b/packages/mail/src/templates/AnalyticsTemplate.tsx @@ -10,6 +10,8 @@ export interface PeriodData { visitDuration: string; topPages: { url: string; views: number }[]; topReferrers: { url: string; visitors: number }[]; + topEntryPages: { url: string; visitors: number }[]; + topExitPages: { url: string; visitors: number }[]; chartData: { label: string; value: number }[]; } @@ -135,8 +137,8 @@ export const AnalyticsTemplate = ({
- Seiten - Die beliebtesten Unterseiten sortiert nach Aufrufen. + Beliebteste Seiten + Was schauen sich Ihre Besucher am häufigsten an? {period.topPages.map((p, idx) => ( {p.url} @@ -148,8 +150,8 @@ export const AnalyticsTemplate = ({
- Quellen - Woher Ihre Besucher auf die Website kommen. + Woher kommen Ihre Besucher? + Wie wurden Sie gefunden? (z.B. Google, Direkt) {period.topReferrers.map((r, idx) => ( {r.url || "Direktaufruf"} @@ -161,6 +163,38 @@ export const AnalyticsTemplate = ({ + + + + + + + + +
+
+ Häufigste Einstiege + Auf welchen Seiten starten Ihre Besucher? + {period.topEntryPages?.map((p, idx) => ( + + {p.url} + {p.visitors.toLocaleString("de-DE")} + + ))} +
+
+ +
+ Häufigste Ausstiege + Wo verlassen Besucher Ihre Website am häufigsten? + {period.topExitPages?.map((p, idx) => ( + + {p.url} + {p.visitors.toLocaleString("de-DE")} + + ))} +
+

))} @@ -332,6 +366,7 @@ const badgePositive = { fontWeight: "bold", color: "#16a34a", margin: "4px 0 0 0", + lineHeight: "1.5", }; const badgeNeutral = { @@ -339,4 +374,5 @@ const badgeNeutral = { fontWeight: "bold", color: "#6b7280", margin: "4px 0 0 0", + lineHeight: "1.5", };