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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions components/ads/media-split-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
attributedClicks,
ctrReadable,
ctrUnreadableNote,
FIXED_FORMAT,
rotatedImpressions,
UNATTRIBUTED,
type MediaSplitRow,
Expand All @@ -27,6 +28,10 @@ const LABELS: Record<string, { name: string; hint: string }> = {
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." },
[FIXED_FORMAT]: {
name: "Fixed format",
hint: "Feed items, text links and terminal units. One presentation, nothing to rotate. Excluded from every share and rate.",
},
[UNATTRIBUTED]: {
name: "Unattributed",
hint: "Served before the rotation shipped, or a click whose impression cannot be resolved. Excluded from every share and rate.",
Expand All @@ -41,11 +46,11 @@ export function MediaSplitCard({
rangeHint?: string;
}) {
const delivery = rotatedImpressions(rows);
const unattributed = rows.find((r) => !r.rotated);
const context = rows.some((r) => !r.rotated && r.impressions + r.clicks + r.freeClicks > 0);

// 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;
if (delivery === 0 && !context) return null;

const showCtr = ctrReadable(rows);
const note = ctrUnreadableNote(rows);
Expand Down
30 changes: 21 additions & 9 deletions lib/ads/media-stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ import { AD_MEDIA_KINDS, type AdMediaKind } from "./media";
*/
export const UNATTRIBUTED = "unknown";

/**
* The bucket for fills whose format has one presentation and nothing to rotate:
* feed items, text links, terminal units (see rotatesMedia). They serve as
* 'static' by construction, and they are most of the network's delivery, so
* booking them as the static arm made static win an arm it could not lose
* ("Static 98.7%" while a 300x250 split 46% static). Reported, never shared or
* rated. Bucketed in SQL by ad_media_bucket.
*/
export const FIXED_FORMAT = "fixed";

export type MediaSplitRow = {
media: string;
/** True for the five real arms; false for UNATTRIBUTED. */
Expand All @@ -40,7 +50,9 @@ export type MediaSplitRow = {
spentCents: number;
/** Share of rotated delivery, 0-1. Zero for UNATTRIBUTED. */
share: number;
/** clicks / impressions, or null when there is nothing to divide. */
/** clicks / impressions for a rotated arm; null when there is nothing to
* divide, and always null for FIXED_FORMAT and UNATTRIBUTED, which are
* context rather than arms and would otherwise show a crawler's CTR. */
ctr: number | null;
};

Expand Down Expand Up @@ -77,9 +89,10 @@ export function mediaSplitRows(rows: Row[]): MediaSplitRow[] {
const clicks = n(r.clicks);
const freeClicks = n(r.free_clicks);
const impressions = paidImpressions + freeImpressions;
const rotated = isRotated(media);
return {
media,
rotated: isRotated(media),
rotated,
impressions,
paidImpressions,
freeImpressions,
Expand All @@ -89,7 +102,7 @@ export function mediaSplitRows(rows: Row[]): MediaSplitRow[] {
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,
ctr: rotated && impressions > 0 ? (clicks + freeClicks) / impressions : null,
};
});

Expand All @@ -103,12 +116,11 @@ export function mediaSplitRows(rows: Row[]): MediaSplitRow[] {
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;
});
// Biggest arm first, then fixed-format delivery, and the unattributed bucket
// always last — both are context, not competitors.
const rank = (r: { rotated: boolean; media: string }) =>
r.rotated ? 0 : r.media === FIXED_FORMAT ? 1 : 2;
return mapped.sort((a, b) => rank(a) - rank(b) || b.impressions - a.impressions);
}

/** Rotated delivery in the window — the denominator, and whether there is one. */
Expand Down
168 changes: 168 additions & 0 deletions supabase/migrations/20260928120000_ad_media_split_fixed_formats.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
-- Delivery by medium: take the formats that cannot rotate out of the experiment.
--
-- 20260925150000 booked every fill under the medium it was served as, and a
-- feed item, a text link and a terminal unit are always served as 'static' —
-- they have exactly one presentation (mediaKindsForFormat in lib/ads/media.ts).
-- Those formats are ~95% of the network's fills, so the card read
-- "Static 98.7%" while a 300x250 was actually splitting 46% static, which is
-- what a pool with ~40% of campaigns rendered should produce. Static was
-- winning an arm it could not lose.
--
-- The clicks went the same way: every click in the window is invalid, and the
-- bulk are crawlers replaying feed-item links from impressions days to weeks
-- old. Counted as 'static' (or as 'unknown' when the impression predates the
-- media column) they lifted the table over the 30-click gate and put a CTR
-- column on five arms that had six banner clicks between them.
--
-- Fix: a fourth bucket, 'fixed', for any fill whose creative's format does not
-- rotate. It is reported and never shared or rated, like 'unknown'. The format
-- comes from the creative (impressions and clicks both carry creative_id), so a
-- click whose impression was deleted still lands in the right bucket.
--
-- The rotating formats are listed here by hand; tests/contract/
-- ads-media-split-fixed-formats pins the list to rotatesMedia().

create or replace function public.ad_media_bucket(p_format text, p_media text)
returns text
language sql
immutable
as $$
select case
when p_format in ('banner_300x250', 'banner_728x90', 'banner_320x50')
then coalesce(p_media, 'unknown')
else 'fixed'
end;
$$;

-- Refresh. Now DELETES the window before re-inserting: an upsert only touches
-- the keys it produces, so a (owner, day, 'static') row computed under the old
-- bucketing would otherwise sit beside the new 'fixed' row forever.
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
v_from_day timestamptz := date_trunc('day', p_from at time zone 'UTC') at time zone 'UTC';
begin
delete from public.ad_stats_owner_media_daily
where day >= (v_from_day at time zone 'UTC')::date;

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, public.ad_media_bucket(cr.format, i.media) 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
join public.ad_creatives cr on cr.id = i.creative_id
where not i.duplicate and i.ts >= v_from_day
union all
select cl.campaign_id, cl.ts, public.ad_media_bucket(cr.format, i.media),
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_creatives cr on cr.id = cl.creative_id
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;

-- The read: unchanged apart from the live edge using the same bucketing.
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 public.ad_media_bucket(cr.format, i.media),
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
join public.ad_creatives cr on cr.id = i.creative_id
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 public.ad_media_bucket(cr.format, i.media), 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
join public.ad_creatives cr on cr.id = cl.creative_id
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;

-- Rebuild the whole rollup under the new bucketing, from the oldest raw event
-- rather than from the rollup's own first day: 20260925150000 only filled the
-- two days before it shipped, so a "last 30 days" card was reading one week.
select public.ad_stats_media_rollup_refresh(
least((select min(ts) from public.ad_impressions),
(select min(ts) from public.ad_clicks),
now() - interval '2 days')
);
35 changes: 35 additions & 0 deletions tests/ads-media-stats.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
rotatedImpressions,
MIN_CLICKS_TO_COMPARE,
UNATTRIBUTED,
FIXED_FORMAT,
} from "@/lib/ads/media-stats";

/** An RPC row, with the zeros the RPC would actually send. */
Expand All @@ -32,6 +33,40 @@ function row(
}

describe("shaping the split", () => {
it("keeps fixed-format delivery out of the static arm's share", () => {
// The production shape on 2026-09-28: feed items are ~95% of fills and can
// only render static. Counted as the static arm they read "Static 98.7%".
const rows = mediaSplitRows([
row("static", { free_impressions: 46 }),
row("image", { free_impressions: 30 }),
row("gif", { free_impressions: 24 }),
row(FIXED_FORMAT, { free_impressions: 190_000, free_clicks: 2_000 }),
]);
const byMedia = Object.fromEntries(rows.map((r) => [r.media, r]));
expect(byMedia.static.share).toBeCloseTo(0.46);
expect(byMedia[FIXED_FORMAT].rotated).toBe(false);
expect(byMedia[FIXED_FORMAT].share).toBe(0);
// A crawler replaying feed-item links is not a rate for anything...
expect(byMedia[FIXED_FORMAT].ctr).toBeNull();
// ...and its clicks do not open the CTR gate for the real arms.
expect(ctrReadable(rows)).toBe(false);
});

it("gives the unattributed bucket no rate", () => {
const [r] = mediaSplitRows([row(UNATTRIBUTED, { free_impressions: 100, free_clicks: 8 })]);
expect(r.ctr).toBeNull();
});

it("orders arms, then fixed format, then unattributed", () => {
const rows = mediaSplitRows([
row(UNATTRIBUTED, { free_impressions: 9_000 }),
row(FIXED_FORMAT, { free_impressions: 90_000 }),
row("gif", { free_impressions: 5 }),
row("static", { free_impressions: 10 }),
]);
expect(rows.map((r) => r.media)).toEqual(["static", "gif", FIXED_FORMAT, UNATTRIBUTED]);
});

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.
Expand Down
28 changes: 28 additions & 0 deletions tests/contract/ads-media-split-fixed-formats.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { AD_FORMAT_IDS } from "@/lib/ads/formats";
import { rotatesMedia } from "@/lib/ads/media";

// ad_media_bucket (20260928120000) names the formats that rotate by hand, in
// SQL, because the rollup cannot call TypeScript. If a format gains a second
// presentation in MEDIA_BY_FORMAT and the SQL is not updated, every fill of it
// is booked as 'fixed' and the rotation it now runs is invisible — or, the
// other way round, a one-presentation format lands in the static arm and makes
// it read ~99% again. Pin the two lists together.

const MIGRATION = join(
process.cwd(),
"supabase/migrations/20260928120000_ad_media_split_fixed_formats.sql",
);

describe("ad_media_bucket's rotating formats", () => {
it("are exactly the formats rotatesMedia() says rotate", () => {
const sql = readFileSync(MIGRATION, "utf8");
const m = sql.match(/p_format in \(([^)]*)\)/);
expect(m).not.toBeNull();
const inSql = [...m![1].matchAll(/'([^']+)'/g)].map((x) => x[1]).sort();
const rotating = AD_FORMAT_IDS.filter(rotatesMedia).sort();
expect(inSql).toEqual(rotating);
});
});