feat: enhance analytics mailer with umami entry/exit pages and beginner-friendly UI
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧹 Lint (push) Failing after 1m23s
Monorepo Pipeline / 🧪 Test (push) Successful in 41s
Monorepo Pipeline / 🏗️ Build (push) Successful in 2m20s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
🏥 Server Maintenance / 🧹 Prune & Clean (push) Failing after 6s
Analytics Mailer / 📊 Send Analytics Reports (push) Successful in 46s
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧹 Lint (push) Failing after 1m23s
Monorepo Pipeline / 🧪 Test (push) Successful in 41s
Monorepo Pipeline / 🏗️ Build (push) Successful in 2m20s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
🏥 Server Maintenance / 🧹 Prune & Clean (push) Failing after 6s
Analytics Mailer / 📊 Send Analytics Reports (push) Successful in 46s
This commit is contained in:
28
apps/analytics-mailer/src/index.test.ts
Normal file
28
apps/analytics-mailer/src/index.test.ts
Normal file
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -48,20 +48,24 @@ async function getUmamiToken(): Promise<string> {
|
|||||||
|
|
||||||
import https from "https";
|
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";
|
let unit = "day";
|
||||||
if (label === "Dieses Jahr") unit = "month";
|
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}/stats`, { params: { startAt, endAt } }),
|
||||||
api.get(`/websites/${websiteId}/metrics`, { params: { type: "path", limit: 5, 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}/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}/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 stats = statsRes.data;
|
||||||
const topPages = (pagesRes.data || []).map((p: any) => ({ url: p.x, views: p.y || 0 }));
|
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 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 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;
|
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,
|
visitDuration,
|
||||||
topPages,
|
topPages,
|
||||||
topReferrers,
|
topReferrers,
|
||||||
|
topEntryPages,
|
||||||
|
topExitPages,
|
||||||
chartData,
|
chartData,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -130,7 +136,9 @@ async function main() {
|
|||||||
} : undefined,
|
} : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const timeRanges = [getLast7Days(), getThisMonth(), getThisYear()];
|
const timeRanges = runInterval === "weekly"
|
||||||
|
? [getLast7Days(), getThisMonth(), getThisYear()]
|
||||||
|
: [getThisMonth(), getThisYear()];
|
||||||
|
|
||||||
console.log(`Generating reports...`);
|
console.log(`Generating reports...`);
|
||||||
|
|
||||||
@@ -157,8 +165,12 @@ async function main() {
|
|||||||
|
|
||||||
const subject = `Performance-Report - ${client.domain}`;
|
const subject = `Performance-Report - ${client.domain}`;
|
||||||
|
|
||||||
|
const recipients = client.clientEmail.split(',').map(e => e.trim()).filter(e => e.length > 0);
|
||||||
|
|
||||||
if (isDryRun) {
|
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`);
|
const debugPath = path.resolve(process.cwd(), `dry-run-${client.domain}.html`);
|
||||||
await fs.writeFile(debugPath, html);
|
await fs.writeFile(debugPath, html);
|
||||||
console.log(`[DRY-RUN] Saved HTML preview to ${debugPath}`);
|
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.");
|
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) {
|
for (const recipient of recipients) {
|
||||||
await transporter.sendMail({
|
await transporter.sendMail({
|
||||||
from: SMTP_SENDER,
|
from: SMTP_SENDER,
|
||||||
|
|||||||
41
packages/mail/src/templates/AnalyticsTemplate.test.tsx
Normal file
41
packages/mail/src/templates/AnalyticsTemplate.test.tsx
Normal file
@@ -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(
|
||||||
|
<AnalyticsTemplate
|
||||||
|
greeting="Hallo Kunde,"
|
||||||
|
domain="example.com"
|
||||||
|
periods={[period]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,8 @@ export interface PeriodData {
|
|||||||
visitDuration: string;
|
visitDuration: string;
|
||||||
topPages: { url: string; views: number }[];
|
topPages: { url: string; views: number }[];
|
||||||
topReferrers: { url: string; visitors: number }[];
|
topReferrers: { url: string; visitors: number }[];
|
||||||
|
topEntryPages: { url: string; visitors: number }[];
|
||||||
|
topExitPages: { url: string; visitors: number }[];
|
||||||
chartData: { label: string; value: number }[];
|
chartData: { label: string; value: number }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,8 +137,8 @@ export const AnalyticsTemplate = ({
|
|||||||
<tr>
|
<tr>
|
||||||
<td width="48%" valign="top">
|
<td width="48%" valign="top">
|
||||||
<div style={listContainer}>
|
<div style={listContainer}>
|
||||||
<Text style={listTitle}>Seiten</Text>
|
<Text style={listTitle}>Beliebteste Seiten</Text>
|
||||||
<Text style={listSubtitle}>Die beliebtesten Unterseiten sortiert nach Aufrufen.</Text>
|
<Text style={listSubtitle}>Was schauen sich Ihre Besucher am häufigsten an?</Text>
|
||||||
{period.topPages.map((p, idx) => (
|
{period.topPages.map((p, idx) => (
|
||||||
<Row key={idx} style={listRow}>
|
<Row key={idx} style={listRow}>
|
||||||
<Column><Text style={listText}>{p.url}</Text></Column>
|
<Column><Text style={listText}>{p.url}</Text></Column>
|
||||||
@@ -148,8 +150,8 @@ export const AnalyticsTemplate = ({
|
|||||||
<td width="4%" />
|
<td width="4%" />
|
||||||
<td width="48%" valign="top">
|
<td width="48%" valign="top">
|
||||||
<div style={listContainer}>
|
<div style={listContainer}>
|
||||||
<Text style={listTitle}>Quellen</Text>
|
<Text style={listTitle}>Woher kommen Ihre Besucher?</Text>
|
||||||
<Text style={listSubtitle}>Woher Ihre Besucher auf die Website kommen.</Text>
|
<Text style={listSubtitle}>Wie wurden Sie gefunden? (z.B. Google, Direkt)</Text>
|
||||||
{period.topReferrers.map((r, idx) => (
|
{period.topReferrers.map((r, idx) => (
|
||||||
<Row key={idx} style={listRow}>
|
<Row key={idx} style={listRow}>
|
||||||
<Column><Text style={listText}>{r.url || "Direktaufruf"}</Text></Column>
|
<Column><Text style={listText}>{r.url || "Direktaufruf"}</Text></Column>
|
||||||
@@ -161,6 +163,38 @@ export const AnalyticsTemplate = ({
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
<table width="100%" border={0} cellPadding="0" cellSpacing="0" style={tablesContainer}>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td width="48%" valign="top">
|
||||||
|
<div style={listContainer}>
|
||||||
|
<Text style={listTitle}>Häufigste Einstiege</Text>
|
||||||
|
<Text style={listSubtitle}>Auf welchen Seiten starten Ihre Besucher?</Text>
|
||||||
|
{period.topEntryPages?.map((p, idx) => (
|
||||||
|
<Row key={idx} style={listRow}>
|
||||||
|
<Column><Text style={listText}>{p.url}</Text></Column>
|
||||||
|
<Column align="right"><Text style={listTextBold}>{p.visitors.toLocaleString("de-DE")}</Text></Column>
|
||||||
|
</Row>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td width="4%" />
|
||||||
|
<td width="48%" valign="top">
|
||||||
|
<div style={listContainer}>
|
||||||
|
<Text style={listTitle}>Häufigste Ausstiege</Text>
|
||||||
|
<Text style={listSubtitle}>Wo verlassen Besucher Ihre Website am häufigsten?</Text>
|
||||||
|
{period.topExitPages?.map((p, idx) => (
|
||||||
|
<Row key={idx} style={listRow}>
|
||||||
|
<Column><Text style={listText}>{p.url}</Text></Column>
|
||||||
|
<Column align="right"><Text style={listTextBold}>{p.visitors.toLocaleString("de-DE")}</Text></Column>
|
||||||
|
</Row>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
<hr style={divider} />
|
<hr style={divider} />
|
||||||
</Section>
|
</Section>
|
||||||
))}
|
))}
|
||||||
@@ -332,6 +366,7 @@ const badgePositive = {
|
|||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#16a34a",
|
color: "#16a34a",
|
||||||
margin: "4px 0 0 0",
|
margin: "4px 0 0 0",
|
||||||
|
lineHeight: "1.5",
|
||||||
};
|
};
|
||||||
|
|
||||||
const badgeNeutral = {
|
const badgeNeutral = {
|
||||||
@@ -339,4 +374,5 @@ const badgeNeutral = {
|
|||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#6b7280",
|
color: "#6b7280",
|
||||||
margin: "4px 0 0 0",
|
margin: "4px 0 0 0",
|
||||||
|
lineHeight: "1.5",
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user