diff --git a/lib/dashboard/stats-text.ts b/lib/dashboard/stats-text.ts index fb90f04..fe642b8 100644 --- a/lib/dashboard/stats-text.ts +++ b/lib/dashboard/stats-text.ts @@ -13,6 +13,8 @@ export type StatsAnswerish = { sources?: StatsItem[] | null; referrers?: StatsItem[] | null; pages?: StatsItem[] | null; + countries?: StatsItem[] | null; + cities?: StatsItem[] | null; }; const list = (v: StatsItem[] | null | undefined): StatsItem[] => (Array.isArray(v) ? v : []); @@ -42,6 +44,12 @@ export function renderStats( section("Sources", list(answer.sources)); section("Referrers", list(answer.referrers)); section("Pages", list(answer.pages)); + // Last, but the two that settle an argument. A referrer is whatever the + // client typed; the country is where the packets came from. Traffic that + // reads as a broad audience in Sources and as a single city here is one + // operator, not an audience. + section("Countries", list(answer.countries)); + section("Cities", list(answer.cities)); // Nothing at all is a real answer, and the likeliest cause is worth naming. if (!(totals.pageviews ?? 0) && !(totals.events ?? 0) && !list(answer.sources).length) { diff --git a/lib/tracker/apiStats.ts b/lib/tracker/apiStats.ts index 5403365..242d328 100644 --- a/lib/tracker/apiStats.ts +++ b/lib/tracker/apiStats.ts @@ -33,8 +33,24 @@ export type ProjectRow = { id: string; name: string; url: string; tracker_enable * Deliberately not every panel: a CLI answer people read in a terminal is the * sources, the pages and the shape over time. Devices and browsers are a * different question and cost another query each. + * + * Geography is here because it answers a question the others cannot. Sources + * and referrers describe what a caller SAYS it is, and both are trivially + * forged; where the packets entered from is not. When traffic to one site + * turned out to be almost entirely one city, that single fact identified it as + * one operator on a cloud region rather than the scattered readership the + * referrer panel implied — and nothing in the terminal could show it, because + * these two panels rendered only in the dashboard. Two extra queries is a + * cheap price for the panel that tells you who you are actually talking to. */ -export const STATS_PANELS: PanelKey[] = ["series", "sources", "pages", "referrers"]; +export const STATS_PANELS: PanelKey[] = [ + "series", + "sources", + "pages", + "referrers", + "countries", + "cities", +]; export type ResolveResult = | { ok: true; project: ProjectRow } @@ -123,6 +139,9 @@ export type StatsAnswer = { sources: ListItem[]; referrers: ListItem[]; pages: ListItem[]; + /** Where the traffic entered from. Unforgeable, unlike a referrer. */ + countries: ListItem[]; + cities: ListItem[]; /** * The shape over time, present only with `detail`. It is what the totals were * summed from, so asking for it costs nothing extra. @@ -241,6 +260,8 @@ export async function projectStats( sources: asList(panels.sources), referrers: asList(panels.referrers), pages: asList(panels.pages), + countries: asList(panels.countries), + cities: asList(panels.cities), }; if (!detail) return answer; diff --git a/tests/dashboard-package-cli.test.ts b/tests/dashboard-package-cli.test.ts index b15273f..9cab7d8 100644 --- a/tests/dashboard-package-cli.test.ts +++ b/tests/dashboard-package-cli.test.ts @@ -103,4 +103,30 @@ describe("renderStats", () => { const text = renderStats({ totals: { visitors: 0, pageviews: 0 } }, { range: "1d", who: "humans" }); expect(text).toContain("Check the tag is on the page"); }); + + it("prints where the traffic came from", () => { + const text = renderStats( + { + project: { name: "site.com" }, + totals: { visitors: 10, pageviews: 40 }, + countries: [{ label: "Singapore (SG)", value: 38 }], + cities: [{ label: "Singapore, SG", value: 38 }], + }, + { range: "1d", who: "all" }, + ); + expect(text).toContain("Countries"); + expect(text).toContain("Singapore (SG)"); + expect(text).toContain("Cities"); + }); + + it("omits the geo sections when the panels are empty", () => { + // Same rule as every other section: an empty panel prints nothing rather + // than an empty heading, so a quiet window stays readable. + const text = renderStats( + { project: { name: "site.com" }, totals: { visitors: 1, pageviews: 1 }, pages: [{ label: "/", value: 1 }] }, + { range: "1d", who: "humans" }, + ); + expect(text).not.toContain("Countries"); + expect(text).not.toContain("Cities"); + }); });