diff --git a/app/(app)/dashboard/ads/page.tsx b/app/(app)/dashboard/ads/page.tsx
index ea50e41..01f0b46 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 0000000..febe7ae
--- /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.
+
+
+
+
+
+
+ | Medium |
+ Impressions |
+ Share |
+ Clicks |
+ {showCtr && CTR | }
+
+
+
+ {rows.map((row) => {
+ const label = LABELS[row.media] ?? { name: row.media, hint: "" };
+ return (
+
+ |
+ {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()}
+ |
+ {showCtr && (
+
+ {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 0000000..db52f7b
--- /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 0000000..02d14b7
--- /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 0000000..c490dda
--- /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);
+ });
+});