From e69c0435f1dbf40b1b45f83169595a7e260d8243 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 06:03:17 +0000 Subject: [PATCH] feat(ads): report the presentation rotation, and refuse to fake the rate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #316 records which medium every fill was served as. Nothing read it, which made the rotation randomised delivery that taught nothing. This adds the read. A rollup rather than a query over raw events, for the reason 20260902140000 exists: ad_impressions is ~376k rows growing ~90k/day, a 30-day window selects about half the table, and the 8s statement_timeout on `authenticated` was already cancelling reporting RPCs that scanned it. A `group by media` over the same range walks straight back into that. Grain is (owner_id, day, media) — daily, not the account series' hourly, because this is a comparison table and nothing here is plotted at 4-hour buckets. The part worth arguing about is what the card does NOT show. CTR per medium is the obvious report and it is unreadable on this network: every slot and every campaign belong to one account, so clicks book as free self-deal and there has not been a valid click since 2026-07-29. Five rows of 0.000% is not a neutral presentation of that — it reads as a finished experiment that found motion worthless. So the rate column is withheld below 30 attributed clicks and replaced by a note naming the structural cause, because "not enough data yet" would tell the reader to wait and waiting will not fix it. Playback is the signal that does discriminate today (#320), and the note says so. Two denominators that would each have read as a real number if got wrong: * share is denominated on ROTATED delivery, not on everything in the window. The pre-rotation archive is far larger than anything the rotation has served, so including it would show all five arms at ~0% indefinitely. * a NULL media is 'unknown', never 'static'. Folding the archive into static would make static the permanent winner of an experiment it never ran in. Clicks whose impression_id no longer resolves land there too — ad_clicks is `on delete set null` and serveAd synthesises an id when its insert failed. A click's medium comes from the impression it came from, not from its creative: the same creative serves several arms, so inferring it would be wrong by construction. Migration applied on dev2 ahead of this, with the PostgREST schema reload. Co-Authored-By: Claude Opus 5 (1M context) --- app/(app)/dashboard/ads/page.tsx | 23 +- components/ads/media-split-card.tsx | 143 +++++++++++++ lib/ads/media-stats.ts | 181 ++++++++++++++++ .../20260925150000_ad_media_split_rollup.sql | 202 ++++++++++++++++++ tests/ads-media-stats.test.ts | 161 ++++++++++++++ 5 files changed, 709 insertions(+), 1 deletion(-) create mode 100644 components/ads/media-split-card.tsx create mode 100644 lib/ads/media-stats.ts create mode 100644 supabase/migrations/20260925150000_ad_media_split_rollup.sql create mode 100644 tests/ads-media-stats.test.ts diff --git a/app/(app)/dashboard/ads/page.tsx b/app/(app)/dashboard/ads/page.tsx index ea50e413..01f0b46d 100644 --- a/app/(app)/dashboard/ads/page.tsx +++ b/app/(app)/dashboard/ads/page.tsx @@ -7,6 +7,7 @@ import { AccountTrend } from "@/components/ads/account-trend"; import { RangeTabs } from "@/components/ads/range-tabs"; import { StatSpark } from "@/components/ads/stat-spark"; import { StatsUnavailable } from "@/components/stats-unavailable"; +import { MediaSplitCard } from "@/components/ads/media-split-card"; import { deliveredClicks, deliveredImpressions, @@ -22,6 +23,7 @@ import { type CampaignDailyPoint, type RangeTotals, } from "@/lib/ads/series"; +import { getMediaSplit, type MediaSplitRow } from "@/lib/ads/media-stats"; import { resolveRange } from "@/lib/ads/ranges"; import { campaignDisplayStatus, spendTodayCents, utcToday } from "@/lib/ads/status"; @@ -77,8 +79,13 @@ export default async function AdsPage({ let statsFailed = false; let seriesFailed = false; let dailyFailed = false; + let mediaSplit: MediaSplitRow[] = []; + // Its own flag rather than folding into statsFailed: the split reads a + // different RPC, and a card that cannot load must not make the tiles above it + // claim they could not either. + let mediaSplitFailed = false; if (user) { - const [{ data }, { data: profile }, accountSeries, campaignTotals] = await Promise.all([ + const [{ data }, { data: profile }, accountSeries, campaignTotals, split] = await Promise.all([ supabase .from("ad_campaigns") .select( @@ -92,6 +99,7 @@ export default async function AdsPage({ .maybeSingle(), getAccountSeries(supabase, range), getCampaignRangeTotals(supabase, range), + getMediaSplit(supabase, range), ]); creditsAvailable = (profile?.credits_balance ?? 0) + (profile?.ad_bonus_credits ?? 0); campaigns = (data as CampaignRow[]) ?? []; @@ -105,6 +113,8 @@ export default async function AdsPage({ seriesById = daily.data; seriesFailed = accountSeries.failed; dailyFailed = daily.failed; + mediaSplit = split.data; + mediaSplitFailed = split.failed; statsFailed = accountSeries.failed || campaignTotals.failed || daily.failed; } @@ -195,6 +205,17 @@ export default async function AdsPage({
+ + {/* Which presentation the server chose, per fill. Below the chart + because it explains the delivery the chart plots rather than adding + a measure of its own. */} + {mediaSplitFailed ? ( +
+ +
+ ) : ( + + )} )} diff --git a/components/ads/media-split-card.tsx b/components/ads/media-split-card.tsx new file mode 100644 index 00000000..febe7ae8 --- /dev/null +++ b/components/ads/media-split-card.tsx @@ -0,0 +1,143 @@ +import { + attributedClicks, + ctrReadable, + ctrUnreadableNote, + rotatedImpressions, + UNATTRIBUTED, + type MediaSplitRow, +} from "@/lib/ads/media-stats"; + +/** + * How delivery split between the media a slot rotates through. + * + * Separate from the impressions/clicks tiles above it for the same reason the + * playback card is: those count the network's delivery in total, this one counts + * how that delivery was *presented*. A publisher's embed names a size and never + * a medium, so this is the only place the choice the server made is visible. + * + * The CTR column is deliberately withheld rather than shown as zeros. On a + * network with no third-party demand every arm reads 0.000%, which looks like a + * completed experiment that found motion worthless — the most likely way this + * table gets misread. See ctrUnreadableNote. + */ + +const LABELS: Record = { + static: { name: "Static", hint: "Code-drawn unit on a brand wash. No asset." }, + image: { name: "Hero image", hint: "The same unit with the advertiser's artwork." }, + gif: { name: "Animated", hint: "The rendered GIF at the slot's exact size." }, + video: { name: "In-banner video", hint: "Muted, looping MP4 with the CTA beneath." }, + audio: { name: "Audible companion", hint: "The unit plus a click-to-play control." }, + [UNATTRIBUTED]: { + name: "Unattributed", + hint: "Served before the rotation shipped, or a click whose impression cannot be resolved. Excluded from every share and rate.", + }, +}; + +export function MediaSplitCard({ + rows, + rangeHint, +}: { + rows: MediaSplitRow[]; + rangeHint?: string; +}) { + const delivery = rotatedImpressions(rows); + const unattributed = rows.find((r) => !r.rotated); + + // Nothing rotated in this window. The card would be a header over an empty + // table, and the tiles above already say whether there was any delivery. + if (delivery === 0 && !unattributed) return null; + + const showCtr = ctrReadable(rows); + const note = ctrUnreadableNote(rows); + const clicks = attributedClicks(rows); + const pct = (v: number) => `${(v * 100).toFixed(1)}%`; + + return ( +
+
+

Delivery by medium

+ {rangeHint && ( + {rangeHint} + )} +
+

+ Your embed names a size, not a medium — the server picks one per fill from + whatever each campaign has rendered. Only the rectangle can carry all five; + a leaderboard takes no video and the mobile strip takes neither video nor + artwork. +

+ +
+ + + + + + + + {showCtr && } + + + + {rows.map((row) => { + const label = LABELS[row.media] ?? { name: row.media, hint: "" }; + return ( + + + + + + {showCtr && ( + + )} + + ); + })} + +
MediumImpressionsShareClicksCTR
+
{label.name}
+ {label.hint && ( +
{label.hint}
+ )} +
+ {row.impressions.toLocaleString()} + + {row.rotated ? ( +
+ {/* The bar is the whole point of the column: five + near-equal numbers is the rotation working, and a + lopsided one is a campaign pool that has not + rendered. That reads instantly and the percentages + do not. */} +
+
+
+ {pct(row.share)} +
+ ) : ( + — + )} +
+ {(row.clicks + row.freeClicks).toLocaleString()} + + {row.ctr === null ? "—" : pct(row.ctr)} +
+
+ + {note &&

{note}

} + + {showCtr && ( +

+ {clicks.toLocaleString()} clicks attributed. A rate this early separates + the arms loosely at best — read it as a direction, not a verdict. +

+ )} +
+ ); +} diff --git a/lib/ads/media-stats.ts b/lib/ads/media-stats.ts new file mode 100644 index 00000000..db52f7bb --- /dev/null +++ b/lib/ads/media-stats.ts @@ -0,0 +1,181 @@ +// Reading the presentation rotation: how delivery split between media, and +// whether that split can be judged yet. +// +// The second half is the point. #316 rotates a slot between up to five media so +// that we can find out which one a size converts in, and the obvious report — +// CTR per medium — is unreadable on this network today: every slot and every +// campaign belong to the same account, so clicks book as free self-deal and the +// last valid click was 2026-07-29. A table of five 0.000% rows invites exactly +// the wrong conclusion ("motion does nothing"), so this module reports the +// delivery mix as fact and is explicit about the rate being unavailable rather +// than zero. +// +// The rates themselves are derived here rather than in SQL so every surface — +// the dashboard, the CLI, the API — divides the same way. + +import type { SupabaseClient } from "@supabase/supabase-js"; +import { rangeSince, type RangeDef } from "./ranges"; +import { rpcFailed, type Loaded } from "@/lib/loaded"; +import { AD_MEDIA_KINDS, type AdMediaKind } from "./media"; + +/** + * The bucket the rollup uses for delivery that belongs to no arm: an impression + * served before the rotation existed (media NULL), or a click whose impression + * can no longer be resolved. Reported, never included in a share or a rate — + * counting the pre-rotation archive as 'static' would make static the permanent + * winner of an experiment it never ran in. + */ +export const UNATTRIBUTED = "unknown"; + +export type MediaSplitRow = { + media: string; + /** True for the five real arms; false for UNATTRIBUTED. */ + rotated: boolean; + /** Paid + free. Everything actually shown, which is what a mix describes. */ + impressions: number; + paidImpressions: number; + freeImpressions: number; + clicks: number; + freeClicks: number; + spentCents: number; + /** Share of rotated delivery, 0-1. Zero for UNATTRIBUTED. */ + share: number; + /** clicks / impressions, or null when there is nothing to divide. */ + ctr: number | null; +}; + +type Row = { + media: string | null; + impressions: number | string; + free_impressions: number | string; + clicks: number | string; + free_clicks: number | string; + spent_cents: number | string; +}; + +const n = (v: number | string | null | undefined): number => Number(v) || 0; + +function isRotated(media: string): media is AdMediaKind { + return (AD_MEDIA_KINDS as readonly string[]).includes(media); +} + +/** + * Shape the RPC's rows into the table the UI draws. + * + * Pure, so the share and rate arithmetic is testable without a database — which + * matters because both have an edge case that reads as a real number if it is + * got wrong: a share denominated on total delivery (including UNATTRIBUTED) + * would make every arm look tiny while the archive dominates, and a CTR of 0 + * where there were no clicks to count is indistinguishable from a medium nobody + * clicked. + */ +export function mediaSplitRows(rows: Row[]): MediaSplitRow[] { + const mapped = rows.map((r) => { + const media = r.media ?? UNATTRIBUTED; + const paidImpressions = n(r.impressions); + const freeImpressions = n(r.free_impressions); + const clicks = n(r.clicks); + const freeClicks = n(r.free_clicks); + const impressions = paidImpressions + freeImpressions; + return { + media, + rotated: isRotated(media), + impressions, + paidImpressions, + freeImpressions, + clicks, + freeClicks, + spentCents: n(r.spent_cents), + share: 0, + // Null, not 0: "nobody clicked this" and "nothing was measured" are + // different findings and only one of them is about the medium. + ctr: impressions > 0 ? (clicks + freeClicks) / impressions : null, + }; + }); + + // Denominated on rotated delivery only, so the shares of the five arms sum to + // 1 regardless of how much pre-rotation archive the window happens to include. + const rotatedTotal = mapped + .filter((r) => r.rotated) + .reduce((a, r) => a + r.impressions, 0); + + for (const row of mapped) { + row.share = row.rotated && rotatedTotal > 0 ? row.impressions / rotatedTotal : 0; + } + + // Biggest arm first, and the unattributed bucket always last — it is context, + // not a competitor. + return mapped.sort((a, b) => { + if (a.rotated !== b.rotated) return a.rotated ? -1 : 1; + return b.impressions - a.impressions; + }); +} + +/** Rotated delivery in the window — the denominator, and whether there is one. */ +export function rotatedImpressions(rows: MediaSplitRow[]): number { + return rows.filter((r) => r.rotated).reduce((a, r) => a + r.impressions, 0); +} + +/** Every click attributed to a medium in the window, paid or free. */ +export function attributedClicks(rows: MediaSplitRow[]): number { + return rows.filter((r) => r.rotated).reduce((a, r) => a + r.clicks + r.freeClicks, 0); +} + +/** + * Whether the CTR column means anything yet. + * + * Deliberately a hard gate rather than a caveat in small print. With no clicks + * at all, every arm reads 0.000% and the table looks like a finished experiment + * that found nothing — which is the single most likely way this feature gets + * misread. Below the threshold the UI shows the mix and hides the rate. + * + * 30 is not a power calculation; it is the point below which a rate would be + * noise whatever it said. A real decision between five arms needs far more, and + * the note says so. + */ +export const MIN_CLICKS_TO_COMPARE = 30; + +export function ctrReadable(rows: MediaSplitRow[]): boolean { + return attributedClicks(rows) >= MIN_CLICKS_TO_COMPARE; +} + +/** + * One line on why the rate column is missing, or undefined when it is shown. + * + * Names the actual reason rather than "not enough data": on this network the + * cause is structural (no third-party demand, so no billable clicks) and will + * not fix itself by waiting, which is a different instruction to the reader + * than "come back tomorrow". + */ +export function ctrUnreadableNote(rows: MediaSplitRow[]): string | undefined { + if (ctrReadable(rows)) return undefined; + const clicks = attributedClicks(rows); + const delivery = rotatedImpressions(rows); + if (delivery === 0) { + return "No rotated delivery in this range yet — the mix appears once slots start serving."; + } + if (clicks === 0) { + return `${delivery.toLocaleString()} impressions across the media above and no clicks yet, so there is no rate to compare. Click-through cannot separate these arms until a third-party advertiser exists: every campaign and slot on the network share one account, so clicks book as free self-deal. Playback (start and completion on the video and audio arms) is the signal that does work today.`; + } + return `Only ${clicks.toLocaleString()} click${clicks === 1 ? "" : "s"} attributed so far — under ${MIN_CLICKS_TO_COMPARE}, a per-medium rate is noise. Showing the delivery mix only.`; +} + +/** + * Delivery per medium over a range, for the signed-in advertiser's campaigns. + * + * Reads the rollup-backed RPC rather than raw events: ad_impressions is ~376k + * rows growing ~90k/day and a `group by media` over a 30-day window is the + * scan that 20260902140000 exists to avoid. + */ +export async function getMediaSplit( + supabase: SupabaseClient, + range: RangeDef, + now: Date = new Date(), +): Promise> { + const { data, error } = await supabase.rpc("ad_owner_media_split", { + p_since: rangeSince(range, now), + }); + + const failed = rpcFailed("ads", "ad_owner_media_split", error); + return { data: failed ? [] : mediaSplitRows((data as Row[]) ?? []), failed }; +} diff --git a/supabase/migrations/20260925150000_ad_media_split_rollup.sql b/supabase/migrations/20260925150000_ad_media_split_rollup.sql new file mode 100644 index 00000000..02d14b7f --- /dev/null +++ b/supabase/migrations/20260925150000_ad_media_split_rollup.sql @@ -0,0 +1,202 @@ +-- Reporting the presentation rotation: delivery and clicks per medium. +-- +-- #316 started choosing a medium per fill and recording it on +-- ad_impressions.media. That column is the whole return on rotating — without a +-- read of it the feature is randomised delivery that teaches nothing — and +-- nothing consumed it until this migration. +-- +-- It is a rollup rather than a query over raw events for the reason +-- 20260902140000 exists: ad_impressions is ~376k rows growing ~90k/day, a 30-day +-- window selects about half the table, and the 8s statement_timeout on +-- `authenticated` was already cancelling reporting RPCs that scanned it. Adding +-- a `group by media` over the same raw range would walk straight back into that. +-- +-- GRAIN: (owner_id, day, media). Daily rather than the account series' hourly +-- because this is a comparison table, not a plotted series — nothing here is +-- drawn at 4-hour buckets, so the finer grain would cost rows and buy nothing. +-- At six possible media that is ~6 rows per owner per day. +-- +-- NOTE: prod migration history diverged — apply this single file via psql over +-- the pooler, do NOT `supabase db push`. And send +-- `notify pgrst, 'reload schema';` afterwards or PostgREST will not see the new +-- function. + +-- 1. The rollup. -------------------------------------------------------------- +-- +-- `media` is not null so it can carry the primary key, so the two genuinely +-- unattributable cases are folded into one honest bucket rather than dropped: +-- +-- * an impression served before #316 shipped, whose media column is NULL. It +-- belongs to no arm and must not be counted as 'static' — that would put +-- the entire pre-rotation archive into one arm of the experiment and make +-- static look like the runaway winner forever. +-- * a click whose impression_id is null or no longer resolves. ad_clicks has +-- `on delete set null`, and serveAd synthesises an impression id when its +-- own insert failed, so a click can exist with nothing to attribute it to. +-- +-- Both land in 'unknown', which the dashboard reports separately and never +-- includes in a share or a rate. +create table if not exists public.ad_stats_owner_media_daily ( + owner_id uuid not null, + day date not null, + media text not null, + paid_impressions bigint not null default 0, + free_impressions bigint not null default 0, + valid_clicks bigint not null default 0, + free_clicks bigint not null default 0, + spent_cents bigint not null default 0, + primary key (owner_id, day, media) +); + +alter table public.ad_stats_owner_media_daily enable row level security; + +-- Read through the SECURITY DEFINER RPC below, exactly as the other rollups are. +-- No policy: the rollups are not addressable directly by `authenticated`. + +-- 2. Refresh, extending the existing one. ------------------------------------- +-- +-- Same contract as ad_stats_rollup_refresh: a full recompute of the touched +-- periods rather than a delta, because ad_clicks rows are updated after insert +-- (charged, or later invalidated) and counting only new rows would drift. +create or replace function public.ad_stats_media_rollup_refresh( + p_from timestamptz default (now() - interval '2 days') +) returns void +language plpgsql +security definer +set search_path to 'public' +as $fn$ +declare + -- Snap down to a whole day before recomputing: p_from lands mid-day, and + -- filtering raw events on it directly would upsert a partial count over the + -- complete row already stored for that day. + v_from_day timestamptz := date_trunc('day', p_from at time zone 'UTC') at time zone 'UTC'; +begin + insert into public.ad_stats_owner_media_daily as t + (owner_id, day, media, paid_impressions, free_impressions, + valid_clicks, free_clicks, spent_cents) + select c.owner_id, + (e.ts at time zone 'UTC')::date, + e.media, + sum(e.paid)::bigint, sum(e.free)::bigint, sum(e.clk)::bigint, + sum(e.fclk)::bigint, sum(e.spent)::bigint + from ( + select i.campaign_id, i.ts, coalesce(i.media, 'unknown') as media, + case when i.tier = 'free' then 0 else 1 end as paid, + case when i.tier = 'free' then 1 else 0 end as free, + 0 as clk, 0 as fclk, 0 as spent + from public.ad_impressions i + where not i.duplicate and i.ts >= v_from_day + union all + -- A click's medium is the medium of the impression it came from: the click + -- row has no presentation of its own, and inferring one from the creative + -- would be wrong the moment the same creative serves two arms. + select cl.campaign_id, cl.ts, coalesce(i.media, 'unknown'), + 0, 0, + case when cl.valid then 1 else 0 end, + case when not cl.valid and cl.tier = 'free' then 1 else 0 end, + case when cl.valid then coalesce(cl.charged_cents, 0) else 0 end + from public.ad_clicks cl + left join public.ad_impressions i on i.id = cl.impression_id + where cl.ts >= v_from_day + ) e + join public.ad_campaigns c on c.id = e.campaign_id + group by 1, 2, 3 + on conflict (owner_id, day, media) do update set + paid_impressions = excluded.paid_impressions, + free_impressions = excluded.free_impressions, + valid_clicks = excluded.valid_clicks, + free_clicks = excluded.free_clicks, + spent_cents = excluded.spent_cents; +end; +$fn$; + +revoke all on function public.ad_stats_media_rollup_refresh(timestamptz) from public; + +-- 3. The read. ---------------------------------------------------------------- +-- +-- Closed days come from the rollup, the current UTC day (and any leading +-- partial day when p_since lands mid-day) from raw — the same exact split the +-- other reporting RPCs use, so the live edge is at most one day wide however +-- long the window. +create or replace function public.ad_owner_media_split( + p_since timestamptz default null +) returns table( + media text, impressions bigint, free_impressions bigint, + clicks bigint, free_clicks bigint, spent_cents bigint +) +language plpgsql +stable +security definer +set search_path to 'public' +as $$ +declare + uid uuid := auth.uid(); + today_start timestamptz := date_trunc('day', now() at time zone 'UTC') at time zone 'UTC'; + first_full_day date; +begin + if uid is null then + return; + end if; + + first_full_day := case + when p_since is null then null + when p_since = date_trunc('day', p_since at time zone 'UTC') at time zone 'UTC' + then (p_since at time zone 'UTC')::date + else ((p_since at time zone 'UTC')::date + 1) + end; + + return query + with ev as ( + select r.media, r.paid_impressions as paid, r.free_impressions as free, + r.valid_clicks as clk, r.free_clicks as fclk, r.spent_cents as spent + from public.ad_stats_owner_media_daily r + where r.owner_id = uid + and r.day < (today_start at time zone 'UTC')::date + and (first_full_day is null or r.day >= first_full_day) + union all + select coalesce(i.media, 'unknown'), + case when i.tier = 'free' then 0 else 1 end, + case when i.tier = 'free' then 1 else 0 end, + 0, 0, 0 + from public.ad_impressions i + join public.ad_campaigns c on c.id = i.campaign_id and c.owner_id = uid + where not i.duplicate + and i.ts >= greatest(today_start, coalesce(p_since, today_start)) + and (p_since is null or i.ts >= p_since) + union all + select coalesce(i.media, 'unknown'), 0, 0, + case when cl.valid then 1 else 0 end, + case when not cl.valid and cl.tier = 'free' then 1 else 0 end, + case when cl.valid then coalesce(cl.charged_cents, 0) else 0 end + from public.ad_clicks cl + join public.ad_campaigns c on c.id = cl.campaign_id and c.owner_id = uid + left join public.ad_impressions i on i.id = cl.impression_id + where cl.ts >= greatest(today_start, coalesce(p_since, today_start)) + and (p_since is null or cl.ts >= p_since) + ) + select ev.media, sum(ev.paid)::bigint, sum(ev.free)::bigint, + sum(ev.clk)::bigint, sum(ev.fclk)::bigint, sum(ev.spent)::bigint + from ev group by ev.media order by (sum(ev.paid) + sum(ev.free)) desc; +end; +$$; + +revoke all on function public.ad_owner_media_split(timestamptz) from public; +grant execute on function public.ad_owner_media_split(timestamptz) to authenticated; + +-- 4. Schedule the refresh beside the existing one. ---------------------------- +-- +-- Guarded: pg_cron is present on the self-hosted stack, but a migration that +-- hard-depends on it cannot be applied anywhere it is not. +do $$ +begin + if exists (select 1 from pg_extension where extname = 'pg_cron') then + perform cron.unschedule('ad-stats-media-rollup') + where exists (select 1 from cron.job where jobname = 'ad-stats-media-rollup'); + perform cron.schedule( + 'ad-stats-media-rollup', + '7,37 * * * *', + $cmd$select public.ad_stats_media_rollup_refresh();$cmd$ + ); + end if; +end +$$; diff --git a/tests/ads-media-stats.test.ts b/tests/ads-media-stats.test.ts new file mode 100644 index 00000000..c490dda7 --- /dev/null +++ b/tests/ads-media-stats.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from "vitest"; +import { + attributedClicks, + ctrReadable, + ctrUnreadableNote, + mediaSplitRows, + rotatedImpressions, + MIN_CLICKS_TO_COMPARE, + UNATTRIBUTED, +} from "@/lib/ads/media-stats"; + +/** An RPC row, with the zeros the RPC would actually send. */ +function row( + media: string | null, + over: Partial<{ + impressions: number; + free_impressions: number; + clicks: number; + free_clicks: number; + spent_cents: number; + }> = {}, +) { + return { + media, + impressions: 0, + free_impressions: 0, + clicks: 0, + free_clicks: 0, + spent_cents: 0, + ...over, + }; +} + +describe("shaping the split", () => { + it("counts paid and free together, because a mix describes what was shown", () => { + // Every fill on this network books free, so a paid-only impressions figure + // would report the whole card as zeros — the #199 bug, one surface later. + const [r] = mediaSplitRows([row("gif", { impressions: 4, free_impressions: 96 })]); + expect(r.impressions).toBe(100); + expect(r.paidImpressions).toBe(4); + expect(r.freeImpressions).toBe(96); + }); + + it("denominates share on rotated delivery, not on everything in the window", () => { + // The pre-rotation archive is much larger than anything the rotation has + // served. Including it would make all five arms read ~0% forever. + const rows = mediaSplitRows([ + row("static", { free_impressions: 60 }), + row("gif", { free_impressions: 40 }), + row(UNATTRIBUTED, { free_impressions: 100_000 }), + ]); + const byMedia = Object.fromEntries(rows.map((r) => [r.media, r])); + expect(byMedia.static.share).toBeCloseTo(0.6); + expect(byMedia.gif.share).toBeCloseTo(0.4); + // Context, not a competitor. + expect(byMedia[UNATTRIBUTED].share).toBe(0); + expect(byMedia[UNATTRIBUTED].rotated).toBe(false); + }); + + it("has the five arms' shares sum to 1", () => { + const rows = mediaSplitRows([ + row("static", { free_impressions: 7 }), + row("image", { free_impressions: 5 }), + row("gif", { free_impressions: 3 }), + row("video", { free_impressions: 2 }), + row("audio", { free_impressions: 1 }), + row(UNATTRIBUTED, { free_impressions: 900 }), + ]); + const total = rows.filter((r) => r.rotated).reduce((a, r) => a + r.share, 0); + expect(total).toBeCloseTo(1); + }); + + it("treats a null medium as unattributed rather than as static", () => { + // Folding the archive into 'static' would make static the permanent winner + // of an experiment it never ran in. + const [r] = mediaSplitRows([row(null, { free_impressions: 10 })]); + expect(r.media).toBe(UNATTRIBUTED); + expect(r.rotated).toBe(false); + }); + + it("reports no CTR at all where there were no impressions to divide by", () => { + // null, not 0: "nobody clicked this" and "nothing was measured" are + // different findings, and only one of them is about the medium. + const [r] = mediaSplitRows([row("video")]); + expect(r.ctr).toBeNull(); + }); + + it("computes CTR over delivered impressions and delivered clicks", () => { + const [r] = mediaSplitRows([ + row("gif", { free_impressions: 200, clicks: 1, free_clicks: 3 }), + ]); + expect(r.ctr).toBeCloseTo(4 / 200); + }); + + it("orders arms by delivery and keeps the unattributed bucket last", () => { + const rows = mediaSplitRows([ + row(UNATTRIBUTED, { free_impressions: 10_000 }), + row("gif", { free_impressions: 5 }), + row("static", { free_impressions: 50 }), + ]); + expect(rows.map((r) => r.media)).toEqual(["static", "gif", UNATTRIBUTED]); + }); + + it("tolerates the strings PostgREST sends for bigint", () => { + const [r] = mediaSplitRows([ + { media: "gif", impressions: "5", free_impressions: "7", clicks: "2", free_clicks: "0", spent_cents: "13" }, + ]); + expect(r.impressions).toBe(12); + expect(r.clicks).toBe(2); + expect(r.spentCents).toBe(13); + }); +}); + +describe("whether the rate can be read", () => { + const delivered = (n: number) => mediaSplitRows([row("gif", { free_impressions: n })]); + + it("withholds CTR when nothing has been clicked", () => { + const rows = delivered(5000); + expect(ctrReadable(rows)).toBe(false); + const note = ctrUnreadableNote(rows); + // The note has to name the structural cause. "Not enough data yet" would + // tell the reader to wait, and waiting will not fix a network whose every + // campaign and slot share one account. + expect(note).toMatch(/no clicks yet/); + expect(note).toMatch(/self-deal/); + expect(note).toMatch(/Playback/); + }); + + it("withholds CTR on a click count too small to mean anything", () => { + const rows = mediaSplitRows([ + row("gif", { free_impressions: 5000, free_clicks: MIN_CLICKS_TO_COMPARE - 1 }), + ]); + expect(ctrReadable(rows)).toBe(false); + expect(ctrUnreadableNote(rows)).toMatch(/noise/); + }); + + it("shows CTR once there are enough clicks to compare", () => { + const rows = mediaSplitRows([ + row("gif", { free_impressions: 5000, free_clicks: MIN_CLICKS_TO_COMPARE }), + ]); + expect(ctrReadable(rows)).toBe(true); + expect(ctrUnreadableNote(rows)).toBeUndefined(); + }); + + it("says the mix is simply absent when nothing has rotated", () => { + const rows = mediaSplitRows([row(UNATTRIBUTED, { free_impressions: 900 })]); + expect(rotatedImpressions(rows)).toBe(0); + expect(ctrUnreadableNote(rows)).toMatch(/No rotated delivery/); + }); + + it("counts only attributed clicks toward readability", () => { + // A pile of clicks that cannot be tied to a medium must not unlock a + // per-medium rate. + const rows = mediaSplitRows([ + row("gif", { free_impressions: 100 }), + row(UNATTRIBUTED, { free_clicks: 10_000 }), + ]); + expect(attributedClicks(rows)).toBe(0); + expect(ctrReadable(rows)).toBe(false); + }); +});