diff --git a/app/(app)/dashboard/analytics/page.tsx b/app/(app)/dashboard/analytics/page.tsx index e358295..4c8edad 100644 --- a/app/(app)/dashboard/analytics/page.tsx +++ b/app/(app)/dashboard/analytics/page.tsx @@ -15,11 +15,29 @@ import { type Trend, type TrendDirection, } from "@/lib/tracker/trend"; +import { + emptyTotals, + sumTotals, + toProjectTotals, + totalsTrends, + type ProjectTotals, + type TotalsRow, +} from "@/lib/tracker/totals"; +import { + AI_REFERRALS_DEFINITION, + ALL_EVENTS_DEFINITION, + BOTS_DEFINITION, + BOTS_LABEL, + HUMANS_DEFINITION, + HUMANS_LABEL, + humansFrom, +} from "@/lib/tracker/humans"; import { TrackerAnalytics, type TrackerListItem, } from "@/components/charts/tracker-analytics"; import { + BOTS_KEY, OTHER_KEY, PortfolioTrend, type PortfolioPoint, @@ -53,29 +71,14 @@ type PortfolioProject = { organization_id?: string | null; }; -type TotalsRow = { - project_id: string; - events: number | string; - ai: number | string; - bots: number | string; - prev_events: number | string; - prev_ai: number | string; - prev_bots: number | string; -}; - -type ProjectTotals = { - events: number; - ai: number; - bots: number; - prevEvents: number; - prevAi: number; - prevBots: number; -}; - +// Every figure on this page leads with humans (bucket not `bot:`, AI referrals +// included) and shows bot crawls apart. See lib/tracker/humans.ts. type ProjectRow = { project: PortfolioProject; totals: ProjectTotals; + /** Human visits, this window vs the one before. */ trend: Trend; + /** Daily human visits for the sparkline; null when over the row budget. */ samples: number[] | null; }; @@ -208,33 +211,21 @@ export default async function PortfolioAnalyticsPage({ const totalsByProject = new Map(); for (const row of (totalsRes.data ?? []) as TotalsRow[]) { - totalsByProject.set(row.project_id, { - events: Number(row.events), - ai: Number(row.ai), - bots: Number(row.bots), - prevEvents: Number(row.prev_events), - prevAi: Number(row.prev_ai), - prevBots: Number(row.prev_bots), - }); + totalsByProject.set(row.project_id, toProjectTotals(row)); } const portfolio = sumTotals(totalsByProject.values()); - const eventsTrend = computeTrend(portfolio.events, portfolio.prevEvents); - const aiTrend = computeTrend(portfolio.ai, portfolio.prevAi); - const botsTrend = computeTrend(portfolio.bots, portfolio.prevBots); - const otherTrend = computeTrend( - otherVisits(portfolio.events, portfolio.ai, portfolio.bots), - otherVisits(portfolio.prevEvents, portfolio.prevAi, portfolio.prevBots), - ); + const trends = totalsTrends(portfolio); - // Ranked by current-window volume: the biggest properties get the chart - // bands, and the daily-detail budget is spent on them first. + // Ranked by current-window HUMAN volume: the biggest properties get the + // chart bands, and the daily-detail budget is spent on them first. Ranking + // by events would hand the top band to whichever site a crawler is hitting. const ranked = projects .map((project) => ({ project, totals: totalsByProject.get(project.id) ?? emptyTotals(), })) - .sort((a, b) => b.totals.events - a.totals.events); + .sort((a, b) => b.totals.humans - a.totals.humans); const detailCount = Math.max( CHART_BANDS, @@ -251,6 +242,8 @@ export default async function PortfolioAnalyticsPage({ perProjectError, ); + // Per-project daily HUMAN counts. The RPC also returns events and bots per + // row; only humans feed the bands and sparklines. const axis = utcDayAxis(days); const dailyByProject = new Map>(); for (const id of detailIds) dailyByProject.set(id, new Map()); @@ -258,8 +251,12 @@ export default async function PortfolioAnalyticsPage({ project_id: string; day: string; events: number | string; + humans?: number | string | null; + bots?: number | string | null; }>) { - dailyByProject.get(row.project_id)?.set(row.day, Number(row.events)); + dailyByProject + .get(row.project_id) + ?.set(row.day, humansFrom({ humans: row.humans, events: row.events, bots: row.bots ?? 0 })); } const rows: ProjectRow[] = ranked.map(({ project, totals }) => { @@ -267,22 +264,24 @@ export default async function PortfolioAnalyticsPage({ return { project, totals, - trend: computeTrend(totals.events, totals.prevEvents), + trend: computeTrend(totals.humans, totals.prevHumans), samples: byDay ? axis.map((day) => byDay.get(day) ?? 0) : null, }; }); const withTraffic = rows.filter( - (r) => r.totals.events > 0 || r.totals.prevEvents > 0, + (r) => r.totals.humans > 0 || r.totals.prevHumans > 0, ); const verdict = portfolioVerdict( - eventsTrend, + trends.humans, withTraffic.map((r) => r.trend.direction), ); - // Stacked bands for the top properties; "Other" is the exact remainder of - // the full-portfolio series, so the stack always sums to the real total. - const banded = ranked.slice(0, CHART_BANDS).filter((r) => r.totals.events > 0); + // Stacked bands of human visits for the top properties; "Other" is the + // exact remainder of the full-portfolio human series, so the stack always + // sums to the real human total. Bot crawls ride on top as one unstacked + // dashed line so a crawler storm stays visible without swamping the bands. + const banded = ranked.slice(0, CHART_BANDS).filter((r) => r.totals.humans > 0); const bandIds = banded.map((r) => r.project.id); const chartSeries: PortfolioSeries[] = banded.map((r) => ({ key: r.project.id, @@ -290,6 +289,7 @@ export default async function PortfolioAnalyticsPage({ })); const hasOther = ranked.length > banded.length; if (hasOther) chartSeries.push({ key: OTHER_KEY, name: "Other properties" }); + chartSeries.push({ key: BOTS_KEY, name: "Bot crawls (all properties)", kind: "overlay" }); const chartData: PortfolioPoint[] = daily.map((point) => { const row: PortfolioPoint = { date: point.date }; @@ -299,7 +299,8 @@ export default async function PortfolioAnalyticsPage({ row[id] = value; bandedTotal += value; } - if (hasOther) row[OTHER_KEY] = Math.max(0, point.events - bandedTotal); + if (hasOther) row[OTHER_KEY] = Math.max(0, point.humans - bandedTotal); + row[BOTS_KEY] = point.bots; return row; }); @@ -421,7 +422,7 @@ export default async function PortfolioAnalyticsPage({ const topOperatingSystems = topDeviceItems(deviceRows, (row) => row.os); const missingSparklines = rows.filter( - (r) => r.samples === null && r.totals.events > 0, + (r) => r.samples === null && r.totals.humans > 0, ).length; return ( @@ -444,10 +445,30 @@ export default async function PortfolioAnalyticsPage({
- - - - + + + +
{portfolio.events === 0 && portfolio.prevEvents === 0 ? ( @@ -464,11 +485,18 @@ export default async function PortfolioAnalyticsPage({

Portfolio trend

- Daily events, stacked by property. + Daily human visits, stacked by property. Bot crawls across + every property are the dashed line, kept out of the stack.

- {portfolio.events.toLocaleString()} events + + {portfolio.humans.toLocaleString()} human visits + + {" · "} + + {portfolio.bots.toLocaleString()} bot hits + @@ -479,7 +507,9 @@ export default async function PortfolioAnalyticsPage({

By property

- Each site against its own previous {days} days. + Human visits per site against its own previous {days} days. + AI referrals are part of the human figure; bot crawls are + not.

Property - Events - Previous + + Humans + + + Previous + Change Trend - AI / bots + + AI + + + Bots + @@ -637,11 +676,11 @@ function ProjectTrendTable({ rows }: { rows: ProjectRow[] }) { - - {totals.events.toLocaleString()} + + {totals.humans.toLocaleString()} - {totals.prevEvents.toLocaleString()} + {totals.prevHumans.toLocaleString()} @@ -653,8 +692,11 @@ function ProjectTrendTable({ rows }: { rows: ProjectRow[] }) { — )} - - {totals.ai.toLocaleString()} / {totals.bots.toLocaleString()} + + {totals.ai.toLocaleString()} + + + {totals.bots.toLocaleString()} ))} @@ -685,19 +727,24 @@ function TrendMetric({ label, trend, tone, + hint, }: { label: string; trend: Trend; tone: "accent" | "pass" | "warn" | "muted"; + /** What this number counts; shown on hover and under the figure. */ + hint?: string; }) { const color = tone === "pass" ? "text-green-600" : tone === "warn" ? "text-yellow-600" - : "text-[var(--color-foreground)]"; + : tone === "muted" + ? "text-[var(--color-muted)]" + : "text-[var(--color-foreground)]"; return ( -
+

{label}

{trend.current.toLocaleString()} @@ -706,6 +753,11 @@ function TrendMetric({ from {trend.previous.toLocaleString()}

+ {hint && ( +

+ {hint} +

+ )}
); } @@ -731,29 +783,6 @@ function parseRange(value: string | undefined): Range { : DEFAULT_RANGE; } -function emptyTotals(): ProjectTotals { - return { events: 0, ai: 0, bots: 0, prevEvents: 0, prevAi: 0, prevBots: 0 }; -} - -function sumTotals(all: Iterable): ProjectTotals { - const out = emptyTotals(); - for (const t of all) { - out.events += t.events; - out.ai += t.ai; - out.bots += t.bots; - out.prevEvents += t.prevEvents; - out.prevAi += t.prevAi; - out.prevBots += t.prevBots; - } - return out; -} - -// Everything that isn't an AI referral or a bot crawl, matching the split the -// per-project stats page uses. -function otherVisits(events: number, ai: number, bots: number) { - return Math.max(0, events - ai - bots); -} - function buildProjectAccessFilter( userId: string, projectMemberIds: string[], diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx index d81b847..5bd7b40 100644 --- a/app/(app)/dashboard/page.tsx +++ b/app/(app)/dashboard/page.tsx @@ -3,6 +3,7 @@ import { createClient } from "@/lib/supabase/server"; import { rpcFailed, type Loaded } from "@/lib/loaded"; import { ScoreBadge } from "@/components/score-badge"; import { FontSparkline } from "@/components/font-sparkline"; +import { BOTS_DEFINITION, HUMANS_DEFINITION } from "@/lib/tracker/humans"; import { ProjectLogo } from "@/components/project-logo"; import { StatsUnavailable } from "@/components/stats-unavailable"; import { backfillProjectLogo } from "@/app/actions/createProject"; @@ -124,7 +125,7 @@ export default async function DashboardPage({ fetchEnabledProjectIds(supabase, "lx_site", projectIds, { status: "active" }), fetchEnabledProjectIds(supabase, "sp_site_account", projectIds), fetchLatestBlogPostByProject(supabase, projectIds), - fetchSevenDayPageviews(supabase, projectIds), + fetchSevenDayTraffic(supabase, projectIds), ]); const trafficByProject = traffic.data; const trafficFailed = traffic.failed; @@ -192,7 +193,7 @@ export default async function DashboardPage({
{projects && projects.length > 0 && trafficFailed && ( - + )} {projects && projects.length > 0 ? ( @@ -263,17 +264,25 @@ export default async function DashboardPage({ )}
-
+
{trafficFailed - ? "Pageviews unavailable" - : `${totalTraffic(trafficByProject.get(p.id) ?? []).toLocaleString()} pageviews`} + ? "Traffic unavailable" + : `${totalHumans(trafficByProject.get(p.id) ?? []).toLocaleString()} human visits`}
-
- {trafficFailed ? "Query failed \u2014 not zero" : "Past 7 days"} +
+ {trafficFailed + ? "Query failed \u2014 not zero" + : `${totalBots(trafficByProject.get(p.id) ?? []).toLocaleString()} bot hits \u00b7 Past 7 days`}
{!trafficFailed && ( - + )}
{orgSchemaReady && ( @@ -330,7 +339,10 @@ export default async function DashboardPage({ } type LatestPost = { url: string; publishedAt: string }; -type TrafficPoint = { day: string; count: number }; +// One UTC day of a project's traffic, already split: humans is every bucket +// that is not `bot:` (AI referrals included), bots is the rest. See +// lib/tracker/humans.ts for why the card no longer leads with the sum. +type TrafficPoint = { day: string; humans: number; bots: number }; async function fetchLatestBlogPostByProject( supabase: Awaited>, @@ -372,7 +384,7 @@ async function fetchLatestBlogPostByProject( return out; } -async function fetchSevenDayPageviews( +async function fetchSevenDayTraffic( supabase: Awaited>, projectIds: string[], ): Promise>> { @@ -381,7 +393,7 @@ async function fetchSevenDayPageviews( for (const projectId of projectIds) { out.set( projectId, - days.map((day) => ({ day, count: 0 })), + days.map((day) => ({ day, humans: 0, bots: 0 })), ); } if (projectIds.length === 0) return { data: out, failed: false }; @@ -390,28 +402,35 @@ async function fetchSevenDayPageviews( // 1000-row response cap and silently truncates whichever projects' // rows didn't make the cut. // - // The error is checked rather than discarded: this RPC reads a 1.2M-row - // rollup under RLS, and when it was cancelled by the 8s statement_timeout - // the zero-filled map below rendered "0 pageviews" on every card. That is - // indistinguishable from a portfolio with no traffic, and was read as a - // dead tracker three times over. - const { data, error } = await supabase.rpc("dashboard_project_pageviews", { + // dashboard_project_traffic reads tracker_daily_stats (the bucket rollup, + // the only one that knows bot from human) rather than the bot-inclusive + // pageview rollup dashboard_project_pageviews summed. On a crawled site the + // old number was 99% one AI training bot and read as "80k pageviews a day". + // + // The error is checked rather than discarded: when this kind of RPC was + // cancelled by the 8s statement_timeout the zero-filled map below rendered + // "0" on every card. That is indistinguishable from a portfolio with no + // traffic, and was read as a dead tracker three times over. + const { data, error } = await supabase.rpc("dashboard_project_traffic", { p_project_ids: projectIds, p_since: days[0], }); - if (rpcFailed("tracker", "dashboard_project_pageviews", error)) { + if (rpcFailed("tracker", "dashboard_project_traffic", error)) { return { data: out, failed: true }; } for (const row of (data ?? []) as Array<{ project_id: string; day: string; - count: number; + humans: number | string; + bots: number | string; }>) { const points = out.get(row.project_id); if (!points) continue; const point = points.find((item) => item.day === row.day); - if (point) point.count += Number(row.count); + if (!point) continue; + point.humans += Number(row.humans); + point.bots += Number(row.bots); } return { data: out, failed: false }; @@ -425,12 +444,16 @@ function lastSevenDays() { }); } -function trafficSamples(points: TrafficPoint[] | undefined) { - return points?.map((point) => point.count) ?? Array(7).fill(0); +function humanSamples(points: TrafficPoint[] | undefined) { + return points?.map((point) => point.humans) ?? Array(7).fill(0); +} + +function totalHumans(points: TrafficPoint[]) { + return points.reduce((sum, point) => sum + point.humans, 0); } -function totalTraffic(points: TrafficPoint[]) { - return points.reduce((sum, point) => sum + point.count, 0); +function totalBots(points: TrafficPoint[]) { + return points.reduce((sum, point) => sum + point.bots, 0); } function dashboardHref(status: StatusFilter, organizationId: string | null) { diff --git a/app/(app)/dashboard/projects/[id]/stats/page.tsx b/app/(app)/dashboard/projects/[id]/stats/page.tsx index 4c1346e..65c0c8c 100644 --- a/app/(app)/dashboard/projects/[id]/stats/page.tsx +++ b/app/(app)/dashboard/projects/[id]/stats/page.tsx @@ -9,6 +9,13 @@ import { type TrackerPanels, } from "@/components/charts/tracker-analytics"; import { fetchPanels, PANEL_KEYS } from "@/lib/tracker/panels"; +import { + AI_REFERRALS_DEFINITION, + BOTS_DEFINITION, + BOTS_LABEL, + HUMANS_DEFINITION, + HUMANS_LABEL, +} from "@/lib/tracker/humans"; import { DEFAULT_TRACKER_RANGE, trackerRange } from "@/lib/tracker/ranges"; import { InstallSnippet } from "./install-snippet"; import { TrackerToggle } from "./tracker-toggle"; @@ -61,15 +68,16 @@ export default async function ProjectStatsPage({ )) as unknown as TrackerPanels; // Headline metrics come straight from the series so they stay exact even - // though Top sources below is truncated to the top 10 buckets. "Other visits" - // is everything that isn't an AI referral or a bot (human/search/social/ - // referral), matching the original bucket-prefix split. + // though Top sources below is truncated to the top 10 buckets. The page + // leads with humans (every bucket that is not `bot:`, AI referrals + // included) and shows bot crawls apart — see lib/tracker/humans.ts for why + // the old bot-inclusive total is no longer a headline. const points = panels.series.points; const totalAi = points.reduce((s, p) => s + p.ai, 0); const totalBot = points.reduce((s, p) => s + p.bots, 0); + const totalHuman = points.reduce((s, p) => s + p.humans, 0); const grandTotal = points.reduce((s, p) => s + p.events, 0); const eventTotal = points.reduce((s, p) => s + p.pageviews + p.interactions, 0); - const totalHuman = Math.max(0, grandTotal - totalAi - totalBot); // Older projects have rollup rows in tracker_daily_stats but nothing in // tracker_event_daily_stats, which would leave Event mix empty on a page @@ -230,9 +238,24 @@ export default async function ProjectStatsPage({ )}
- - - + + +
{grandTotal === 0 && eventTotal === 0 ? ( @@ -307,10 +330,13 @@ function Metric({ label, value, tone, + hint, }: { label: string; value: number; - tone: "pass" | "warn" | "muted"; + tone: "accent" | "pass" | "warn" | "muted"; + /** What this number counts; shown on hover and under the figure. */ + hint?: string; }) { const color = tone === "pass" @@ -319,11 +345,16 @@ function Metric({ ? "text-yellow-600" : "text-[var(--color-foreground)]"; return ( -
+

{label}

{value.toLocaleString()}

+ {hint && ( +

+ {hint} +

+ )}
); } diff --git a/components/charts/portfolio-trend.tsx b/components/charts/portfolio-trend.tsx index 0fb578a..0f2e1c0 100644 --- a/components/charts/portfolio-trend.tsx +++ b/components/charts/portfolio-trend.tsx @@ -11,17 +11,25 @@ import { YAxis, } from "recharts"; -// One stacked band per property, so the total height is the portfolio and each -// band shows which site is actually moving it. Series keys are project ids -// (plus the synthetic OTHER_KEY band) because project names are not unique. +// One stacked band per property, so the total height is the portfolio's +// human traffic and each band shows which site is actually moving it. Series +// keys are project ids (plus the synthetic OTHER_KEY band) because project +// names are not unique. +// +// Bot crawls are NOT part of the stack. They are drawn as one dashed, unfilled +// overlay line (BOTS_KEY) so a crawler storm is visible without swamping the +// human bands it used to be summed into. export type PortfolioSeries = { key: string; name: string; + /** "band" (default) stacks; "overlay" draws an unstacked line on top. */ + kind?: "band" | "overlay"; }; export type PortfolioPoint = { date: string } & Record; export const OTHER_KEY = "__other"; +export const BOTS_KEY = "__bots"; const COLORS = [ "var(--color-accent)", @@ -33,6 +41,7 @@ const COLORS = [ ]; const OTHER_COLOR = "var(--color-muted)"; +const OVERLAY_COLOR = "var(--color-muted)"; export function PortfolioTrend({ data, @@ -41,6 +50,9 @@ export function PortfolioTrend({ data: PortfolioPoint[]; series: PortfolioSeries[]; }) { + const bands = series.filter((entry) => entry.kind !== "overlay"); + const overlays = series.filter((entry) => entry.kind === "overlay"); + return (
- {series.map((entry, index) => { + {bands.map((entry, index) => { const color = entry.key === OTHER_KEY ? OTHER_COLOR @@ -84,6 +96,19 @@ export function PortfolioTrend({ /> ); })} + {overlays.map((entry) => ( + + ))}
diff --git a/components/charts/tracker-analytics.tsx b/components/charts/tracker-analytics.tsx index 097a48b..d1d23fd 100644 --- a/components/charts/tracker-analytics.tsx +++ b/components/charts/tracker-analytics.tsx @@ -24,6 +24,11 @@ import { type TrackerRangeKey, } from "@/lib/tracker/ranges"; import type { ListItem, SeriesPayload } from "@/lib/tracker/panels"; +import { + AI_REFERRALS_DEFINITION, + BOTS_DEFINITION, + HUMANS_DEFINITION, +} from "@/lib/tracker/humans"; export type TrackerDailyPoint = { date: string; @@ -32,6 +37,8 @@ export type TrackerDailyPoint = { interactions: number; ai: number; bots: number; + /** Everything not identified as a crawler; see lib/tracker/humans.ts. */ + humans: number; }; export type TrackerListItem = ListItem; @@ -181,6 +188,8 @@ function PanelFrame({ title, subtitle, total, + unit = ["event", "events"], + totalHint, ranges, range, onRange, @@ -193,6 +202,10 @@ function PanelFrame({ title: string; subtitle?: string; total?: number; + /** [singular, plural] noun for the right-hand total. */ + unit?: [string, string]; + /** Hover text that says what the total counts. */ + totalHint?: string; ranges: TrackerRange[]; range: TrackerRangeKey; onRange: (key: TrackerRangeKey) => void; @@ -212,12 +225,15 @@ function PanelFrame({ )}
- + {error ? "—" : total === undefined ? "" - : `${total.toLocaleString()} ${total === 1 ? "event" : "events"}`} + : `${total.toLocaleString()} ${total === 1 ? unit[0] : unit[1]}`} {showTabs && ( sum + point.events, 0); + // Lead with people. Older rollup days predate the bucket table and carry + // humans: 0, so the frame total is the human figure, not the old + // bot-inclusive event count — which is exactly the number this chart used + // to mislead with. + const humans = points.reduce((sum, point) => sum + point.humans, 0); + const bots = points.reduce((sum, point) => sum + point.bots, 0); const byTime = data?.granularity === "time"; return ( + {/* The stack is humans + bots = every event, split honestly. + The bot-inclusive pageview series is gone: on a crawled site it + was indistinguishable from the bot band and double-counted it. + AI referrals (a subset of humans) and interactions (clicks and + forms, whoever fired them) are unstacked overlays. */}
+
+
+
Human visits:
+
{HUMANS_DEFINITION}
+
+
+
Bot crawls:
+
{BOTS_DEFINITION}
+
+
+
AI referrals:
+
{AI_REFERRALS_DEFINITION}
+
+
); } diff --git a/lib/tracker/humans.ts b/lib/tracker/humans.ts new file mode 100644 index 0000000..20420e4 --- /dev/null +++ b/lib/tracker/humans.ts @@ -0,0 +1,55 @@ +// The one place that says what "human" means on the analytics pages. +// +// WHY: the dashboards used to lead with a bot-inclusive total. On one property +// 99% of ~257k weekly hits were a single AI training crawler, and the card was +// read as "80k pageviews a day" for a site with a few hundred real readers. +// Every headline now leads with humans and shows bot crawls separately, and +// every number that gets labelled points at these strings so the definition +// is the same wherever it is shown. +// +// Definition (mirrored in supabase/migrations/20260905120000_tracker_human_split.sql): +// human = bucket does NOT start with "bot:" (ai_referral / search / social / +// referral / human:direct — a person arriving from ChatGPT is a person) +// bot = bucket starts with "bot:" (named AI crawlers + bot:other) +// so humans + bots = events, exactly. + +export const HUMANS_LABEL = "Human visits"; +export const BOTS_LABEL = "Bot crawls"; + +export const HUMANS_DEFINITION = + "Everything not identified as a crawler, including visits referred by AI assistants such as ChatGPT or Perplexity."; + +export const BOTS_DEFINITION = + "Hits from user agents identified as crawlers: AI training and retrieval bots, search engine bots, and other automated clients. Not people."; + +export const AI_REFERRALS_DEFINITION = + "People who arrived from an AI assistant. These are already counted inside human visits."; + +export const ALL_EVENTS_DEFINITION = + "Every tracked event, human and bot together. This is the old headline number; bots can dominate it."; + +type Countable = number | string | null | undefined; + +/** Coerce a PostgREST count (bigint columns arrive as strings). null when absent. */ +export function toCount(value: Countable): number | null { + if (value === null || value === undefined || value === "") return null; + const n = Number(value); + return Number.isFinite(n) ? n : null; +} + +/** + * Humans for one RPC row. Prefers the `humans` column the split migration + * adds; before it is applied the column is missing, and the identity + * humans = events - bots is exact for every bucket-based leg, so fall back to + * that rather than to zero (a dashboard that says "0 human visits" while the + * bot column climbs is the misreading this exists to prevent, mirrored). + */ +export function humansFrom(row: { + humans?: Countable; + events: Countable; + bots: Countable; +}): number { + const explicit = toCount(row.humans); + if (explicit !== null) return explicit; + return Math.max(0, (toCount(row.events) ?? 0) - (toCount(row.bots) ?? 0)); +} diff --git a/lib/tracker/panels.ts b/lib/tracker/panels.ts index 26b6b7a..a25f066 100644 --- a/lib/tracker/panels.ts +++ b/lib/tracker/panels.ts @@ -15,6 +15,7 @@ import type { SupabaseClient } from "@supabase/supabase-js"; import { bucketLabel } from "@/lib/tracker/categorize"; import { countryNameFromCode } from "@/lib/tracker/country"; +import { humansFrom } from "@/lib/tracker/humans"; import { buildDailyAxis, toSeriesRow } from "@/lib/tracker/series"; import { isRawRange, @@ -59,6 +60,8 @@ export type SeriesPoint = { interactions: number; ai: number; bots: number; + /** Everything not identified as a crawler; see lib/tracker/humans.ts. */ + humans: number; }; export type SeriesPayload = { @@ -357,6 +360,8 @@ type RecentSeriesRow = { ai: number | string; bots: number | string; events: number | string; + /** Absent until the human-split migration is applied; derived then. */ + humans?: number | string | null; }; // Zero-fill the sub-day series across every bucket in the window. The RPC only @@ -388,6 +393,7 @@ export function buildBucketAxis( interactions: 0, ai: 0, bots: 0, + humans: 0, }); } @@ -401,6 +407,7 @@ export function buildBucketAxis( point.ai += Number(row.ai); point.bots += Number(row.bots); point.events += Number(row.events); + point.humans += humansFrom(row); } return Array.from(byTs.values()); diff --git a/lib/tracker/series.ts b/lib/tracker/series.ts index 752458a..d55c6c9 100644 --- a/lib/tracker/series.ts +++ b/lib/tracker/series.ts @@ -3,6 +3,8 @@ // the sparse per-day rows onto a zero-filled UTC axis so gaps render as flat // spans rather than being skipped entirely. +import { humansFrom } from "@/lib/tracker/humans"; + export type TrackerSeriesRow = { day: string; pageviews: number; @@ -10,6 +12,8 @@ export type TrackerSeriesRow = { ai: number; bots: number; events: number; + /** Everything not identified as a crawler; see lib/tracker/humans.ts. */ + humans: number; }; export type TrackerDailyPointShape = { @@ -19,9 +23,12 @@ export type TrackerDailyPointShape = { interactions: number; ai: number; bots: number; + humans: number; }; // Coerce a raw RPC row. bigint columns arrive as strings over PostgREST. +// `humans` is absent until the human-split migration is applied, in which +// case it is derived from the bucket totals (events - bots is exact there). export function toSeriesRow(row: { day: string; pageviews: number | string; @@ -29,6 +36,7 @@ export function toSeriesRow(row: { ai: number | string; bots: number | string; events: number | string; + humans?: number | string | null; }): TrackerSeriesRow { return { day: row.day, @@ -37,6 +45,7 @@ export function toSeriesRow(row: { ai: Number(row.ai), bots: Number(row.bots), events: Number(row.events), + humans: humansFrom(row), }; } @@ -70,6 +79,7 @@ export function buildDailyAxis( interactions: 0, ai: 0, bots: 0, + humans: 0, }); } @@ -81,10 +91,13 @@ export function buildDailyAxis( point.ai += row.ai; point.bots += row.bots; point.events += row.events; + point.humans += row.humans; } // Older rollups predate the bucket table, so `events` can be 0 on a day that - // clearly had traffic. Fall back to the event table's own totals. + // clearly had traffic. Fall back to the event table's own totals. `humans` + // is deliberately left alone: those event-table totals are bot-inclusive, + // so there is no honest human figure for such a day. for (const point of byDay.values()) { if (point.events === 0) point.events = point.pageviews + point.interactions; } diff --git a/lib/tracker/totals.ts b/lib/tracker/totals.ts new file mode 100644 index 0000000..0926385 --- /dev/null +++ b/lib/tracker/totals.ts @@ -0,0 +1,95 @@ +// Per-project window totals from tracker_project_totals, and the portfolio +// roll-up of them. Pulled out of the analytics page so the arithmetic behind +// the headline tiles — humans first, bots apart — is testable without a +// database. + +import { computeTrend, type Trend } from "@/lib/tracker/trend"; +import { humansFrom } from "@/lib/tracker/humans"; + +/** One row of tracker_project_totals. bigint columns arrive as strings. */ +export type TotalsRow = { + project_id: string; + events: number | string; + ai: number | string; + bots: number | string; + prev_events: number | string; + prev_ai: number | string; + prev_bots: number | string; + /** Added by the human-split migration; absent until it is applied. */ + humans?: number | string | null; + prev_humans?: number | string | null; +}; + +export type ProjectTotals = { + events: number; + ai: number; + bots: number; + /** Everything not identified as a crawler; includes AI referrals. */ + humans: number; + prevEvents: number; + prevAi: number; + prevBots: number; + prevHumans: number; +}; + +export function emptyTotals(): ProjectTotals { + return { + events: 0, + ai: 0, + bots: 0, + humans: 0, + prevEvents: 0, + prevAi: 0, + prevBots: 0, + prevHumans: 0, + }; +} + +export function toProjectTotals(row: TotalsRow): ProjectTotals { + return { + events: Number(row.events), + ai: Number(row.ai), + bots: Number(row.bots), + humans: humansFrom(row), + prevEvents: Number(row.prev_events), + prevAi: Number(row.prev_ai), + prevBots: Number(row.prev_bots), + prevHumans: humansFrom({ + humans: row.prev_humans, + events: row.prev_events, + bots: row.prev_bots, + }), + }; +} + +export function sumTotals(all: Iterable): ProjectTotals { + const out = emptyTotals(); + for (const t of all) { + out.events += t.events; + out.ai += t.ai; + out.bots += t.bots; + out.humans += t.humans; + out.prevEvents += t.prevEvents; + out.prevAi += t.prevAi; + out.prevBots += t.prevBots; + out.prevHumans += t.prevHumans; + } + return out; +} + +export type TotalsTrends = { + humans: Trend; + ai: Trend; + bots: Trend; + events: Trend; +}; + +/** Window-over-window trend for each headline figure. */ +export function totalsTrends(t: ProjectTotals): TotalsTrends { + return { + humans: computeTrend(t.humans, t.prevHumans), + ai: computeTrend(t.ai, t.prevAi), + bots: computeTrend(t.bots, t.prevBots), + events: computeTrend(t.events, t.prevEvents), + }; +} diff --git a/supabase/migrations/20260905120000_tracker_human_split.sql b/supabase/migrations/20260905120000_tracker_human_split.sql new file mode 100644 index 0000000..5d6db9c --- /dev/null +++ b/supabase/migrations/20260905120000_tracker_human_split.sql @@ -0,0 +1,307 @@ +-- Human / bot split for every tracker aggregate the dashboards lead with. +-- +-- WHY: the headline numbers on /dashboard, /dashboard/analytics and the +-- per-project stats page were bot-inclusive. On one property 99% of ~257k +-- weekly hits were a single AI training crawler, and the card read as +-- "80k pageviews a day" when the site had a few hundred real readers. The +-- pages need to lead with people and show crawler traffic as a separate +-- figure, which means every RPC they read has to report both. +-- +-- DEFINITION (used everywhere, in SQL and in lib/tracker): a row is a HUMAN +-- when its bucket does NOT start with 'bot:'. That includes 'ai_referral:*' +-- (a person arriving from ChatGPT / Perplexity is a person), 'search:*', +-- 'social:*', 'referral:*' and 'human:direct'. A row is a BOT when its bucket +-- starts with 'bot:' (named AI crawlers plus the 'bot:other' catch-all from +-- lib/tracker/categorize.ts). So humans + bots = events, exactly. +-- +-- tracker_daily_stats(project_id, day, bucket, count) is the only rollup +-- that records bot-ness; tracker_event_daily_stats has no bucket column, so +-- its pageview / interaction legs stay bot-inclusive and are left as they +-- were. The new columns come from the bucket leg only. +-- +-- Return types change here, and `create or replace` cannot change a +-- function's OUT columns, so each one is dropped and re-created. Argument +-- signatures are identical and columns are only ADDED (at the end), so +-- every existing caller keeps reading the fields it already names. +-- Dropping a function drops its grants, hence the re-grants below. +-- +-- NOT touched, on purpose: tracker_top_pages_multi, tracker_top_actions_multi +-- and tracker_top_exit_pages_multi carry `set work_mem to '16MB'` from the +-- applied tracker_reporting_indexes migration; re-creating them from an older +-- body would silently drop that setting. +-- +-- security invoker throughout, matching every other tracker_* RPC: RLS on the +-- tracker_* tables already scopes SELECT to the project owner / members. +-- Apply one file at a time via the Supabase MCP, not `db push`. + +-- --------------------------------------------------------------------------- +-- /dashboard cards: per (project, day) humans and bots for the past-7-days +-- headline and sparkline. dashboard_project_pageviews stays in place; this is +-- the bucket-aware sibling that the cards now read instead. +-- --------------------------------------------------------------------------- +create or replace function public.dashboard_project_traffic( + p_project_ids uuid[], + p_since date +) +returns table(project_id uuid, day date, humans bigint, bots bigint) +language sql +stable +security invoker +set search_path = public +as $$ + select project_id, + day, + coalesce(sum(count) filter (where bucket not like 'bot:%'), 0)::bigint as humans, + coalesce(sum(count) filter (where bucket like 'bot:%'), 0)::bigint as bots + from public.tracker_daily_stats + where project_id = any(p_project_ids) + and day >= p_since + group by project_id, day + order by project_id, day; +$$; + +grant execute on function public.dashboard_project_traffic(uuid[], date) to authenticated, service_role; + +-- --------------------------------------------------------------------------- +-- Per-project window totals (+ humans, prev_humans). +-- --------------------------------------------------------------------------- +drop function if exists public.tracker_project_totals(uuid[], integer); + +create function public.tracker_project_totals( + p_projects uuid[], + days integer default 30 +) +returns table ( + project_id uuid, + events bigint, + ai bigint, + bots bigint, + prev_events bigint, + prev_ai bigint, + prev_bots bigint, + humans bigint, + prev_humans bigint +) +language sql +stable +security invoker +set search_path = public +as $$ + -- The two windows are adjacent and equal-length: prev_end is the day before + -- cur_start, so every row scanned falls in exactly one of them. + with bounds as ( + select (today - (n - 1)) as cur_start, + (today - (2 * n - 1)) as prev_start, + (today - n) as prev_end + from ( + select greatest(coalesce(days, 30), 1) as n, + (now() at time zone 'UTC')::date as today + ) win + ) + select s.project_id, + coalesce(sum(s.count) filter (where s.day >= b.cur_start), 0)::bigint as events, + coalesce(sum(s.count) filter (where s.day >= b.cur_start and s.bucket like 'ai_referral:%'), 0)::bigint as ai, + coalesce(sum(s.count) filter (where s.day >= b.cur_start and s.bucket like 'bot:%'), 0)::bigint as bots, + coalesce(sum(s.count) filter (where s.day <= b.prev_end), 0)::bigint as prev_events, + coalesce(sum(s.count) filter (where s.day <= b.prev_end and s.bucket like 'ai_referral:%'), 0)::bigint as prev_ai, + coalesce(sum(s.count) filter (where s.day <= b.prev_end and s.bucket like 'bot:%'), 0)::bigint as prev_bots, + coalesce(sum(s.count) filter (where s.day >= b.cur_start and s.bucket not like 'bot:%'), 0)::bigint as humans, + coalesce(sum(s.count) filter (where s.day <= b.prev_end and s.bucket not like 'bot:%'), 0)::bigint as prev_humans + from public.tracker_daily_stats s + cross join bounds b + where s.project_id = any(p_projects) + and s.day >= b.prev_start + group by s.project_id; +$$; + +grant execute on function public.tracker_project_totals(uuid[], integer) to authenticated, service_role; + +-- --------------------------------------------------------------------------- +-- Single-project daily series (+ humans). +-- --------------------------------------------------------------------------- +drop function if exists public.tracker_daily_series(uuid, integer); + +create function public.tracker_daily_series( + p_project uuid, + days integer default 30 +) +returns table ( + day date, + pageviews bigint, + interactions bigint, + ai bigint, + bots bigint, + events bigint, + humans bigint +) +language sql +stable +security invoker +set search_path = public +as $$ + with since as ( + select ((now() at time zone 'UTC')::date - (greatest(coalesce(days, 30), 1) - 1)) as d + ), + ev as ( + select day, + sum(count) filter (where event = 'pageview')::bigint as pageviews, + sum(count) filter (where event <> 'pageview')::bigint as interactions + from public.tracker_event_daily_stats + where project_id = p_project and day >= (select d from since) + group by day + ), + bk as ( + select day, + sum(count) filter (where bucket like 'ai_referral:%')::bigint as ai, + sum(count) filter (where bucket like 'bot:%')::bigint as bots, + sum(count)::bigint as events, + sum(count) filter (where bucket not like 'bot:%')::bigint as humans + from public.tracker_daily_stats + where project_id = p_project and day >= (select d from since) + group by day + ) + select coalesce(ev.day, bk.day) as day, + coalesce(ev.pageviews, 0) as pageviews, + coalesce(ev.interactions, 0) as interactions, + coalesce(bk.ai, 0) as ai, + coalesce(bk.bots, 0) as bots, + coalesce(bk.events, 0) as events, + coalesce(bk.humans, 0) as humans + from ev + full outer join bk on ev.day = bk.day; +$$; + +grant execute on function public.tracker_daily_series(uuid, integer) to authenticated, service_role; + +-- --------------------------------------------------------------------------- +-- Portfolio daily series (+ humans). +-- --------------------------------------------------------------------------- +drop function if exists public.tracker_daily_series_multi(uuid[], integer); + +create function public.tracker_daily_series_multi( + p_projects uuid[], + days integer default 30 +) +returns table ( + day date, + pageviews bigint, + interactions bigint, + ai bigint, + bots bigint, + events bigint, + humans bigint +) +language sql +stable +security invoker +set search_path = public +as $$ + with since as ( + select ((now() at time zone 'UTC')::date - (greatest(coalesce(days, 30), 1) - 1)) as d + ), + ev as ( + select day, + sum(count) filter (where event = 'pageview')::bigint as pageviews, + sum(count) filter (where event <> 'pageview')::bigint as interactions + from public.tracker_event_daily_stats + where project_id = any(p_projects) and day >= (select d from since) + group by day + ), + bk as ( + select day, + sum(count) filter (where bucket like 'ai_referral:%')::bigint as ai, + sum(count) filter (where bucket like 'bot:%')::bigint as bots, + sum(count)::bigint as events, + sum(count) filter (where bucket not like 'bot:%')::bigint as humans + from public.tracker_daily_stats + where project_id = any(p_projects) and day >= (select d from since) + group by day + ) + select coalesce(ev.day, bk.day) as day, + coalesce(ev.pageviews, 0) as pageviews, + coalesce(ev.interactions, 0) as interactions, + coalesce(bk.ai, 0) as ai, + coalesce(bk.bots, 0) as bots, + coalesce(bk.events, 0) as events, + coalesce(bk.humans, 0) as humans + from ev + full outer join bk on ev.day = bk.day; +$$; + +grant execute on function public.tracker_daily_series_multi(uuid[], integer) to authenticated, service_role; + +-- --------------------------------------------------------------------------- +-- Per-project daily counts for the stacked portfolio chart (+ humans, bots). +-- Still projects x days rows; callers keep passing a narrowed id list. +-- --------------------------------------------------------------------------- +drop function if exists public.tracker_project_daily_series(uuid[], integer); + +create function public.tracker_project_daily_series( + p_projects uuid[], + days integer default 30 +) +returns table (project_id uuid, day date, events bigint, humans bigint, bots bigint) +language sql +stable +security invoker +set search_path = public +as $$ + select project_id, + day, + sum(count)::bigint as events, + coalesce(sum(count) filter (where bucket not like 'bot:%'), 0)::bigint as humans, + coalesce(sum(count) filter (where bucket like 'bot:%'), 0)::bigint as bots + from public.tracker_daily_stats + where project_id = any(p_projects) + and day >= ((now() at time zone 'UTC')::date - (greatest(coalesce(days, 30), 1) - 1)) + group by project_id, day; +$$; + +grant execute on function public.tracker_project_daily_series(uuid[], integer) to authenticated, service_role; + +-- --------------------------------------------------------------------------- +-- Sub-day series from the raw event table (+ humans). +-- --------------------------------------------------------------------------- +drop function if exists public.tracker_recent_series(uuid, integer, integer); + +create function public.tracker_recent_series( + p_project uuid, + p_minutes integer default 60, + p_bucket_seconds integer default 300 +) +returns table ( + ts timestamptz, + pageviews bigint, + interactions bigint, + ai bigint, + bots bigint, + events bigint, + humans bigint +) +language sql +stable +security invoker +set search_path = public +as $$ + with args as ( + select least(greatest(coalesce(p_minutes, 60), 1), 1440) as mins, + least(greatest(coalesce(p_bucket_seconds, 300), 60), 86400) as secs + ) + select to_timestamp( + floor(extract(epoch from e.occurred_at) / (select secs from args)) + * (select secs from args) + ) as ts, + count(*) filter (where e.event = 'pageview')::bigint as pageviews, + count(*) filter (where e.event <> 'pageview')::bigint as interactions, + count(*) filter (where e.bucket like 'ai_referral:%')::bigint as ai, + count(*) filter (where e.bucket like 'bot:%')::bigint as bots, + count(*)::bigint as events, + count(*) filter (where coalesce(e.bucket, '') not like 'bot:%')::bigint as humans + from public.tracker_events e + where e.project_id = p_project + and e.occurred_at >= now() - ((select mins from args) || ' minutes')::interval + group by 1 + order by 1; +$$; + +grant execute on function public.tracker_recent_series(uuid, integer, integer) to authenticated, service_role; diff --git a/tests/portfolio-analytics.test.ts b/tests/portfolio-analytics.test.ts index d78d955..ef843bf 100644 --- a/tests/portfolio-analytics.test.ts +++ b/tests/portfolio-analytics.test.ts @@ -26,6 +26,7 @@ function row(day: string, over: Partial = {}): TrackerSeriesRo ai: 0, bots: 0, events: 0, + humans: 0, ...over, }; } @@ -170,6 +171,27 @@ describe("buildDailyAxis", () => { ); expect(daily[0].events).toBe(10); }); + + it("sums humans per day and never backfills them from bot-inclusive totals", () => { + const daily = buildDailyAxis( + [ + // A crawled day: 3 people, 997 bot hits. + row("2026-08-09", { events: 1000, bots: 997, humans: 3 }), + row("2026-08-09", { events: 10, bots: 0, humans: 10 }), + // A pre-bucket day: the event table knows 40 hits, nobody knows how + // many were people. events gets the legacy backfill; humans stays 0. + row("2026-08-08", { pageviews: 30, interactions: 10, events: 0 }), + ], + 2, + NOW, + ); + const [older, today] = daily; + expect(today.humans).toBe(13); + expect(today.bots).toBe(997); + expect(today.events).toBe(1010); + expect(older.events).toBe(40); + expect(older.humans).toBe(0); + }); }); describe("toSeriesRow", () => { @@ -181,6 +203,7 @@ describe("toSeriesRow", () => { ai: "4", bots: "5", events: "20", + humans: "15", }); expect(coerced).toEqual({ day: "2026-08-09", @@ -189,6 +212,21 @@ describe("toSeriesRow", () => { ai: 4, bots: 5, events: 20, + humans: 15, + }); + }); + + it("derives humans = events - bots when the RPC predates the split", () => { + // Before 20260905120000_tracker_human_split is applied the column is + // absent. Both legs come from the bucket rollup, so the identity is exact. + const coerced = toSeriesRow({ + day: "2026-08-09", + pageviews: "12", + interactions: "3", + ai: "4", + bots: "5", + events: "20", }); + expect(coerced.humans).toBe(15); }); }); diff --git a/tests/tracker-human-split.test.ts b/tests/tracker-human-split.test.ts new file mode 100644 index 0000000..1977d8c --- /dev/null +++ b/tests/tracker-human-split.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from "vitest"; +import { humansFrom, toCount } from "@/lib/tracker/humans"; +import { + emptyTotals, + sumTotals, + toProjectTotals, + totalsTrends, + type TotalsRow, +} from "@/lib/tracker/totals"; +import { buildBucketAxis } from "@/lib/tracker/panels"; + +// The dashboards lead with human visits and show bot crawls apart. On one +// property 99% of ~257k weekly hits were one AI training crawler, and the +// old bot-inclusive headline read as "80k pageviews a day". These pin the +// arithmetic that keeps the two figures separate, including the period +// before the split migration is applied (no `humans` column yet). + +const NOW = new Date("2026-09-05T12:00:00Z"); + +function totalsRow(over: Partial = {}): TotalsRow { + return { + project_id: "p1", + events: 0, + ai: 0, + bots: 0, + prev_events: 0, + prev_ai: 0, + prev_bots: 0, + ...over, + }; +} + +describe("toCount", () => { + it("coerces PostgREST bigint strings and treats absent as null, not zero", () => { + expect(toCount("257000")).toBe(257000); + expect(toCount(12)).toBe(12); + expect(toCount(null)).toBeNull(); + expect(toCount(undefined)).toBeNull(); + expect(toCount("")).toBeNull(); + expect(toCount("nope")).toBeNull(); + }); +}); + +describe("humansFrom", () => { + it("prefers the humans column the split migration adds", () => { + expect(humansFrom({ humans: "3", events: "1000", bots: "997" })).toBe(3); + // Trusted even when it disagrees with the identity (e.g. 0 on purpose). + expect(humansFrom({ humans: 0, events: 50, bots: 10 })).toBe(0); + }); + + it("falls back to events - bots when the column is missing", () => { + expect(humansFrom({ events: "1000", bots: "997" })).toBe(3); + expect(humansFrom({ humans: null, events: 20, bots: 5 })).toBe(15); + }); + + it("never goes negative on a malformed row", () => { + expect(humansFrom({ events: 5, bots: 9 })).toBe(0); + }); +}); + +describe("toProjectTotals", () => { + it("reads humans and prev_humans when present", () => { + const totals = toProjectTotals( + totalsRow({ + events: "257000", + ai: "40", + bots: "254430", + humans: "2570", + prev_events: "180000", + prev_ai: "30", + prev_bots: "177600", + prev_humans: "2400", + }), + ); + expect(totals.humans).toBe(2570); + expect(totals.prevHumans).toBe(2400); + expect(totals.bots).toBe(254430); + expect(totals.events).toBe(257000); + }); + + it("derives both human figures before the migration lands", () => { + const totals = toProjectTotals( + totalsRow({ + events: 1000, + bots: 997, + prev_events: 800, + prev_bots: 700, + }), + ); + expect(totals.humans).toBe(3); + expect(totals.prevHumans).toBe(100); + }); +}); + +describe("sumTotals", () => { + it("adds every field including the human ones", () => { + const sum = sumTotals([ + { ...emptyTotals(), events: 10, bots: 4, humans: 6, prevHumans: 1 }, + { ...emptyTotals(), events: 20, bots: 5, humans: 15, prevHumans: 2, ai: 3 }, + ]); + expect(sum).toEqual({ + events: 30, + ai: 3, + bots: 9, + humans: 21, + prevEvents: 0, + prevAi: 0, + prevBots: 0, + prevHumans: 3, + }); + }); +}); + +describe("totalsTrends", () => { + it("trends humans on humans, not on the bot-inclusive total", () => { + // Humans fell by half while a crawler tripled the raw total. + const trends = totalsTrends({ + events: 300_000, + ai: 0, + bots: 299_500, + humans: 500, + prevEvents: 100_000, + prevAi: 0, + prevBots: 99_000, + prevHumans: 1000, + }); + expect(trends.humans.direction).toBe("down"); + expect(trends.humans.changePct).toBeCloseTo(-50); + expect(trends.bots.direction).toBe("up"); + expect(trends.events.direction).toBe("up"); + }); +}); + +describe("buildBucketAxis", () => { + it("carries humans through the sub-day series, derived when absent", () => { + const points = buildBucketAxis( + [ + { + ts: "2026-09-05T11:55:00Z", + pageviews: "10", + interactions: "0", + ai: "1", + bots: "7", + events: "10", + humans: "3", + }, + { + ts: "2026-09-05T11:50:00Z", + pageviews: "4", + interactions: "0", + ai: "0", + bots: "1", + events: "4", + // no humans column: pre-migration shape + }, + ], + 10, + 300, + NOW, + ); + const byIso = new Map(points.map((p) => [p.date, p])); + expect(byIso.get("2026-09-05T11:55:00.000Z")?.humans).toBe(3); + expect(byIso.get("2026-09-05T11:50:00.000Z")?.humans).toBe(3); + // Zero-filled buckets carry the field too. + expect(byIso.get("2026-09-05T12:00:00.000Z")?.humans).toBe(0); + }); +});