From fb8489651026d434df534e46de9cc38e3f53ced6 Mon Sep 17 00:00:00 2001 From: Mehar Khanna Date: Fri, 13 Nov 2026 09:21:47 -0500 Subject: [PATCH 1/3] Report email: about the account's own sites. Before any app sends data, name it and say what comes next A paying account whose only app has never sent a page load got a report that was mostly generic (a genclass suggest pitch and three showcase sites). Until GenClass has measured one of the account's sites or holds a genclass suggest answer, the headline names the apps that send nothing yet, the subject says finish setting up (not the account's own sign-in as important), and one card says what the next reports hold for those apps, in place of the generic ideas and showcase. Co-Authored-By: Claude Opus 5.5 --- telemetry-worker/src/report-email.ts | 42 ++++++++++++++++++++++------ telemetry-worker/test/report.test.ts | 19 +++++++++++++ 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/telemetry-worker/src/report-email.ts b/telemetry-worker/src/report-email.ts index b5735b98..248c3def 100644 --- a/telemetry-worker/src/report-email.ts +++ b/telemetry-worker/src/report-email.ts @@ -11,8 +11,10 @@ // links a report signs) is a prompt printed, and then only a short one; the plain-text body always has them all. // - The sections and their order: the headline, the summary, what needs doing (critical, then important), setup when // an app is not connected, the apps' numbers, code changes, visitors, security, setup otherwise, worth a look, -// suggestions, website design, then how to change or stop these emails. An app on Free is counted, not listed: -// its problems' count shows with Pro's box, never its pages or prompts. +// suggestions, website design, then how to change or stop these emails. Every section is about the account's own +// sites: while GenClass has measured none of them and has no genclass suggest answer, the headline names the apps +// that send nothing yet, and one card says what the next reports hold for them in place of generic ideas. +// - An app on Free is counted, not listed: its problems' count shows with Pro's box, never its pages or prompts. import { esc } from "./escape.js"; import { DESIGN_CHECKS, EVERY_WORDS, PRIORITY_WORDS, showcasePrompt, type AppReport, type Every, type Item, type Priority, type Report } from "./report.js"; @@ -65,6 +67,12 @@ const counts = (items: Item[]): Record => { const counted = (r: Report): Item[] => r.items.filter((i) => i.section !== "setup" || i.p === 0); const problemItems = (r: Report) => r.items.filter((i) => i.section === "problems"); const measured = (r: Report): AppReport[] => r.apps.filter((x) => !!x.lastEvent && !x.demo); +/** Whether GenClass knows anything about the account's sites: a measured app, or a genclass suggest answer. */ +const knowsSites = (r: Report): boolean => measured(r).length > 0 || r.suggestions.length + r.suggestionsHidden + r.designs.length + r.designsHidden > 0; +/** "Glyco site" or "3 apps": what the report is about, by name when there is one. */ +const appsName = (r: Report): string => (r.apps.length === 1 ? r.apps[0]!.name : plural(r.apps.length, "app", "apps")); +/** The headline when no app has measured data: the apps by name. */ +const notSending = (r: Report): string => (r.apps.length === 1 ? `${r.apps[0]!.name} isn't sending data yet` : `${plural(r.apps.length, "app isn't", "apps aren't")} sending data yet`); /** The subject line. Pure. */ export function subjectOf(r: Report): string { @@ -75,7 +83,7 @@ export function subjectOf(r: Report): string { const name = r.apps.length === 1 ? r.apps[0]!.name : `${r.apps.length} apps`; const adj = ADJ[r.every] || "daily"; const lead = `${test}GenClass ${adj} report, ${name}`; - if (c[0] || c[1]) return `${lead}: ${[c[0] ? `${c[0]} critical` : "", c[1] ? `${c[1]} important` : ""].filter(Boolean).join(", ")}`.slice(0, 200); + if (c[0] || (c[1] && measured(r).length)) return `${lead}: ${[c[0] ? `${c[0]} critical` : "", c[1] ? `${c[1]} important` : ""].filter(Boolean).join(", ")}`.slice(0, 200); if (!measured(r).length) return `${lead}: finish setting up`; return `${lead}: all clear`; } @@ -85,8 +93,8 @@ function headlineOf(r: Report): { text: string; tone: Priority | null } { const c = counts(counted(r)); if (r.kind === "alert") return { text: r.items.length === 1 ? "Something critical needs you" : `${fmt(r.items.length)} critical problems need you`, tone: 0 }; if (c[0]) return { text: `${plural(c[0], "critical problem", "critical problems")}${c[1] ? ` and ${plural(c[1], "important one", "important ones")}` : ""}`, tone: 0 }; + if (!measured(r).length) return { text: notSending(r), tone: 1 }; if (c[1]) return { text: `${plural(c[1], "important thing", "important things")} to look at`, tone: 1 }; - if (!measured(r).length) return { text: "Finish setting up GenClass", tone: null }; return { text: "All clear on your apps", tone: null }; } @@ -384,6 +392,18 @@ function designCard(r: Report): string { return card(out.join(""), "Upgrade your website design"); } +/** + * In place of the suggestions and the design ideas when GenClass knows nothing about the account's sites yet (no + * measured app, no genclass suggest answer): what the next reports hold for them, by name, and how to get ideas now. + */ +function nextCard(r: Report): string { + const name = appsName(r); + return card( + `${p(`Once ${esc(name)} ${r.apps.length === 1 ? "sends its" : "send their"} first page load, this report lists what breaks there with the pages it happens on, ${r.apps.some((x) => x.analytics) ? "who visits and what they click, " : ""}and contrast and layout problems measured on your visitors' screens.`, "margin:8px 0 0")}${p(`For ideas picked for ${esc(name)} today, run ${code("npx genclass suggest")} in ${r.apps.length === 1 ? "its" : "an app's"} folder: GenClass checks the repository and the live site, then suggests what to fix and website designs from sites in your field.`, "margin:8px 0 0")}`, + "What this report will show", + ); +} + function upgradeCard(r: Report): string { const u = r.upgrade; if (!u) return ""; @@ -452,8 +472,8 @@ export function htmlOf(r: Report): string { securityCard(r), setupFirst ? "" : setupCard(r), laterCard(r), - suggestionsCard(r), - designCard(r), + knowsSites(r) ? suggestionsCard(r) : "", + knowsSites(r) ? designCard(r) : nextCard(r), upgradeCard(r), ].join(""); return `${esc(subjectOf(r))}${DARK} @@ -513,9 +533,13 @@ export function textOf(r: Report): string { const p2 = problemItems(r).filter((i) => i.p === 2); if (p2.length) out.push("WORTH A LOOK", ...p2.flatMap(textItem)); if (r.suggestions.length) out.push("SUGGESTIONS", ...r.suggestions.flatMap((s) => [`- ${s.title}${s.why ? `: ${s.why}` : ""}`, ...(s.prompt ? [` Prompt: ${s.prompt}`] : [])]), ""); - out.push("UPGRADE YOUR WEBSITE DESIGN"); - for (const d of r.designs) out.push(`- ${d.title} (inspired by ${d.inspired_by})`, ` Prompt: ${d.prompt}`); - for (const s of r.showcase) out.push(`- ${s.name} ${s.site}${s.three ? " (3D)" : ""}: ${s.take}`); + if (knowsSites(r)) { + out.push("UPGRADE YOUR WEBSITE DESIGN"); + for (const d of r.designs) out.push(`- ${d.title} (inspired by ${d.inspired_by})`, ` Prompt: ${d.prompt}`); + for (const s of r.showcase) out.push(`- ${s.name} ${s.site}${s.three ? " (3D)" : ""}: ${s.take}`); + } else { + out.push("WHAT THIS REPORT WILL SHOW", `Once ${appsName(r)} sends its first page load: what breaks there and on which pages, and contrast and layout problems on your visitors' screens.`, "For ideas now, run npx genclass suggest in the app's folder."); + } out.push(""); if (r.upgrade) out.push(`${r.upgrade.plan}: everything in this report, ${r.upgrade.price}${r.upgrade.trialDays ? `, ${r.upgrade.trialDays} days free to start` : ""}: ${r.upgrade.url}`, ""); } diff --git a/telemetry-worker/test/report.test.ts b/telemetry-worker/test/report.test.ts index 2716d6b9..66c89702 100644 --- a/telemetry-worker/test/report.test.ts +++ b/telemetry-worker/test/report.test.ts @@ -263,6 +263,25 @@ describe("reports: one account's report", () => { expect(html.length).toBeLessThan(102_000); }); + it("before an app sends anything, the report is about that app: no generic ideas, what the next reports hold", async () => { + const { env, sql } = world(); + const { cookie, account } = await signedIn(env); + grantPro(sql, (await createApp(env, cookie, "Glyco site")).id); + const r = (await buildReport(env, accountRow(sql, account.id), { kind: "report", now: now(), model: false, fetch: noModel }))!.report; + const { subject, html, text } = renderReport(r); + expect(subject).toBe("GenClass daily report, Glyco site: finish setting up"); + expect(html).toContain("Glyco site isn't sending data yet"); + expect(html).toContain("What this report will show"); + expect(html).toContain("Once Glyco site sends its first page load"); + for (const generic of ["Sites to learn from today", "Upgrade your website design", "Suggestions for your app"]) expect(html).not.toContain(generic); + expect(text).not.toContain("UPGRADE YOUR WEBSITE DESIGN"); + // once it has measured data, the ideas and the showcase come back + await sendBatch(env, (sql.db.prepare("SELECT token FROM projects WHERE name = 'Glyco site'").get() as { token: string }).token, [page("/")]); + const later = renderReport((await buildReport(env, accountRow(sql, account.id), { kind: "report", now: now(), model: false, fetch: noModel }))!.report); + expect(later.html).toContain("Sites to learn from today"); + expect(later.html).not.toContain("What this report will show"); + }); + it("without signed links a short prompt is printed and a long one is left to the text part", async () => { const { env, sql } = world({ INTEGRATIONS_KEY: undefined }); const { cookie, account } = await signedIn(env); From b84d55ddeed5c318e7da90bc63f91e92022659ba Mon Sep 17 00:00:00 2001 From: Mehar Khanna Date: Fri, 13 Nov 2026 10:04:58 -0500 Subject: [PATCH 2/3] Report: "went silent" needs steady traffic, not one burst A small site (glycotech.ca) had 30 of its 38 page loads in two hours while it was being set up, then a load every few hours. Its night counted as "stopped sending data 6 hours ago", a P0 that would have emailed a paying customer that their site was down. The rule now also needs loads in at least 12 of the 18 hours before the 6 quiet ones (SILENT_STEADY_HOURS). Co-Authored-By: Claude Opus 5.5 --- telemetry-worker/README.md | 2 +- telemetry-worker/src/report.ts | 6 +++++- telemetry-worker/test/report.test.ts | 4 ++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/telemetry-worker/README.md b/telemetry-worker/README.md index 55df4082..cda90934 100644 --- a/telemetry-worker/README.md +++ b/telemetry-worker/README.md @@ -1273,7 +1273,7 @@ text), `src/report-sweep.ts` is the hourly run, `src/report-routes.ts` the pages box with the trial. - **Priorities** are rules in `appItems`, tested in `test/report.test.ts`: P0 a failing endpoint 3 times, a duplicate, race or failure on a payment page (checkout, pay, billing, cart, order, subscribe, invoice), a blank page, a page that - stopped taking clicks or scrolling (3 times), an app silent for 6 hours after 36 page loads in the 18 before, + stopped taking clicks or scrolling (3 times), an app silent for 6 hours after 36 page loads spread over at least 12 of the 18 hours before, problems in the last two hours at least 10 and 4 times the usual; P1 the app's own bugs (duplicate, stale, conflict, broken state), a failing endpoint, visible checks (invisible text, controls out of reach, covered dialogs, stalled requests, broken images, template text), rage clicks, setup items; P2 the rest. The model never diff --git a/telemetry-worker/src/report.ts b/telemetry-worker/src/report.ts index 97cf9dd8..e5ba2661 100644 --- a/telemetry-worker/src/report.ts +++ b/telemetry-worker/src/report.ts @@ -53,6 +53,8 @@ export const ALERT_APPS = 60; /** An hourly report asks the model at most this often. */ export const MODEL_EVERY_MS = 6 * 3_600_000; export const REPORT_MODEL_TIMEOUT_MS = 45_000; +/** "Went silent" needs page loads in at least this many of the 18 hours before the 6 quiet ones. */ +export const SILENT_STEADY_HOURS = 12; /** The apps of one account a report covers (the oldest first, as the account page lists them). */ export const MAX_APPS = 10; export const FROM = { email: "report@genclass.dev", name: "GenClass" }; @@ -392,7 +394,9 @@ export function appItems(app: AppReport, s: Stats, series24: Stats | null, origi const loads = h.map((b) => b.sessions); const problems = h.map((b) => b.detections + b.checks); const before = sum(loads.slice(0, 18)); - if (before >= 36 && sum(loads.slice(18)) === 0) { + // steady means spread over the day, not one burst (someone testing for an hour): loads in most of those 18 hours + const steady = loads.slice(0, 18).filter((x) => x > 0).length >= SILENT_STEADY_HOURS; + if (before >= 36 && steady && sum(loads.slice(18)) === 0) { add({ p: 0, rule: "silent", diff --git a/telemetry-worker/test/report.test.ts b/telemetry-worker/test/report.test.ts index 66c89702..88a3e27a 100644 --- a/telemetry-worker/test/report.test.ts +++ b/telemetry-worker/test/report.test.ts @@ -181,6 +181,10 @@ describe("reports: the priority rules", () => { expect(rules(fakeStats({ hours }))).toContain("0 silent"); const quiet = [...Array.from({ length: 18 }, () => ({ sessions: 1 })), ...Array.from({ length: 6 }, () => ({ sessions: 0 }))]; expect(rules(fakeStats({ hours: quiet }))).not.toContain("0 silent"); + // a burst is not steady traffic: a small site's real day (30 of 38 loads in two hours while it was set up, then a + // load every few hours) must not tell its owner the site went down + const burst = [1, 0, 1, 1, 1, 0, 0, 0, 0, 14, 16, 1, 1, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0].map((sessions) => ({ sessions })); + expect(rules(fakeStats({ hours: burst }))).not.toContain("0 silent"); }); it("problems that jumped within the hour are critical; a steady level is not", () => { From 131e3a71e480b5c176586ea89b4ea943c7e1445b Mon Sep 17 00:00:00 2001 From: Mehar Khanna Date: Fri, 13 Nov 2026 10:06:46 -0500 Subject: [PATCH 3/3] Report email: no "Visits 0" tile beside page loads when the app sends no analytics Co-Authored-By: Claude Opus 5.5 --- telemetry-worker/src/report-email.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/telemetry-worker/src/report-email.ts b/telemetry-worker/src/report-email.ts index 248c3def..78ae25be 100644 --- a/telemetry-worker/src/report-email.ts +++ b/telemetry-worker/src/report-email.ts @@ -197,7 +197,8 @@ function hero(r: Report): string { .join(""); const m = measured(r); const tot = (f: (x: AppReport) => number) => m.reduce((s, x) => s + f(x), 0); - const visits = m.some((x) => x.visitors) ? tot((x) => x.visitors?.visits ?? 0) : null; + // visits only when some were counted: an app whose pages do not send analytics would read "0 visits" beside its loads + const visits = m.some((x) => x.visitors) && tot((x) => x.visitors?.visits ?? 0) > 0 ? tot((x) => x.visitors?.visits ?? 0) : null; const tiles = m.length && r.kind !== "alert" ? [ ["Page loads", tot((x) => x.totals.loads), tot((x) => x.totals.prevLoads)],