Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion telemetry-worker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 35 additions & 10 deletions telemetry-worker/src/report-email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -65,6 +67,12 @@ const counts = (items: Item[]): Record<Priority, number> => {
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 {
Expand All @@ -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`;
}
Expand All @@ -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 };
}

Expand Down Expand Up @@ -189,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)],
Expand Down Expand Up @@ -384,6 +393,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 "";
Expand Down Expand Up @@ -452,8 +473,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 `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="color-scheme" content="light dark"><meta name="supported-color-schemes" content="light dark"><title>${esc(subjectOf(r))}</title>${DARK}</head>
Expand Down Expand Up @@ -513,9 +534,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}`, "");
}
Expand Down
6 changes: 5 additions & 1 deletion telemetry-worker/src/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" };
Expand Down Expand Up @@ -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",
Expand Down
23 changes: 23 additions & 0 deletions telemetry-worker/test/report.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -263,6 +267,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&#39;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);
Expand Down
Loading