diff --git a/app/(app)/dashboard/settings/form.tsx b/app/(app)/dashboard/settings/form.tsx index 54efe34..9e670b0 100644 --- a/app/(app)/dashboard/settings/form.tsx +++ b/app/(app)/dashboard/settings/form.tsx @@ -3,7 +3,7 @@ import { useState, useTransition } from "react"; import { saveSettings } from "@/app/actions/settings"; -type Cadence = "off" | "weekly" | "monthly"; +type Cadence = "off" | "daily" | "weekly" | "monthly"; export function SettingsForm({ displayName, @@ -69,8 +69,9 @@ export function SettingsForm({ Email reports

A combined digest of your audit scores and Autoblog activity. - Sent on Monday 09:00 (weekly) or the 1st of the month at 09:00 - (monthly), in the timezone below. + Sent at 09:00 in the timezone below — every day (nightly), on + Monday (weekly), or on the 1st (monthly). The nightly one leads + with traffic over the last 24 hours, busiest property first.

@@ -82,6 +83,7 @@ export function SettingsForm({ onChange={(e) => setCadence(e.target.value as Cadence)} > + diff --git a/app/(app)/dashboard/settings/page.tsx b/app/(app)/dashboard/settings/page.tsx index 24dc38b..4a34b9d 100644 --- a/app/(app)/dashboard/settings/page.tsx +++ b/app/(app)/dashboard/settings/page.tsx @@ -61,8 +61,9 @@ export default async function SettingsPage() { displayName={profile?.display_name ?? ""} retainRawHtml={!!profile?.retain_raw_html} perfReportCadence={ - (profile?.perf_report_cadence ?? "weekly") as + (profile?.perf_report_cadence ?? "daily") as | "off" + | "daily" | "weekly" | "monthly" } diff --git a/app/actions/settings.ts b/app/actions/settings.ts index bf26f4f..f80bac6 100644 --- a/app/actions/settings.ts +++ b/app/actions/settings.ts @@ -3,7 +3,7 @@ import { createClient } from "@/lib/supabase/server"; import { isValidTimezone } from "@/lib/timezones"; -const ALLOWED_CADENCES = ["off", "weekly", "monthly"] as const; +const ALLOWED_CADENCES = ["off", "daily", "weekly", "monthly"] as const; type Cadence = (typeof ALLOWED_CADENCES)[number]; export async function saveSettings(input: { diff --git a/lib/perfReport.ts b/lib/perfReport.ts index 204772d..0eae77c 100644 --- a/lib/perfReport.ts +++ b/lib/perfReport.ts @@ -10,7 +10,7 @@ import { env } from "./env"; const DAY_MS = 24 * 60 * 60 * 1000; -export type Cadence = "weekly" | "monthly"; +export type Cadence = "daily" | "weekly" | "monthly"; export type ProjectRow = { id: string; @@ -29,6 +29,27 @@ export type AutoblogSummary = { nextPublishAt: string | null; } | null; +/** + * A property's last-24h traffic, split the way the tracker splits it: a + * bucket starting `bot:` is a crawler, everything else is a human. AI + * referrals count as humans on purpose — someone asked a model and came. + */ +export type TrafficRow = { + projectId: string; + name: string; + humans: number; + bots: number; + total: number; +}; + +export type TrafficSummary = { + humans: number; + bots: number; + total: number; + /** Most to least traffic. */ + properties: TrafficRow[]; +}; + export type PerfReport = { userId: string; userEmail: string; @@ -38,9 +59,12 @@ export type PerfReport = { windowEnd: Date; projects: ProjectRow[]; autoblog: AutoblogSummary; + /** Present on every cadence; it is the point of the nightly one. */ + traffic: TrafficSummary; }; function windowDays(cadence: Cadence): number { + if (cadence === "daily") return 1; return cadence === "weekly" ? 7 : 30; } @@ -96,6 +120,15 @@ export function isReportDue( } if (local.hour !== 9) return false; + if (cadence === "daily") { + // One tick a day is eligible, and 20h of dedupe covers a DST shift + // without ever letting two sends land in the same local day. + if (lastSentAt && now.getTime() - lastSentAt.getTime() < 20 * 60 * 60 * 1000) { + return false; + } + return true; + } + if (cadence === "weekly") { if (local.weekday !== 1) return false; // Mon if ( @@ -121,6 +154,64 @@ export function isReportDue( return false; } +/** + * Traffic per property over the window, most to least. + * + * `tracker_daily_stats` is already bucketed per day, so this is a read of + * whole days rather than a rolling window — a nightly report at 09:00 local + * wants "yesterday and today so far", which is what `days` back from today + * gives. Counting is by bucket because that is the only place the human/bot + * split exists: `tracker_event_daily_stats` has no bucket column and its + * pageview counts include crawlers. + */ +async function aggregateTraffic( + supabase: SupabaseClient, + projects: { id: string; name: string }[], + days: number, + now: Date, +): Promise { + const empty: TrafficSummary = { humans: 0, bots: 0, total: 0, properties: [] }; + if (!projects.length) return empty; + + const since = new Date(now.getTime() - days * DAY_MS).toISOString().slice(0, 10); + const { data } = await supabase + .from("tracker_daily_stats") + .select("project_id, bucket, count") + .in( + "project_id", + projects.map((project) => project.id), + ) + .gte("day", since); + + const byProject = new Map(); + for (const row of (data ?? []) as { project_id: string; bucket: string; count: number }[]) { + const tally = byProject.get(row.project_id) ?? { humans: 0, bots: 0 }; + if (row.bucket?.startsWith("bot:")) tally.bots += row.count ?? 0; + else tally.humans += row.count ?? 0; + byProject.set(row.project_id, tally); + } + + const properties: TrafficRow[] = projects + .map((project) => { + const tally = byProject.get(project.id) ?? { humans: 0, bots: 0 }; + return { + projectId: project.id, + name: project.name, + humans: tally.humans, + bots: tally.bots, + total: tally.humans + tally.bots, + }; + }) + .sort((a, b) => b.total - a.total || a.name.localeCompare(b.name)); + + return { + humans: properties.reduce((sum, row) => sum + row.humans, 0), + bots: properties.reduce((sum, row) => sum + row.bots, 0), + total: properties.reduce((sum, row) => sum + row.total, 0), + properties, + }; +} + export async function aggregatePerfReport( supabase: SupabaseClient, userId: string, @@ -228,6 +319,12 @@ export async function aggregatePerfReport( windowEnd: now, projects: projectRows, autoblog, + traffic: await aggregateTraffic( + supabase, + (projects ?? []).map((project: any) => ({ id: project.id, name: project.name })), + days, + now, + ), }; } @@ -254,11 +351,15 @@ export function renderPerfReportEmail(r: PerfReport): { subject: string; html: string; } { - const window = r.cadence === "weekly" ? "this week" : "this month"; + const window = + r.cadence === "daily" ? "today" : r.cadence === "weekly" ? "this week" : "this month"; + // The nightly one leads with the number the reader actually wants. const subject = - r.projects.length === 0 && !r.autoblog - ? `Your CrawlProof ${r.cadence} digest` - : `CrawlProof ${r.cadence} digest — ${r.projects.length} project${r.projects.length === 1 ? "" : "s"}${r.autoblog ? " + Autoblog" : ""}`; + r.cadence === "daily" + ? `CrawlProof nightly — ${fmt(r.traffic.humans)} human visit${r.traffic.humans === 1 ? "" : "s"}, ${fmt(r.traffic.bots)} bot` + : r.projects.length === 0 && !r.autoblog + ? `Your CrawlProof ${r.cadence} digest` + : `CrawlProof ${r.cadence} digest — ${r.projects.length} project${r.projects.length === 1 ? "" : "s"}${r.autoblog ? " + Autoblog" : ""}`; const projectRowsHtml = r.projects.length ? r.projects @@ -310,10 +411,52 @@ export function renderPerfReportEmail(r: PerfReport): { ` : ""; + const trafficRowsHtml = r.traffic.properties.length + ? r.traffic.properties + .map((row) => { + const share = row.total ? Math.round((row.bots / row.total) * 100) : 0; + return ` + + + + + + +
+
${escapeHtml(row.name)}
+
${fmt(row.humans)} human · ${fmt(row.bots)} bot (${share}%)
+
+ ${fmt(row.total)} +
+ + `; + }) + .join("") + : `No traffic recorded. Is the tracker tag on the site?`; + + const trafficBlock = ` + + + + + + + +
Traffic · last ${r.cadence === "daily" ? "24 hours" : window.replace(/^this /, "")}
+ + + ${statCell("Humans", r.traffic.humans, "#6ee7b7")} + ${statCell("Bots", r.traffic.bots)} + ${statCell("All hits", r.traffic.total)} + +
+
${trafficRowsHtml}
`; + const innerHtml = `

Your ${r.cadence} digest

-

${window.replace(/^this /, "")} of audit + Autoblog activity.

+

Traffic, audits and Autoblog for ${window.replace(/^this /, "")}, busiest property first.

+ ${trafficBlock} ${projectRowsHtml}
@@ -330,6 +473,10 @@ export function renderPerfReportEmail(r: PerfReport): { return { subject, html }; } +function fmt(n: number): string { + return n.toLocaleString("en-US"); +} + function statCell(label: string, value: number, accent?: string): string { return `
${escapeHtml(label)}
diff --git a/supabase/migrations/20260906100000_perf_report_daily.sql b/supabase/migrations/20260906100000_perf_report_daily.sql new file mode 100644 index 0000000..038ec6b --- /dev/null +++ b/supabase/migrations/20260906100000_perf_report_daily.sql @@ -0,0 +1,26 @@ +-- Nightly performance reports. +-- +-- The cadence column already gates who gets a digest and how often; this +-- adds 'daily' to it and makes it the default, so a new account gets the +-- nightly traffic summary without having to find the setting. +-- +-- Existing rows keep whatever their owner chose. A default only applies to +-- inserts, and silently moving somebody from weekly to nightly would be +-- changing a preference they set, not honouring one. +-- +-- Apply one file at a time via the Supabase MCP's apply_migration — prod's +-- migration history has diverged from this directory, so `supabase db push` +-- would try to replay files prod already has. + +alter table public.profiles + drop constraint if exists profiles_perf_report_cadence_check; + +alter table public.profiles + add constraint profiles_perf_report_cadence_check + check (perf_report_cadence in ('off', 'daily', 'weekly', 'monthly')); + +alter table public.profiles + alter column perf_report_cadence set default 'daily'; + +-- The hourly cron reads `where perf_report_cadence <> 'off'`; the existing +-- index still covers it, and daily simply makes more rows eligible per tick. diff --git a/tests/perf-report-daily.test.ts b/tests/perf-report-daily.test.ts new file mode 100644 index 0000000..dfd4047 --- /dev/null +++ b/tests/perf-report-daily.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { isReportDue, renderPerfReportEmail, type PerfReport } from "@/lib/perfReport"; + +// 09:00 in the timezone under test is the only eligible hour. +const at = (iso: string) => new Date(iso); + +describe("isReportDue — daily", () => { + it("fires at 09:00 local on any day of the week", () => { + // A Wednesday and a Sunday; neither is special for the daily cadence. + expect(isReportDue("daily", "UTC", at("2026-09-02T09:00:00Z"), null)).toBe(true); + expect(isReportDue("daily", "UTC", at("2026-09-06T09:30:00Z"), null)).toBe(true); + }); + + it("ignores every other hour", () => { + expect(isReportDue("daily", "UTC", at("2026-09-06T08:59:00Z"), null)).toBe(false); + expect(isReportDue("daily", "UTC", at("2026-09-06T10:00:00Z"), null)).toBe(false); + }); + + it("is local, not UTC", () => { + // 09:00 Los Angeles is 16:00 UTC. + const utcMorning = at("2026-09-06T09:00:00Z"); + expect(isReportDue("daily", "America/Los_Angeles", utcMorning, null)).toBe(false); + expect(isReportDue("daily", "America/Los_Angeles", at("2026-09-06T16:00:00Z"), null)).toBe(true); + }); + + it("does not send twice in the same local day", () => { + const now = at("2026-09-06T09:00:00Z"); + const anHourAgo = at("2026-09-06T08:00:00Z"); + expect(isReportDue("daily", "UTC", now, anHourAgo)).toBe(false); + }); + + it("sends again the next day, even when the clock shifted", () => { + // 23h later is a different local day but under the 24h a naive gate + // would use; the 20h window is what makes a DST day still send. + const now = at("2026-09-07T09:00:00Z"); + const yesterday = at("2026-09-06T09:00:00Z"); + expect(isReportDue("daily", "UTC", now, yesterday)).toBe(true); + }); + + it("leaves the other cadences alone", () => { + // 2026-09-06 is a Sunday, so weekly (Monday) must not fire. + expect(isReportDue("weekly", "UTC", at("2026-09-06T09:00:00Z"), null)).toBe(false); + expect(isReportDue("monthly", "UTC", at("2026-09-06T09:00:00Z"), null)).toBe(false); + expect(isReportDue("weekly", "UTC", at("2026-09-07T09:00:00Z"), null)).toBe(true); + }); +}); + +const report = (overrides: Partial = {}): PerfReport => ({ + userId: "u1", + userEmail: "a@b.com", + userDisplayName: "A", + cadence: "daily", + windowStart: at("2026-09-05T09:00:00Z"), + windowEnd: at("2026-09-06T09:00:00Z"), + projects: [], + autoblog: null, + traffic: { + humans: 1234, + bots: 91011, + total: 92245, + properties: [ + { projectId: "p1", name: "genrewatch.com", humans: 27, bots: 90000, total: 90027 }, + { projectId: "p2", name: "chovy blog", humans: 1207, bots: 1011, total: 2218 }, + ], + }, + ...overrides, +}); + +describe("the nightly email", () => { + it("leads its subject with humans, and formats big numbers", () => { + const { subject, html } = renderPerfReportEmail(report()); + expect(subject).toBe("CrawlProof nightly — 1,234 human visits, 91,011 bot"); + expect(html).toContain("genrewatch.com"); + expect(html).toContain("90,027"); + }); + + it("lists properties in the order the aggregator gave them", () => { + const { html } = renderPerfReportEmail(report()); + expect(html.indexOf("genrewatch.com")).toBeLessThan(html.indexOf("chovy blog")); + }); + + it("shows each property's bot share", () => { + const { html } = renderPerfReportEmail(report()); + expect(html).toContain("27 human · 90,000 bot (100%)"); + }); + + it("says so plainly when nothing was recorded", () => { + const { html } = renderPerfReportEmail( + report({ traffic: { humans: 0, bots: 0, total: 0, properties: [] } }), + ); + expect(html).toContain("No traffic recorded"); + }); + + it("keeps the weekly subject unchanged", () => { + const { subject } = renderPerfReportEmail(report({ cadence: "weekly" })); + expect(subject).toContain("weekly digest"); + }); +});