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
23 changes: 22 additions & 1 deletion app/(app)/dashboard/ads/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";

Expand Down Expand Up @@ -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(
Expand All @@ -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[]) ?? [];
Expand All @@ -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;
}

Expand Down Expand Up @@ -195,6 +205,17 @@ export default async function AdsPage({
<div className="mt-4">
<AccountTrend data={series} range={range} failed={seriesFailed} />
</div>

{/* 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 ? (
<div className="mt-4">
<StatsUnavailable what="the delivery-by-medium split" />
</div>
) : (
<MediaSplitCard rows={mediaSplit} rangeHint={range.hint} />
)}
</>
)}

Expand Down
143 changes: 143 additions & 0 deletions components/ads/media-split-card.tsx
Original file line number Diff line number Diff line change
@@ -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<string, { name: string; hint: string }> = {
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 (
<div className="card mt-4 p-4">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h2 className="font-semibold">Delivery by medium</h2>
{rangeHint && (
<span className="text-xs text-[var(--color-muted)]">{rangeHint}</span>
)}
</div>
<p className="mt-1 text-xs text-[var(--color-muted)]">
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.
</p>

<div className="mt-3 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-[var(--color-muted)]">
<th className="pb-2 font-medium">Medium</th>
<th className="pb-2 text-right font-medium">Impressions</th>
<th className="pb-2 text-right font-medium">Share</th>
<th className="pb-2 text-right font-medium">Clicks</th>
{showCtr && <th className="pb-2 text-right font-medium">CTR</th>}
</tr>
</thead>
<tbody>
{rows.map((row) => {
const label = LABELS[row.media] ?? { name: row.media, hint: "" };
return (
<tr
key={row.media}
className={`border-t border-[var(--color-border)] ${row.rotated ? "" : "text-[var(--color-muted)]"}`}
>
<td className="py-2 pr-3">
<div className="font-medium">{label.name}</div>
{label.hint && (
<div className="text-[11px] text-[var(--color-muted)]">{label.hint}</div>
)}
</td>
<td className="py-2 text-right tabular-nums">
{row.impressions.toLocaleString()}
</td>
<td className="py-2 text-right tabular-nums">
{row.rotated ? (
<div className="flex items-center justify-end gap-2">
{/* 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. */}
<div className="h-1.5 w-16 rounded bg-[var(--color-border)]">
<div
className="h-1.5 rounded bg-[var(--color-accent)]"
style={{ width: `${Math.round(row.share * 100)}%` }}
/>
</div>
<span>{pct(row.share)}</span>
</div>
) : (
<span aria-hidden>—</span>
)}
</td>
<td className="py-2 text-right tabular-nums">
{(row.clicks + row.freeClicks).toLocaleString()}
</td>
{showCtr && (
<td className="py-2 text-right tabular-nums">
{row.ctr === null ? "—" : pct(row.ctr)}
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>

{note && <p className="mt-3 text-xs text-[var(--color-muted)]">{note}</p>}

{showCtr && (
<p className="mt-3 text-xs text-[var(--color-muted)]">
{clicks.toLocaleString()} clicks attributed. A rate this early separates
the arms loosely at best — read it as a direction, not a verdict.
</p>
)}
</div>
);
}
181 changes: 181 additions & 0 deletions lib/ads/media-stats.ts
Original file line number Diff line number Diff line change
@@ -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<Loaded<MediaSplitRow[]>> {
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 };
}
Loading