Skip to content

Commit 7c2c5e1

Browse files
ralyodioclaude
andcommitted
feat(reports): weekly/monthly performance email digest (on by default)
Adds a per-user setting for receiving a combined audit + Autoblog performance digest by email. Three cadences (off / weekly / monthly), defaulting to weekly. Sent on Monday 09:00 (weekly) or 1st-of-month 09:00 (monthly) in the user's chosen IANA timezone. Schema: profiles.perf_report_cadence + .timezone + .perf_report_last_sent_at. Existing users are grandfathered onto weekly by the column default. Cron: hourly tick at /api/cron/perf-reports walks profiles where cadence <> 'off' and checks isReportDue(cadence, tz, now, last_sent) — only users whose local 09:00 matches the current top-of-hour are eligible, which naturally spreads load across the day. Capped at 50 sends per tick. Aggregation: per-project latest score + score delta vs window-start, audits-in-window count; plus Autoblog summary (published, failed, queued, next publish) when the user has a site enrolled. Manual trigger: crawlproof sweep --target=perf-reports. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 529459c commit 7c2c5e1

10 files changed

Lines changed: 851 additions & 13 deletions

File tree

‎app/(app)/settings/form.tsx‎

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,47 @@
33
import { useState, useTransition } from "react";
44
import { saveSettings } from "@/app/actions/settings";
55

6+
type Cadence = "off" | "weekly" | "monthly";
7+
68
export function SettingsForm({
79
displayName,
810
retainRawHtml,
11+
perfReportCadence,
12+
timezone,
13+
commonTimezones,
14+
allTimezones,
915
}: {
1016
displayName: string;
1117
retainRawHtml: boolean;
18+
perfReportCadence: Cadence;
19+
timezone: string;
20+
commonTimezones: string[];
21+
allTimezones: string[];
1222
}) {
1323
const [name, setName] = useState(displayName);
1424
const [retain, setRetain] = useState(retainRawHtml);
25+
const [cadence, setCadence] = useState<Cadence>(perfReportCadence);
26+
const [tz, setTz] = useState(timezone);
1527
const [saved, setSaved] = useState(false);
28+
const [error, setError] = useState<string | null>(null);
1629
const [pending, start] = useTransition();
1730

1831
return (
1932
<form
20-
className="card space-y-4 p-5"
33+
className="card space-y-5 p-5"
2134
onSubmit={(e) => {
2235
e.preventDefault();
2336
setSaved(false);
37+
setError(null);
2438
start(async () => {
25-
await saveSettings({ displayName: name, retainRawHtml: retain });
26-
setSaved(true);
39+
const r = await saveSettings({
40+
displayName: name,
41+
retainRawHtml: retain,
42+
perfReportCadence: cadence,
43+
timezone: tz,
44+
});
45+
if (r.ok) setSaved(true);
46+
else setError(r.error ?? "Could not save.");
2747
});
2848
}}
2949
>
@@ -35,6 +55,7 @@ export function SettingsForm({
3555
onChange={(e) => setName(e.target.value)}
3656
/>
3757
</div>
58+
3859
<label className="flex items-center gap-2 text-sm">
3960
<input
4061
type="checkbox"
@@ -43,7 +64,58 @@ export function SettingsForm({
4364
/>
4465
Retain raw HTML from audits (uncheck to keep only structured findings)
4566
</label>
67+
68+
<fieldset className="space-y-3 border-t border-[var(--color-border)] pt-4">
69+
<legend className="text-sm font-semibold">Email reports</legend>
70+
<p className="text-xs text-[var(--color-muted)]">
71+
A combined digest of your audit scores and Autoblog activity.
72+
Sent on Monday 09:00 (weekly) or the 1st of the month at 09:00
73+
(monthly), in the timezone below.
74+
</p>
75+
76+
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
77+
<div>
78+
<label className="block text-sm font-medium">Cadence</label>
79+
<select
80+
className="input mt-1"
81+
value={cadence}
82+
onChange={(e) => setCadence(e.target.value as Cadence)}
83+
>
84+
<option value="off">Off — no email reports</option>
85+
<option value="weekly">Weekly</option>
86+
<option value="monthly">Monthly</option>
87+
</select>
88+
</div>
89+
90+
<div>
91+
<label className="block text-sm font-medium">Timezone</label>
92+
<select
93+
className="input mt-1"
94+
value={tz}
95+
onChange={(e) => setTz(e.target.value)}
96+
disabled={cadence === "off"}
97+
>
98+
<optgroup label="Common">
99+
{commonTimezones.map((z) => (
100+
<option key={z} value={z}>
101+
{z}
102+
</option>
103+
))}
104+
</optgroup>
105+
<optgroup label="All">
106+
{allTimezones.map((z) => (
107+
<option key={z} value={z}>
108+
{z}
109+
</option>
110+
))}
111+
</optgroup>
112+
</select>
113+
</div>
114+
</div>
115+
</fieldset>
116+
46117
{saved && <p className="text-sm text-[var(--color-pass)]">Saved.</p>}
118+
{error && <p className="text-sm text-[var(--color-fail)]">{error}</p>}
47119
<button type="submit" className="btn btn-primary" disabled={pending}>
48120
{pending ? "Saving…" : "Save"}
49121
</button>

‎app/(app)/settings/page.tsx‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import Link from "next/link";
22
import { createClient } from "@/lib/supabase/server";
33
import { SettingsForm } from "./form";
4+
import { listTimezones } from "@/lib/timezones";
45

56
export const metadata = { title: "Settings" };
67

@@ -11,9 +12,12 @@ export default async function SettingsPage() {
1112
} = await supabase.auth.getUser();
1213
const { data: profile } = await supabase
1314
.from("profiles")
14-
.select("display_name, email, credits_balance, retain_raw_html")
15+
.select(
16+
"display_name, email, credits_balance, retain_raw_html, perf_report_cadence, timezone",
17+
)
1518
.eq("id", user!.id)
1619
.maybeSingle();
20+
const { common, all } = listTimezones();
1721

1822
return (
1923
<div className="space-y-6">
@@ -32,6 +36,15 @@ export default async function SettingsPage() {
3236
<SettingsForm
3337
displayName={profile?.display_name ?? ""}
3438
retainRawHtml={!!profile?.retain_raw_html}
39+
perfReportCadence={
40+
(profile?.perf_report_cadence ?? "weekly") as
41+
| "off"
42+
| "weekly"
43+
| "monthly"
44+
}
45+
timezone={profile?.timezone ?? "UTC"}
46+
commonTimezones={common}
47+
allTimezones={all}
3548
/>
3649
</div>
3750
);

‎app/actions/settings.ts‎

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,38 @@
11
"use server";
22

33
import { createClient } from "@/lib/supabase/server";
4+
import { isValidTimezone } from "@/lib/timezones";
5+
6+
const ALLOWED_CADENCES = ["off", "weekly", "monthly"] as const;
7+
type Cadence = (typeof ALLOWED_CADENCES)[number];
48

59
export async function saveSettings(input: {
610
displayName: string;
711
retainRawHtml: boolean;
8-
}): Promise<{ ok: boolean }> {
12+
perfReportCadence: Cadence;
13+
timezone: string;
14+
}): Promise<{ ok: boolean; error?: string }> {
915
const supabase = await createClient();
1016
const {
1117
data: { user },
1218
} = await supabase.auth.getUser();
13-
if (!user) return { ok: false };
19+
if (!user) return { ok: false, error: "unauthorized" };
20+
21+
if (!ALLOWED_CADENCES.includes(input.perfReportCadence)) {
22+
return { ok: false, error: "invalid cadence" };
23+
}
24+
if (!isValidTimezone(input.timezone)) {
25+
return { ok: false, error: "invalid timezone" };
26+
}
27+
1428
const { error } = await supabase
1529
.from("profiles")
16-
.update({ display_name: input.displayName, retain_raw_html: input.retainRawHtml })
30+
.update({
31+
display_name: input.displayName,
32+
retain_raw_html: input.retainRawHtml,
33+
perf_report_cadence: input.perfReportCadence,
34+
timezone: input.timezone,
35+
})
1736
.eq("id", user.id);
18-
return { ok: !error };
37+
return error ? { ok: false, error: error.message } : { ok: true };
1938
}

‎app/api/cron/perf-reports/route.ts‎

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { NextResponse } from "next/server";
2+
import { serviceClient } from "@/lib/supabase/service";
3+
import { env } from "@/lib/env";
4+
import {
5+
aggregatePerfReport,
6+
isReportDue,
7+
sendPerfReportEmail,
8+
type Cadence,
9+
} from "@/lib/perfReport";
10+
11+
export const runtime = "nodejs";
12+
13+
// Bound per-tick send count so a slow Resend response can't tie up the
14+
// request past the cron's expectation. Most ticks send 0 messages
15+
// (only the user's local Mon 09:00 / 1st 09:00 tick is eligible).
16+
const MAX_PER_TICK = 50;
17+
18+
type ProfileRow = {
19+
id: string;
20+
email: string;
21+
perf_report_cadence: "off" | "weekly" | "monthly";
22+
timezone: string;
23+
perf_report_last_sent_at: string | null;
24+
};
25+
26+
export async function GET(req: Request) {
27+
return POST(req);
28+
}
29+
30+
export async function POST(req: Request) {
31+
const incoming =
32+
req.headers.get("x-cron-secret") ??
33+
req.headers.get("authorization")?.replace(/^Bearer\s+/i, "");
34+
if (incoming !== env.cronSecret) {
35+
return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 });
36+
}
37+
38+
const svc = serviceClient();
39+
const now = new Date();
40+
41+
const { data: rows, error } = await svc
42+
.from("profiles")
43+
.select(
44+
"id, email, perf_report_cadence, timezone, perf_report_last_sent_at",
45+
)
46+
.neq("perf_report_cadence", "off")
47+
.not("email", "is", null)
48+
.limit(1000);
49+
if (error) {
50+
return NextResponse.json(
51+
{ ok: false, error: error.message },
52+
{ status: 500 },
53+
);
54+
}
55+
56+
let sent = 0;
57+
let skipped_not_due = 0;
58+
let failed = 0;
59+
for (const r of (rows ?? []) as ProfileRow[]) {
60+
if (sent >= MAX_PER_TICK) break;
61+
62+
const lastSentAt = r.perf_report_last_sent_at
63+
? new Date(r.perf_report_last_sent_at)
64+
: null;
65+
const due = isReportDue(
66+
r.perf_report_cadence as Cadence,
67+
r.timezone ?? "UTC",
68+
now,
69+
lastSentAt,
70+
);
71+
if (!due) {
72+
skipped_not_due++;
73+
continue;
74+
}
75+
76+
try {
77+
const report = await aggregatePerfReport(
78+
svc,
79+
r.id,
80+
r.perf_report_cadence as Cadence,
81+
now,
82+
);
83+
if (!report) {
84+
skipped_not_due++;
85+
continue;
86+
}
87+
const result = await sendPerfReportEmail(r.email, report);
88+
if (!result.ok) {
89+
failed++;
90+
console.warn(`[perf-reports] send failed user=${r.id}: ${result.error}`);
91+
continue;
92+
}
93+
// Claim AFTER successful send so a failure leaves us eligible to
94+
// retry on the next hourly tick (which only re-eligibilizes if
95+
// the user's local 09:00 falls again, i.e. typically next week).
96+
// For weekly that's the right behavior; a one-tick miss just
97+
// skips this week.
98+
await svc
99+
.from("profiles")
100+
.update({ perf_report_last_sent_at: now.toISOString() })
101+
.eq("id", r.id);
102+
sent++;
103+
} catch (err) {
104+
failed++;
105+
console.error(`[perf-reports] crashed user=${r.id}`, err);
106+
}
107+
}
108+
109+
return NextResponse.json({
110+
ok: true,
111+
candidates: rows?.length ?? 0,
112+
sent,
113+
skipped_not_due,
114+
failed,
115+
});
116+
}

‎cli/index.ts‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -111,9 +111,16 @@ async function cmdSweep(args: Args): Promise<number> {
111111
return 2;
112112
}
113113
const target = (args.flags.target as string | undefined) ?? "scheduled-audits";
114-
const path = target === "autoblog" ? "lx-autoblog" : "scheduled-audits";
115-
if (target !== "autoblog" && target !== "scheduled-audits") {
116-
console.error(`unknown --target: ${target} (expected: scheduled-audits | autoblog)`);
114+
const ALLOWED: Record<string, string> = {
115+
"scheduled-audits": "scheduled-audits",
116+
"autoblog": "lx-autoblog",
117+
"perf-reports": "perf-reports",
118+
};
119+
const path = ALLOWED[target];
120+
if (!path) {
121+
console.error(
122+
`unknown --target: ${target} (expected: scheduled-audits | autoblog | perf-reports)`,
123+
);
117124
return 2;
118125
}
119126
const base = process.env.CRAWLPROOF_SITE_URL ?? "https://crawlproof.com";
@@ -150,10 +157,11 @@ COMMANDS
150157
Fetch a public report by share-token from production.
151158
Override with CRAWLPROOF_SITE_URL.
152159
153-
sweep [--target=scheduled-audits|autoblog]
160+
sweep [--target=scheduled-audits|autoblog|perf-reports]
154161
Force a cron sweep to run now. --target=scheduled-audits (default)
155162
fires /api/cron/scheduled-audits; --target=autoblog fires
156-
/api/cron/lx-autoblog. Useful for testing without waiting for the
163+
/api/cron/lx-autoblog; --target=perf-reports fires
164+
/api/cron/perf-reports. Useful for testing without waiting for the
157165
hourly pg_cron tick. Requires CRON_SECRET. Override host with
158166
CRAWLPROOF_SITE_URL.
159167

0 commit comments

Comments
 (0)