diff --git a/app/(app)/ads/[id]/page.tsx b/app/(app)/ads/[id]/page.tsx
index 6b057712..52ba772f 100644
--- a/app/(app)/ads/[id]/page.tsx
+++ b/app/(app)/ads/[id]/page.tsx
@@ -55,10 +55,10 @@ export default async function CampaignDetailPage({
.maybeSingle();
if (!campaign) notFound();
- const [{ data: stats }, { data: creativeRows }, series] = await Promise.all([
+ const [{ data: stats }, { data: creativeRows }, series, { data: profile }] = await Promise.all([
supabase
.from("ad_campaign_stats")
- .select("impressions, clicks")
+ .select("impressions, free_impressions, clicks, free_clicks")
.eq("campaign_id", id)
.maybeSingle(),
supabase
@@ -69,13 +69,21 @@ export default async function CampaignDetailPage({
.eq("campaign_id", id)
.order("format"),
getCampaignDailySeries(supabase, [id], 30),
+ supabase
+ .from("profiles")
+ .select("credits_balance, ad_bonus_credits")
+ .eq("id", user.id)
+ .maybeSingle(),
]);
const impressions = (stats?.impressions as number) ?? 0;
const clicks = (stats?.clicks as number) ?? 0;
+ const freeImpressions = (stats?.free_impressions as number) ?? 0;
+ const freeClicks = (stats?.free_clicks as number) ?? 0;
const daily = series.get(id) ?? [];
const today = utcToday();
- const display = campaignDisplayStatus(campaign, today);
+ const creditsAvailable = (profile?.credits_balance ?? 0) + (profile?.ad_bonus_credits ?? 0);
+ const display = campaignDisplayStatus(campaign, today, creditsAvailable);
const creatives: (AdCreative & { id: string })[] = ((creativeRows as CreativeRow[]) ?? []).map(
(r) => ({
@@ -129,7 +137,10 @@ export default async function CampaignDetailPage({
- {!display.serving && (
+ {/* Free tier still serves, so key this on the tier rather than on
+ `serving` — otherwise a campaign quietly running as backfill would
+ look identical to one winning paid placements. */}
+ {display.tier !== "paid" && (
{display.hint}
@@ -144,6 +155,16 @@ export default async function CampaignDetailPage({
+ {/* Counted apart from the paid figures above so the CTR and CPC an
+ advertiser reads stay comparable across tiers. */}
+ {(freeImpressions > 0 || freeClicks > 0) && (
+
+
+
+
+
+ )}
+
diff --git a/app/(app)/ads/page.tsx b/app/(app)/ads/page.tsx
index 01b7781b..765092d2 100644
--- a/app/(app)/ads/page.tsx
+++ b/app/(app)/ads/page.tsx
@@ -1,8 +1,22 @@
import Link from "next/link";
+import { Suspense } from "react";
import { createClient } from "@/lib/supabase/server";
import { CampaignActions } from "@/components/ads/campaign-actions";
import { MiniTrend } from "@/components/ads/mini-trend";
-import { getCampaignDailySeries, type CampaignDailyPoint } from "@/lib/ads/series";
+import { AccountTrend } from "@/components/ads/account-trend";
+import { RangeTabs } from "@/components/ads/range-tabs";
+import { StatSpark } from "@/components/ads/stat-spark";
+import {
+ getAccountSeries,
+ getCampaignDailySeries,
+ getCampaignRangeTotals,
+ sumSeries,
+ EMPTY_TOTALS,
+ type AccountPoint,
+ type CampaignDailyPoint,
+ type RangeTotals,
+} from "@/lib/ads/series";
+import { resolveRange } from "@/lib/ads/ranges";
import { campaignDisplayStatus, spendTodayCents, utcToday } from "@/lib/ads/status";
export const metadata = { title: "Ad campaigns" };
@@ -20,14 +34,6 @@ type CampaignRow = {
created_at: string;
};
-type StatRow = {
- campaign_id: string;
- impressions: number;
- clicks: number;
- spent_cents: number;
- total_spent_cents: number;
-};
-
function dollars(cents: number): string {
return `$${(cents / 100).toFixed(2)}`;
}
@@ -37,17 +43,26 @@ function ctr(clicks: number, impressions: number): string {
return `${((clicks / impressions) * 100).toFixed(1)}%`;
}
-export default async function AdsPage() {
+export default async function AdsPage({
+ searchParams,
+}: {
+ searchParams: Promise<{ range?: string }>;
+}) {
+ const range = resolveRange((await searchParams).range);
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
let campaigns: CampaignRow[] = [];
- const statsById = new Map();
let seriesById = new Map();
+ let series: AccountPoint[] = [];
+ let rangeById = new Map();
+ // Spendable credits decide whether a campaign is on the paid tier or running
+ // as free backfill, so the badge can't be derived from the campaign row alone.
+ let creditsAvailable: number | null = null;
if (user) {
- const [{ data }, { data: stats }] = await Promise.all([
+ const [{ data }, { data: profile }, accountSeries, campaignTotals] = await Promise.all([
supabase
.from("ad_campaigns")
.select(
@@ -55,11 +70,17 @@ export default async function AdsPage() {
)
.order("created_at", { ascending: false }),
supabase
- .from("ad_campaign_stats")
- .select("campaign_id, impressions, clicks, spent_cents, total_spent_cents"),
+ .from("profiles")
+ .select("credits_balance, ad_bonus_credits")
+ .eq("id", user.id)
+ .maybeSingle(),
+ getAccountSeries(supabase, range),
+ getCampaignRangeTotals(supabase, range),
]);
+ creditsAvailable = (profile?.credits_balance ?? 0) + (profile?.ad_bonus_credits ?? 0);
campaigns = (data as CampaignRow[]) ?? [];
- for (const s of (stats as StatRow[]) ?? []) statsById.set(s.campaign_id, s);
+ series = accountSeries;
+ rangeById = campaignTotals;
seriesById = await getCampaignDailySeries(
supabase,
campaigns.map((c) => c.id),
@@ -68,15 +89,7 @@ export default async function AdsPage() {
}
const today = utcToday();
-
- const totals = [...statsById.values()].reduce(
- (a, s) => ({
- impressions: a.impressions + (s.impressions ?? 0),
- clicks: a.clicks + (s.clicks ?? 0),
- spent: a.spent + (s.total_spent_cents ?? 0),
- }),
- { impressions: 0, clicks: 0, spent: 0 },
- );
+ const totals = sumSeries(series);
return (
@@ -100,12 +113,56 @@ export default async function AdsPage() {
{campaigns.length > 0 && (
-
-
-
-
-
-
+ <>
+ {/* One filter row, above everything it scopes: the stats, the chart
+ and the per-campaign figures all read the same slice. */}
+
+
+
+
+ {range.hint}
+
+
+
+ p.impressions} />}
+ />
+ p.clicks} />}
+ />
+
+ p.spentCents} />}
+ />
+
+
+ {/* Free backfill is delivery that costs and earns nothing, so it never
+ belongs in the paid figures above — but hiding it entirely would
+ make impressions look like they collapsed when a campaign runs dry. */}
+ {(totals.freeImpressions > 0 || totals.freeClicks > 0) && (
+
+ Plus{" "}
+
+ {totals.freeImpressions.toLocaleString()}
+ {" "}
+ free-tier impressions and{" "}
+
+ {totals.freeClicks.toLocaleString()}
+ {" "}
+ free clicks in this range, at no cost.
+
+ )}
+
+
+ >
)}
{campaigns.length === 0 ? (
@@ -119,10 +176,11 @@ export default async function AdsPage() {
) : (
{campaigns.map((c) => {
- const s = statsById.get(c.id);
- const impr = s?.impressions ?? 0;
- const clk = s?.clicks ?? 0;
- const display = campaignDisplayStatus(c, today);
+ // Range-scoped, so a row never contradicts the header above it.
+ const s = rangeById.get(c.id) ?? EMPTY_TOTALS;
+ const impr = s.impressions;
+ const clk = s.clicks;
+ const display = campaignDisplayStatus(c, today, creditsAvailable);
return (
@@ -152,7 +210,10 @@ export default async function AdsPage() {
-
+
+ {s.freeImpressions > 0 && (
+
+ )}
{label}
- {value}
+ {value}
+ {spark && {spark}
}
);
}
diff --git a/app/globals.css b/app/globals.css
index 868a53ea..c4bd7164 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -12,6 +12,14 @@
--color-fail: #f87171;
--color-pass: #34d399;
--color-unknown: #64748b;
+ /* Categorical chart series. In-hue with --color-accent / --color-warn but
+ stepped darker: the brand tokens sit at OKLCH L ~0.84, well outside the
+ 0.48–0.67 band a dark chart surface wants, and validate as FAIL. These two
+ pass all six checks against --color-card (#12161c) — lightness band, chroma
+ floor, CVD separation (ΔE 10.4 deutan), normal-vision separation (ΔE 21.2)
+ and 3:1 contrast. Assigned in fixed order; never cycled. */
+ --color-chart-1: #12a97a;
+ --color-chart-2: #c2740a;
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
diff --git a/components/ads/account-trend.tsx b/components/ads/account-trend.tsx
new file mode 100644
index 00000000..74f81b5a
--- /dev/null
+++ b/components/ads/account-trend.tsx
@@ -0,0 +1,118 @@
+"use client";
+
+import {
+ Area,
+ AreaChart,
+ CartesianGrid,
+ Legend,
+ ResponsiveContainer,
+ Tooltip,
+ XAxis,
+ YAxis,
+} from "recharts";
+import { formatBucket, type RangeDef } from "@/lib/ads/ranges";
+import type { AccountPoint } from "@/lib/ads/series";
+
+/**
+ * Account-wide delivery over the selected range.
+ *
+ * Impressions only, split paid vs free — one measure, one unit, one axis.
+ * Clicks and spend live in the stat tiles above rather than as extra lines here:
+ * clicks run ~1% of impressions and spend is money, so putting either on this
+ * plot would mean a second y-scale, and a dual-axis chart invites exactly the
+ * false correlations it appears to show.
+ *
+ * Stacked, because paid + free is a meaningful total (every ad actually shown)
+ * and the split is the point — free backfill is delivery that earns nobody
+ * anything, and it should be visible as a share of the whole.
+ */
+export function AccountTrend({ data, range }: { data: AccountPoint[]; range: RangeDef }) {
+ const total = data.reduce((n, p) => n + p.impressions + p.freeImpressions + p.clicks, 0);
+
+ if (total === 0) {
+ return (
+
+ No delivery in this range.
+
+ );
+ }
+
+ const hasFree = data.some((p) => p.freeImpressions > 0);
+
+ return (
+
+
+
Delivery over time
+ {range.hint}
+
+
+
+
+
+ formatBucket(v, range.tick)}
+ />
+
+ formatBucket(Number(v), range.tick)}
+ formatter={(value, name) => [Number(value).toLocaleString(), String(name)]}
+ />
+ {/* Legend only once there are two series to tell apart — with a
+ single series the heading already names it. */}
+ {hasFree && }
+
+ {hasFree && (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/components/ads/range-tabs.tsx b/components/ads/range-tabs.tsx
new file mode 100644
index 00000000..5f944bf8
--- /dev/null
+++ b/components/ads/range-tabs.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
+import { useTransition } from "react";
+import { RANGES, type RangeId } from "@/lib/ads/ranges";
+
+/**
+ * Stock-chart style range picker: 1H · 4H · 1D · 1W · 1M · 3M · 1Y · ALL.
+ *
+ * One row, above everything it scopes — the range governs the header chart, the
+ * header stats and the per-campaign figures alike, so the numbers on the page
+ * always agree with each other.
+ *
+ * State lives in the URL rather than in component state so a range survives a
+ * refresh and can be linked to. Navigation runs in a transition and the
+ * surrounding content keeps its last render at reduced opacity while the server
+ * re-renders — no skeleton and no layout jump between ranges.
+ */
+export function RangeTabs({ value }: { value: RangeId }) {
+ const router = useRouter();
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+ const [pending, startTransition] = useTransition();
+
+ function select(id: RangeId) {
+ if (id === value) return;
+ const params = new URLSearchParams(searchParams.toString());
+ params.set("range", id);
+ startTransition(() => router.replace(`${pathname}?${params}`, { scroll: false }));
+ }
+
+ return (
+
+ {RANGES.map((r) => {
+ const selected = r.id === value;
+ return (
+ select(r.id)}
+ className={[
+ "rounded-md px-2.5 py-1 text-xs font-semibold tabular-nums transition-colors",
+ selected
+ ? "bg-[var(--color-accent)] text-[var(--color-accent-fg)]"
+ : "text-[var(--color-muted)] hover:bg-[var(--color-border)] hover:text-[var(--color-fg)]",
+ ].join(" ")}
+ >
+ {r.label}
+ — {r.hint}
+
+ );
+ })}
+
+ );
+}
diff --git a/components/ads/stat-spark.tsx b/components/ads/stat-spark.tsx
new file mode 100644
index 00000000..fd4dd419
--- /dev/null
+++ b/components/ads/stat-spark.tsx
@@ -0,0 +1,62 @@
+import type { AccountPoint } from "@/lib/ads/series";
+
+/**
+ * Sparkline for a stat tile — one measure, server-rendered SVG, no recharts.
+ *
+ * The tile's number is the headline; this only carries shape, so it has no
+ * axes, no labels and no hover. Every value it hints at is already reachable
+ * from the main chart's tooltip and the per-campaign table below, which is what
+ * lets it stay this bare.
+ */
+export function StatSpark({
+ data,
+ pick,
+ color = "var(--color-chart-1)",
+ width = 120,
+ height = 28,
+}: {
+ data: AccountPoint[];
+ pick: (p: AccountPoint) => number;
+ color?: string;
+ width?: number;
+ height?: number;
+}) {
+ const values = data.map(pick);
+ const max = Math.max(...values, 0);
+ if (values.length < 2 || max <= 0) {
+ return
;
+ }
+
+ const pad = 2;
+ const w = width - pad * 2;
+ const h = height - pad * 2;
+ const stepX = w / (values.length - 1);
+ const points = values
+ .map((v, i) => `${(pad + i * stepX).toFixed(1)},${(pad + h - (v / max) * h).toFixed(1)}`)
+ .join(" ");
+
+ return (
+
+
+
+
+ );
+}
diff --git a/lib/ads/house.ts b/lib/ads/house.ts
index b652553c..e3481c6d 100644
--- a/lib/ads/house.ts
+++ b/lib/ads/house.ts
@@ -130,5 +130,6 @@ export function houseFill(format: AdFormatId): Fill {
clickUrl,
html: renderHouseAdHtml(format, clickUrl),
text: renderCreativeText(creative, clickUrl, { label: "CRAWLPROOF ADS" }),
+ tier: "house",
};
}
diff --git a/lib/ads/ranges.ts b/lib/ads/ranges.ts
new file mode 100644
index 00000000..f575cc2d
--- /dev/null
+++ b/lib/ads/ranges.ts
@@ -0,0 +1,97 @@
+// Stock-chart style time ranges for the advertiser dashboard.
+//
+// Each range fixes both the window and the bucket width, chosen so every range
+// lands in the 30–90 point band: fewer and a line chart reads as a bar chart,
+// more and buckets fall below one pixel on a narrow card. That also keeps the
+// account-wide series comfortably under PostgREST's 1000-row response cap.
+
+const MIN = 60;
+const HOUR = 60 * MIN;
+const DAY = 24 * HOUR;
+
+export type RangeId = "1h" | "4h" | "1d" | "1w" | "1m" | "3m" | "1y" | "all";
+
+export type RangeDef = {
+ id: RangeId;
+ /** Tab text — terse, the way a trading UI labels these. */
+ label: string;
+ /** Spelled out for the tab's title/aria-label. */
+ hint: string;
+ /** Window length in seconds; null means all time. */
+ windowSeconds: number | null;
+ /** Bucket width in seconds. */
+ bucketSeconds: number;
+ /** How to format a bucket on the x-axis and in the tooltip. */
+ tick: "time" | "datetime" | "date" | "month";
+};
+
+export const RANGES: RangeDef[] = [
+ { id: "1h", label: "1H", hint: "Last hour", windowSeconds: HOUR, bucketSeconds: MIN, tick: "time" },
+ { id: "4h", label: "4H", hint: "Last 4 hours", windowSeconds: 4 * HOUR, bucketSeconds: 5 * MIN, tick: "time" },
+ { id: "1d", label: "1D", hint: "Last 24 hours", windowSeconds: DAY, bucketSeconds: 30 * MIN, tick: "time" },
+ { id: "1w", label: "1W", hint: "Last 7 days", windowSeconds: 7 * DAY, bucketSeconds: 4 * HOUR, tick: "datetime" },
+ { id: "1m", label: "1M", hint: "Last 30 days", windowSeconds: 30 * DAY, bucketSeconds: DAY, tick: "date" },
+ { id: "3m", label: "3M", hint: "Last 90 days", windowSeconds: 90 * DAY, bucketSeconds: DAY, tick: "date" },
+ { id: "1y", label: "1Y", hint: "Last 12 months", windowSeconds: 365 * DAY, bucketSeconds: 7 * DAY, tick: "month" },
+ { id: "all", label: "ALL", hint: "All time", windowSeconds: null, bucketSeconds: 7 * DAY, tick: "month" },
+];
+
+export const DEFAULT_RANGE: RangeId = "1m";
+
+const BY_ID = new Map(RANGES.map((r) => [r.id, r]));
+
+/**
+ * Resolve a `?range=` search param to a range definition.
+ *
+ * Anything unrecognised falls back to the default rather than throwing — the
+ * value is user-controlled via the URL, and a bad one should show the normal
+ * dashboard, not a 500.
+ */
+export function resolveRange(raw: string | string[] | undefined): RangeDef {
+ const key = (Array.isArray(raw) ? raw[0] : raw)?.toLowerCase().trim();
+ return (key && BY_ID.get(key as RangeId)) || BY_ID.get(DEFAULT_RANGE)!;
+}
+
+/** Start of the window as an ISO timestamp, or null for all time. */
+export function rangeSince(range: RangeDef, now: Date = new Date()): string | null {
+ if (range.windowSeconds == null) return null;
+ return new Date(now.getTime() - range.windowSeconds * 1000).toISOString();
+}
+
+/**
+ * Zero-filled bucket axis for the range, oldest first.
+ *
+ * Buckets are aligned to the epoch so they match SQL's
+ * `date_bin(step, ts, 'epoch')` — otherwise every point would miss its slot by
+ * the offset between "now" and the epoch grid.
+ */
+export function bucketAxis(range: RangeDef, now: Date = new Date()): number[] {
+ const stepMs = range.bucketSeconds * 1000;
+ const endMs = Math.floor(now.getTime() / stepMs) * stepMs;
+ if (range.windowSeconds == null) return [endMs];
+ const count = Math.ceil(range.windowSeconds / range.bucketSeconds);
+ const out: number[] = [];
+ for (let i = count - 1; i >= 0; i--) out.push(endMs - i * stepMs);
+ return out;
+}
+
+/** Snap an arbitrary timestamp onto the same epoch-aligned grid. */
+export function bucketOf(iso: string, range: RangeDef): number {
+ const stepMs = range.bucketSeconds * 1000;
+ return Math.floor(new Date(iso).getTime() / stepMs) * stepMs;
+}
+
+/** Axis/tooltip label for a bucket, at the granularity the range implies. */
+export function formatBucket(ms: number, tick: RangeDef["tick"]): string {
+ const d = new Date(ms);
+ switch (tick) {
+ case "time":
+ return d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
+ case "datetime":
+ return d.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric" });
+ case "date":
+ return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
+ case "month":
+ return d.toLocaleDateString(undefined, { month: "short", year: "2-digit" });
+ }
+}
diff --git a/lib/ads/series.ts b/lib/ads/series.ts
index 1c5c34c3..4b5f1368 100644
--- a/lib/ads/series.ts
+++ b/lib/ads/series.ts
@@ -1,4 +1,151 @@
import type { SupabaseClient } from "@supabase/supabase-js";
+import { bucketAxis, bucketOf, rangeSince, type RangeDef } from "./ranges";
+
+export type AccountPoint = {
+ /** Bucket start, epoch ms (epoch-aligned, matching SQL date_bin). */
+ t: number;
+ impressions: number;
+ freeImpressions: number;
+ clicks: number;
+ freeClicks: number;
+ spentCents: number;
+};
+
+export type RangeTotals = {
+ impressions: number;
+ freeImpressions: number;
+ clicks: number;
+ freeClicks: number;
+ spentCents: number;
+};
+
+export const EMPTY_TOTALS: RangeTotals = {
+ impressions: 0,
+ freeImpressions: 0,
+ clicks: 0,
+ freeClicks: 0,
+ spentCents: 0,
+};
+
+type AccountSeriesRow = {
+ bucket: string;
+ impressions: number | string;
+ free_impressions: number | string;
+ clicks: number | string;
+ free_clicks: number | string;
+ spent_cents: number | string;
+};
+
+/**
+ * Account-wide bucketed series for a range, zero-filled across the whole window.
+ *
+ * Account-wide on purpose: a per-campaign version would return
+ * (campaigns × buckets) rows and blow PostgREST's 1000-row cap on any wide
+ * range — the same trap documented on getCampaignDailySeries below. Per-campaign
+ * numbers for the same window come from getCampaignRangeTotals, which is
+ * (campaigns) rows because it doesn't bucket.
+ */
+export async function getAccountSeries(
+ supabase: SupabaseClient,
+ range: RangeDef,
+ now: Date = new Date(),
+): Promise {
+ const { data, error } = await supabase.rpc("ad_account_series", {
+ p_since: rangeSince(range, now),
+ p_bucket_seconds: range.bucketSeconds,
+ });
+
+ const rows = error ? [] : ((data as AccountSeriesRow[]) ?? []);
+
+ // "All time" has no fixed start, so the axis runs from the oldest bucket that
+ // actually has data rather than from a window offset.
+ const axis =
+ range.windowSeconds == null && rows.length > 0
+ ? allTimeAxis(rows, range, now)
+ : bucketAxis(range, now);
+
+ const byBucket = new Map();
+ for (const t of axis) {
+ byBucket.set(t, {
+ t,
+ impressions: 0,
+ freeImpressions: 0,
+ clicks: 0,
+ freeClicks: 0,
+ spentCents: 0,
+ });
+ }
+
+ for (const row of rows) {
+ const point = byBucket.get(bucketOf(row.bucket, range));
+ if (!point) continue;
+ point.impressions += Number(row.impressions) || 0;
+ point.freeImpressions += Number(row.free_impressions) || 0;
+ point.clicks += Number(row.clicks) || 0;
+ point.freeClicks += Number(row.free_clicks) || 0;
+ point.spentCents += Number(row.spent_cents) || 0;
+ }
+
+ return [...byBucket.values()].sort((a, b) => a.t - b.t);
+}
+
+function allTimeAxis(rows: AccountSeriesRow[], range: RangeDef, now: Date): number[] {
+ const stepMs = range.bucketSeconds * 1000;
+ const first = Math.min(...rows.map((r) => bucketOf(r.bucket, range)));
+ const last = Math.floor(now.getTime() / stepMs) * stepMs;
+ const out: number[] = [];
+ // Guard the loop: a clock skew that puts `first` after `last` would otherwise
+ // spin forever building an axis that runs backwards.
+ for (let t = Math.min(first, last); t <= last; t += stepMs) out.push(t);
+ return out;
+}
+
+/** Sum a series into the headline figures for the range. */
+export function sumSeries(points: AccountPoint[]): RangeTotals {
+ return points.reduce(
+ (a, p) => ({
+ impressions: a.impressions + p.impressions,
+ freeImpressions: a.freeImpressions + p.freeImpressions,
+ clicks: a.clicks + p.clicks,
+ freeClicks: a.freeClicks + p.freeClicks,
+ spentCents: a.spentCents + p.spentCents,
+ }),
+ { ...EMPTY_TOTALS },
+ );
+}
+
+type CampaignTotalsRow = {
+ campaign_id: string;
+ impressions: number | string;
+ free_impressions: number | string;
+ clicks: number | string;
+ free_clicks: number | string;
+ spent_cents: number | string;
+};
+
+/** Per-campaign totals for the same window, so the list agrees with the header. */
+export async function getCampaignRangeTotals(
+ supabase: SupabaseClient,
+ range: RangeDef,
+ now: Date = new Date(),
+): Promise> {
+ const out = new Map();
+ const { data, error } = await supabase.rpc("ad_campaign_totals", {
+ p_since: rangeSince(range, now),
+ });
+ if (error) return out;
+
+ for (const row of (data as CampaignTotalsRow[]) ?? []) {
+ out.set(row.campaign_id, {
+ impressions: Number(row.impressions) || 0,
+ freeImpressions: Number(row.free_impressions) || 0,
+ clicks: Number(row.clicks) || 0,
+ freeClicks: Number(row.free_clicks) || 0,
+ spentCents: Number(row.spent_cents) || 0,
+ });
+ }
+ return out;
+}
export type CampaignDailyPoint = {
/** UTC calendar day, YYYY-MM-DD */
diff --git a/lib/ads/serve.ts b/lib/ads/serve.ts
index fc64d604..cebdf28b 100644
--- a/lib/ads/serve.ts
+++ b/lib/ads/serve.ts
@@ -21,6 +21,17 @@ import { runAuction } from "./auction";
// campaign with a ready creative in the requested format is eligible; we pick
// one at random to spread delivery. Auction + budget pacing are a later phase.
+/**
+ * Which inventory this fill came from.
+ *
+ * 'paid' — won the auction; the click bills the advertiser and pays the publisher.
+ * 'free' — backfill from a campaign that ran out of credits or daily budget.
+ * Bills nobody, earns nobody, and only ever fills a request no paying
+ * campaign wanted. Beats showing a house ad.
+ * 'house' — CrawlProof's own promo. Never metered.
+ */
+export type AdTier = "paid" | "free" | "house";
+
export type Fill = {
impressionId: string;
campaignId: string;
@@ -31,6 +42,7 @@ export type Fill = {
html: string;
/** ASCII rendering of the same creative, for terminal/MOTD consumers. */
text: string;
+ tier: AdTier;
};
export function isAdFormat(v: string | null | undefined): v is AdFormatId {
@@ -100,8 +112,9 @@ export async function serveAd(
// clean and avoids exposing paid creatives to crawlers).
if (isBotDevice(ctx.device)) return houseFill(format);
- // Active campaigns with a ready creative in this format. Join manually:
- // fetch candidate creatives whose campaign is active.
+ // Campaigns with a ready creative in this format. 'exhausted' is a legacy
+ // status no longer written by ad_charge_click — rows still carrying it are
+ // live campaigns that ran dry, and belong on the free tier rather than dark.
const { data: creatives } = await sb
.from("ad_creatives")
.select(
@@ -109,7 +122,7 @@ export async function serveAd(
)
.eq("format", format)
.eq("status", "ready")
- .eq("ad_campaigns.status", "active")
+ .in("ad_campaigns.status", ["active", "exhausted"])
.limit(100);
// No paid creative for this format → default CrawlProof house ad.
@@ -132,34 +145,74 @@ export async function serveAd(
// Budget pacing: keep only campaigns with room for at least one more click at
// their bid today. spend_today resets implicitly when spend_date is earlier.
- const eligible = (creatives as unknown as Row[]).filter((row) => {
+ // Never serve someone their own ad on their own slot. ad_charge_click refuses
+ // to bill or accrue on a self-click anyway; filtering here means we don't burn
+ // an impression and a redirect on a click that can't earn.
+ const candidates = (creatives as unknown as Row[]).filter((row) => {
const c = oneCampaign(row.ad_campaigns);
if (!c) return false;
- // Never serve someone their own ad on their own slot. ad_charge_click
- // refuses to bill or accrue on a self-click anyway; filtering here means we
- // don't burn an impression and a redirect on a click that can't earn.
- if (slot.owner_id && c.owner_id === slot.owner_id) return false;
- const spentToday = c.spend_date === today ? c.spend_today_cents : 0;
- const bid = c.bid_credits ?? DEFAULT_BID_CREDITS;
- return spentToday + bid * CREDIT_CENTS <= c.daily_budget_cents;
+ return !(slot.owner_id && c.owner_id === slot.owner_id);
});
- // Every candidate is out of budget → fall back to the house ad.
- if (eligible.length === 0) return houseFill(format);
-
- // Rotate the CrawlProof house ad into a slice of otherwise-fillable requests
- // so the ad network keeps promoting itself even on well-monetized slots.
- // Unmetered, so no paid impression is spent on these.
- if (Math.random() < HOUSE_AD_ROTATION_RATE) return houseFill(format);
+ if (candidates.length === 0) return houseFill(format);
- // Bid-weighted lottery: every eligible campaign can win, with probability
- // proportional to its bid, so all active ads rotate (higher bids more often).
- const auction = runAuction(
- eligible.map((row) => ({
- bidCredits: oneCampaign(row.ad_campaigns).bid_credits ?? DEFAULT_BID_CREDITS,
- item: row,
- })),
+ // A campaign is PAID-eligible only if its owner can actually cover a click at
+ // its bid. Without this a broke campaign would win the auction and displace
+ // one that can pay — the click would go unbilled and the publisher would earn
+ // nothing on inventory a funded advertiser wanted.
+ const ownerIds = [...new Set(candidates.map((r) => oneCampaign(r.ad_campaigns).owner_id))];
+ const { data: owners } = await sb
+ .from("profiles")
+ .select("id, credits_balance, ad_bonus_credits")
+ .in("id", ownerIds);
+ const creditsByOwner = new Map(
+ (owners ?? []).map((o) => [
+ o.id as string,
+ ((o.credits_balance as number) ?? 0) + ((o.ad_bonus_credits as number) ?? 0),
+ ]),
);
- const pick = auction?.winner;
+
+ const paid: Row[] = [];
+ const free: Row[] = [];
+ for (const row of candidates) {
+ const c = oneCampaign(row.ad_campaigns);
+ const bid = c.bid_credits ?? DEFAULT_BID_CREDITS;
+ const spentToday = c.spend_date === today ? c.spend_today_cents : 0;
+ const hasBudget = spentToday + bid * CREDIT_CENTS <= c.daily_budget_cents;
+ const hasFunds = (creditsByOwner.get(c.owner_id) ?? 0) >= bid;
+ // Legacy 'exhausted' rows never compete for paid inventory on that status
+ // alone — funds decide, and a top-up puts them straight back in the auction.
+ (hasBudget && hasFunds ? paid : free).push(row);
+ }
+
+ // Paid inventory first, always. Free-tier campaigns only ever fill requests no
+ // paying advertiser wanted, so they can't cannibalise publisher earnings.
+ let pick: Row | undefined;
+ let tier: AdTier = "paid";
+
+ if (paid.length > 0) {
+ // Rotate the CrawlProof house ad into a slice of otherwise-fillable requests
+ // so the ad network keeps promoting itself even on well-monetized slots.
+ // Unmetered, so no paid impression is spent on these.
+ if (Math.random() < HOUSE_AD_ROTATION_RATE) return houseFill(format);
+
+ // Bid-weighted lottery: every eligible campaign can win, with probability
+ // proportional to its bid, so all active ads rotate (higher bids more often).
+ pick = runAuction(
+ paid.map((row) => ({
+ bidCredits: oneCampaign(row.ad_campaigns).bid_credits ?? DEFAULT_BID_CREDITS,
+ item: row,
+ })),
+ )?.winner;
+ }
+
+ // Nothing paid to show: backfill with a real advertiser's ad instead of the
+ // house ad. Uniform pick, not bid-weighted — nobody is paying, so a high bid
+ // buys no priority here.
+ if (!pick && free.length > 0) {
+ tier = "free";
+ pick = free[Math.floor(Math.random() * free.length)];
+ }
+
if (!pick) return houseFill(format);
const campaign = oneCampaign(pick.ad_campaigns);
if (!campaign) return null;
@@ -176,6 +229,7 @@ export async function serveAd(
geo_country: ctx.country ?? null,
device: ctx.device ?? null,
billable: false,
+ tier,
})
.select("id")
.single();
@@ -201,6 +255,7 @@ export async function serveAd(
clickUrl,
html: renderCreativeHtml(creative, clickUrl),
text: renderCreativeText(creative, clickUrl),
+ tier,
};
}
diff --git a/lib/ads/status.ts b/lib/ads/status.ts
index af89d16f..fe4b93f5 100644
--- a/lib/ads/status.ts
+++ b/lib/ads/status.ts
@@ -1,18 +1,18 @@
-// Advertiser-facing campaign status. The stored `ad_campaigns.status` doesn't
-// tell the whole story, because two very different things stop a campaign from
-// serving and only one of them writes to that column:
+// Advertiser-facing campaign status.
//
-// * Daily budget reached — status stays 'active'. ad_charge_click leaves the
-// column alone and only records an unbilled click; serveAd() filters the
-// campaign out of the auction. The counter resets on the next UTC day, so
-// the campaign resumes on its own with no advertiser action.
-// * Out of ad credits — ad_charge_click flips status to 'exhausted'. Nothing
-// ever flips it back (a deposit grants credits but doesn't touch campaign
-// status), so this one needs a manual Activate.
+// A campaign never stops serving because it ran out of money. Running dry drops
+// it to the FREE TIER instead: it keeps rendering, but only as backfill on
+// inventory no paying campaign wanted, and those impressions/clicks bill nobody
+// and earn the publisher nothing. Two things drop a campaign to free tier, and
+// both self-heal without the advertiser touching anything:
//
-// Rendering the raw column made those two look identical — a stalled campaign
-// with no explanation. campaignDisplayStatus() derives the distinction so the
-// dashboard can say which one happened and whether it self-heals.
+// * Daily budget reached — the counter resets at 00:00 UTC.
+// * Out of ad credits — a top-up restores paid delivery immediately.
+//
+// Before this, out-of-credits flipped ad_campaigns.status to 'exhausted' and
+// nothing ever flipped it back, so a campaign died silently and needed a manual
+// Activate. That status is no longer written; any row still carrying it is
+// treated as an ordinary active campaign on the free tier.
import { CREDIT_CENTS, DEFAULT_BID_CREDITS } from "./pricing";
@@ -24,15 +24,20 @@ export type CampaignBudgetFields = {
bid_credits?: number | null;
};
+/** Which inventory a campaign can win right now. */
+export type CampaignTier = "paid" | "free" | "none";
+
export type CampaignDisplayStatus = {
/** Badge text. */
label: string;
/** One-line explanation of why it's in this state. */
hint: string;
- /** Eligible to fill a slot right now. */
+ /** Rendering somewhere right now — on either tier. */
serving: boolean;
- /** Comes back without the advertiser doing anything. */
+ /** Returns to paid delivery without the advertiser doing anything. */
resumesAutomatically: boolean;
+ /** Paid auction, free backfill only, or not serving at all. */
+ tier: CampaignTier;
};
/** The day boundary the budget counter resets on — UTC, matching SQL current_date. */
@@ -53,24 +58,48 @@ export function isDailyBudgetReached(
today: string = utcToday(),
): boolean {
const bid = c.bid_credits ?? DEFAULT_BID_CREDITS;
- // Mirrors the eligibility filter in serveAd() — keep the two in step.
+ // Mirrors the paid-eligibility filter in serveAd() — keep the two in step.
return spendTodayCents(c, today) + bid * CREDIT_CENTS > c.daily_budget_cents;
}
+/**
+ * True when the advertiser can't cover one more click at this bid.
+ *
+ * `creditsAvailable` is the owner's spendable total (paid + promo + bonus).
+ * Undefined means "not looked up" — callers without the balance to hand get
+ * paid-tier optimism rather than a wrong "out of credits" badge, and
+ * ad_charge_click still refuses to bill what isn't there.
+ */
+export function isOutOfCredits(
+ c: CampaignBudgetFields,
+ creditsAvailable?: number | null,
+): boolean {
+ if (creditsAvailable == null) return false;
+ return creditsAvailable < (c.bid_credits ?? DEFAULT_BID_CREDITS);
+}
+
+/** Which tier a campaign is eligible for right now. */
+export function campaignTier(
+ c: CampaignBudgetFields,
+ today: string = utcToday(),
+ creditsAvailable?: number | null,
+): CampaignTier {
+ // 'exhausted' is legacy — treat it as active, on the free tier.
+ if (c.status !== "active" && c.status !== "exhausted") return "none";
+ if (c.status === "exhausted") return "free";
+ if (isOutOfCredits(c, creditsAvailable)) return "free";
+ if (isDailyBudgetReached(c, today)) return "free";
+ return "paid";
+}
+
export function campaignDisplayStatus(
c: CampaignBudgetFields,
today: string = utcToday(),
+ creditsAvailable?: number | null,
): CampaignDisplayStatus {
- if (c.status === "exhausted") {
- return {
- label: "out of credits",
- hint: "Ran out of ad credits, so the campaign stopped. Top up your credits, then press Activate — it won't restart on its own.",
- serving: false,
- resumesAutomatically: false,
- };
- }
+ const tier = campaignTier(c, today, creditsAvailable);
- if (c.status !== "active") {
+ if (tier === "none") {
return {
label: c.status,
hint:
@@ -79,22 +108,30 @@ export function campaignDisplayStatus(
: "Not live yet. Press Activate to start serving.",
serving: false,
resumesAutomatically: false,
+ tier,
};
}
- if (isDailyBudgetReached(c, today)) {
+ if (tier === "free") {
+ // Out of credits takes precedence in the copy: it's the one the advertiser
+ // has to act on to get back to paid delivery.
+ const broke = c.status === "exhausted" || isOutOfCredits(c, creditsAvailable);
return {
- label: "daily budget reached",
- hint: "Today's budget is spent. Serving resumes automatically at 00:00 UTC — raise the daily budget to keep going sooner.",
- serving: false,
+ label: "free tier",
+ hint: broke
+ ? "Out of ad credits, so it's running as free backfill — still shown on unsold inventory, but it can't win paid placements and you're not charged. Top up to restore priority delivery."
+ : "Today's budget is spent, so it's running as free backfill — still shown on unsold inventory at no cost. Priority delivery resumes at 00:00 UTC, or raise the daily budget to get it back sooner.",
+ serving: true,
resumesAutomatically: true,
+ tier,
};
}
return {
label: "active",
- hint: "Live and eligible to serve.",
+ hint: "Live and competing for paid placements.",
serving: true,
resumesAutomatically: true,
+ tier,
};
}
diff --git a/supabase/migrations/20260731130000_ad_free_tier.sql b/supabase/migrations/20260731130000_ad_free_tier.sql
new file mode 100644
index 00000000..e706ef12
--- /dev/null
+++ b/supabase/migrations/20260731130000_ad_free_tier.sql
@@ -0,0 +1,243 @@
+-- Ad network — Phase 6: campaigns never go dark, they drop to a free tier.
+--
+-- Running out of money used to kill a campaign. ad_charge_click flipped
+-- ad_campaigns.status to 'exhausted' and nothing ever flipped it back — a
+-- deposit granted credits but didn't touch campaign status — so the advertiser
+-- had to notice and press Activate. Meanwhile the slot that would have shown
+-- their ad fell back to a CrawlProof house ad, which earns the publisher
+-- exactly as little as the free-tier ad would have.
+--
+-- Now a dry campaign keeps serving as FREE BACKFILL: it fills requests no
+-- paying campaign wanted, bills nobody, and accrues nothing to the publisher.
+-- Strictly better than a house ad for everyone — the advertiser keeps getting
+-- traffic and a reason to top up, the publisher shows a real ad, and the
+-- network keeps its inventory full. Paid delivery resumes by itself the moment
+-- credits arrive or the daily budget rolls over at 00:00 UTC.
+--
+-- Solvency is untouched: free-tier clicks charge 0 and accrue 0, so they add no
+-- publisher liability. See 20260731120000_ad_solvency.sql.
+--
+-- Apply via psql over the pooler (prod history diverged), not `db push`.
+
+-- ---------------------------------------------------------------------------
+-- Tier provenance on the event tables
+-- ---------------------------------------------------------------------------
+
+-- Which inventory the event came from. 'paid' = won the auction and billed;
+-- 'free' = backfill, unbilled. House ads are never recorded, so they need no
+-- value here. Existing rows all predate the free tier, so 'paid' is right.
+alter table public.ad_impressions
+ add column if not exists tier text not null default 'paid';
+alter table public.ad_clicks
+ add column if not exists tier text not null default 'paid';
+
+do $$
+begin
+ if not exists (select 1 from pg_constraint where conname = 'ad_impressions_tier_check') then
+ alter table public.ad_impressions
+ add constraint ad_impressions_tier_check check (tier in ('paid','free'));
+ end if;
+ if not exists (select 1 from pg_constraint where conname = 'ad_clicks_tier_check') then
+ alter table public.ad_clicks
+ add constraint ad_clicks_tier_check check (tier in ('paid','free'));
+ end if;
+end $$;
+
+create index if not exists ad_impressions_tier_idx on public.ad_impressions (tier);
+create index if not exists ad_clicks_tier_idx on public.ad_clicks (tier);
+
+-- ---------------------------------------------------------------------------
+-- Revive every campaign that was killed by the old behaviour
+-- ---------------------------------------------------------------------------
+
+-- 'exhausted' is no longer written. Rows still carrying it are live campaigns
+-- that ran dry; they belong on the free tier, not dark. serveAd() and
+-- campaignTier() both tolerate the legacy value, but clear it so the dashboard
+-- and the auction agree on one representation.
+update public.ad_campaigns set status = 'active' where status = 'exhausted';
+
+-- ---------------------------------------------------------------------------
+-- Charge a click: free tier instead of deactivation
+-- ---------------------------------------------------------------------------
+
+create or replace function public.ad_charge_click(
+ p_campaign uuid,
+ p_slot uuid,
+ p_creative uuid,
+ p_impression uuid,
+ p_visitor text,
+ p_ip_hash text,
+ p_country text,
+ p_device text,
+ p_cpc_credits int,
+ p_platform_rate numeric
+) returns table(click_id uuid, charged_cents int, publisher_earn_cents int, valid boolean)
+language plpgsql security definer set search_path = public as $$
+declare
+ v_owner uuid;
+ v_status text;
+ v_daily int;
+ v_spend int;
+ v_date date;
+ v_paid int;
+ v_bonus int;
+ v_promo int;
+ v_from_bonus int;
+ v_from_promo int;
+ v_from_cash int;
+ v_rest int;
+ v_slot_owner uuid;
+ v_charged int;
+ v_earn int;
+ v_cut int;
+ v_click uuid;
+ v_rack_cents constant int := 5;
+ v_floor_cents constant numeric := 2.0;
+begin
+ select owner_id, status, daily_budget_cents, spend_today_cents, spend_date
+ into v_owner, v_status, v_daily, v_spend, v_date
+ from public.ad_campaigns where id = p_campaign for update;
+ if not found then return; end if;
+
+ v_charged := p_cpc_credits * v_rack_cents;
+ if v_date is distinct from current_date then v_spend := 0; end if;
+
+ -- Paused / draft: not serving at all, so this click shouldn't exist. Record
+ -- it unbilled on the paid tier — it was never free inventory.
+ if v_status not in ('active', 'exhausted') then
+ insert into public.ad_clicks(impression_id,slot_id,campaign_id,creative_id,visitor_id,ip_hash,geo_country,device,charged_cents,publisher_earn_cents,platform_cut_cents,valid,tier)
+ values (p_impression,p_slot,p_campaign,p_creative,p_visitor,p_ip_hash,p_country,p_device,0,0,0,false,'paid')
+ returning id into v_click;
+ return query select v_click, 0, 0, false;
+ return;
+ end if;
+
+ select owner_id into v_slot_owner from public.ad_slots where id = p_slot;
+
+ -- Self-dealing: clicking your own ad on your own slot moves credits into
+ -- withdrawable cash for free. Bill nobody, earn nobody.
+ if v_slot_owner is not null and v_slot_owner = v_owner then
+ insert into public.ad_clicks(impression_id,slot_id,campaign_id,creative_id,visitor_id,ip_hash,geo_country,device,charged_cents,publisher_earn_cents,platform_cut_cents,valid,tier)
+ values (p_impression,p_slot,p_campaign,p_creative,p_visitor,p_ip_hash,p_country,p_device,0,0,0,false,'paid')
+ returning id into v_click;
+ return query select v_click, 0, 0, false;
+ return;
+ end if;
+
+ -- Over the daily cap: free tier for the rest of the UTC day. The campaign
+ -- stays active and resumes paid delivery on its own at midnight.
+ if (v_spend + v_charged) > v_daily then
+ insert into public.ad_clicks(impression_id,slot_id,campaign_id,creative_id,visitor_id,ip_hash,geo_country,device,charged_cents,publisher_earn_cents,platform_cut_cents,valid,tier)
+ values (p_impression,p_slot,p_campaign,p_creative,p_visitor,p_ip_hash,p_country,p_device,0,0,0,false,'free')
+ returning id into v_click;
+ return query select v_click, 0, 0, false;
+ return;
+ end if;
+
+ select credits_balance,
+ coalesce(ad_bonus_credits, 0),
+ least(coalesce(promo_credits, 0), credits_balance)
+ into v_paid, v_bonus, v_promo
+ from public.profiles where id = v_owner for update;
+
+ -- Out of credits: free tier. Crucially the status is NOT touched — the old
+ -- code set 'exhausted' here and the campaign never came back on its own.
+ -- A top-up restores paid delivery with no advertiser action.
+ if coalesce(v_paid, 0) + coalesce(v_bonus, 0) < p_cpc_credits then
+ insert into public.ad_clicks(impression_id,slot_id,campaign_id,creative_id,visitor_id,ip_hash,geo_country,device,charged_cents,publisher_earn_cents,platform_cut_cents,valid,tier)
+ values (p_impression,p_slot,p_campaign,p_creative,p_visitor,p_ip_hash,p_country,p_device,0,0,0,false,'free')
+ returning id into v_click;
+ return query select v_click, 0, 0, false;
+ return;
+ end if;
+
+ v_from_bonus := least(v_bonus, p_cpc_credits);
+ v_rest := p_cpc_credits - v_from_bonus;
+ v_from_promo := least(v_promo, v_rest);
+ v_from_cash := v_rest - v_from_promo;
+
+ update public.profiles
+ set ad_bonus_credits = ad_bonus_credits - v_from_bonus,
+ credits_balance = credits_balance - (v_from_promo + v_from_cash),
+ promo_credits = greatest(0, coalesce(promo_credits, 0) - v_from_promo)
+ where id = v_owner;
+
+ -- Publisher earns at the floor rate, and ONLY on the cash-backed slice.
+ v_earn := floor(v_from_cash * (1 - p_platform_rate) * v_floor_cents);
+ v_cut := v_charged - v_earn;
+
+ update public.ad_campaigns
+ set spend_today_cents = v_spend + v_charged,
+ spend_date = current_date,
+ total_spent_cents = coalesce(total_spent_cents,0) + v_charged
+ where id = p_campaign;
+
+ insert into public.ad_clicks(impression_id,slot_id,campaign_id,creative_id,visitor_id,ip_hash,geo_country,device,charged_cents,publisher_earn_cents,platform_cut_cents,valid,tier)
+ values (p_impression,p_slot,p_campaign,p_creative,p_visitor,p_ip_hash,p_country,p_device,v_charged,v_earn,v_cut,true,'paid')
+ returning id into v_click;
+
+ if v_slot_owner is not null and v_earn > 0 then
+ insert into public.ad_ledger(kind, owner_id, campaign_id, slot_id, amount_cents, ref_click_id)
+ values ('publisher_accrual', v_slot_owner, p_campaign, p_slot, v_earn, v_click);
+ end if;
+ if v_cut > 0 then
+ insert into public.ad_ledger(kind, owner_id, campaign_id, slot_id, amount_cents, ref_click_id)
+ values ('platform_fee', null, p_campaign, p_slot, v_cut, v_click);
+ end if;
+
+ return query select v_click, v_charged, v_earn, true;
+end $$;
+
+revoke execute on function public.ad_charge_click(uuid,uuid,uuid,uuid,text,text,text,text,int,numeric) from anon, authenticated;
+grant execute on function public.ad_charge_click(uuid,uuid,uuid,uuid,text,text,text,text,int,numeric) to service_role;
+
+-- ---------------------------------------------------------------------------
+-- Stats: keep paid and free delivery visible side by side
+-- ---------------------------------------------------------------------------
+
+-- Without the split, free-tier delivery would inflate impression counts while
+-- spend and earnings stayed flat — an advertiser would think their CPC
+-- collapsed and a publisher would think their RPM did.
+--
+-- Dropped rather than replaced: the new free_* columns land mid-list, and
+-- `create or replace view` can only append columns, not insert them.
+drop view if exists public.ad_campaign_stats;
+drop view if exists public.ad_slot_stats;
+
+create or replace view public.ad_campaign_stats
+ with (security_invoker = true) as
+select
+ c.id as campaign_id,
+ (select count(*) from public.ad_impressions i
+ where i.campaign_id = c.id and i.tier = 'paid') as impressions,
+ (select count(*) from public.ad_impressions i
+ where i.campaign_id = c.id and i.tier = 'free') as free_impressions,
+ (select count(*) from public.ad_clicks cl
+ where cl.campaign_id = c.id and cl.valid) as clicks,
+ (select count(*) from public.ad_clicks cl
+ where cl.campaign_id = c.id and not cl.valid and cl.tier = 'free') as free_clicks,
+ (select coalesce(sum(cl.charged_cents), 0)
+ from public.ad_clicks cl where cl.campaign_id = c.id and cl.valid) as spent_cents,
+ c.spend_today_cents,
+ c.total_spent_cents
+from public.ad_campaigns c;
+
+grant select on public.ad_campaign_stats to authenticated, service_role;
+
+create or replace view public.ad_slot_stats
+ with (security_invoker = true) as
+select
+ s.id as slot_id,
+ (select count(*) from public.ad_impressions i
+ where i.slot_id = s.id and i.tier = 'paid') as impressions,
+ (select count(*) from public.ad_impressions i
+ where i.slot_id = s.id and i.tier = 'free') as free_impressions,
+ (select count(*) from public.ad_clicks cl
+ where cl.slot_id = s.id and cl.valid) as clicks,
+ (select count(*) from public.ad_clicks cl
+ where cl.slot_id = s.id and not cl.valid and cl.tier = 'free') as free_clicks,
+ (select coalesce(sum(cl.publisher_earn_cents), 0)
+ from public.ad_clicks cl where cl.slot_id = s.id and cl.valid) as earned_cents
+from public.ad_slots s;
+
+grant select on public.ad_slot_stats to authenticated, service_role;
diff --git a/supabase/migrations/20260731140000_ad_range_series.sql b/supabase/migrations/20260731140000_ad_range_series.sql
new file mode 100644
index 00000000..e039b22f
--- /dev/null
+++ b/supabase/migrations/20260731140000_ad_range_series.sql
@@ -0,0 +1,136 @@
+-- Ad network: arbitrary-window, arbitrary-bucket series for the advertiser
+-- dashboard's stock-chart style range picker (1H / 4H / 1D / 1W / 1M / 3M / 1Y / ALL).
+--
+-- ad_campaign_daily_series only buckets by UTC calendar day, which can't answer
+-- "the last hour in 1-minute steps". These two functions take the window and the
+-- bucket width as parameters instead.
+--
+-- Deliberately split in two, because of the PostgREST 1000-row response cap that
+-- forced server-side aggregation in the first place (see
+-- 20260717032002_ad_campaign_daily_series_rpc.sql):
+--
+-- * ad_account_series — account-wide, so the row count is (buckets), never
+-- (campaigns × buckets). 34 campaigns over a 90-day daily window would be
+-- 3,060 rows and would silently truncate; account-wide it's 90.
+-- * ad_campaign_totals — per campaign but NOT bucketed, so it's (campaigns)
+-- rows. Enough to make the per-campaign list obey the same range without
+-- re-introducing the product.
+--
+-- security invoker (the SQL default) so RLS applies to the caller, plus an
+-- explicit owner_id = auth.uid() scope so publisher-side read grants on
+-- ad_impressions/ad_clicks can't leak another advertiser's campaigns in.
+--
+-- Apply via psql over the pooler / MCP (prod history diverged), not `db push`.
+
+-- Account-wide bucketed series. p_since null = all time.
+create or replace function public.ad_account_series(
+ p_since timestamptz default null,
+ p_bucket_seconds integer default 86400
+)
+returns table (
+ bucket timestamptz,
+ impressions bigint,
+ free_impressions bigint,
+ clicks bigint,
+ free_clicks bigint,
+ spent_cents bigint
+)
+language sql
+stable
+security invoker
+set search_path = public
+as $$
+ with b as (
+ -- Floor at 60s: a sub-minute bucket over a long window would return more
+ -- rows than the response cap allows, and no range needs finer than 1m.
+ select make_interval(secs => greatest(coalesce(p_bucket_seconds, 86400), 60)) as step
+ ),
+ owned as (
+ select id from public.ad_campaigns where owner_id = auth.uid()
+ ),
+ ev as (
+ select date_bin((select step from b), i.ts, timestamptz 'epoch') as bucket,
+ case when i.tier = 'free' then 0 else 1 end as imp,
+ case when i.tier = 'free' then 1 else 0 end as free_imp,
+ 0 as clk,
+ 0 as free_clk,
+ 0 as spent
+ from public.ad_impressions i
+ where i.campaign_id in (select id from owned)
+ and (p_since is null or i.ts >= p_since)
+ union all
+ select date_bin((select step from b), cl.ts, timestamptz 'epoch'),
+ 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 cl.charged_cents else 0 end
+ from public.ad_clicks cl
+ where cl.campaign_id in (select id from owned)
+ and (p_since is null or cl.ts >= p_since)
+ )
+ select bucket,
+ sum(imp)::bigint,
+ sum(free_imp)::bigint,
+ sum(clk)::bigint,
+ sum(free_clk)::bigint,
+ sum(spent)::bigint
+ from ev
+ group by bucket
+ order by bucket;
+$$;
+
+grant execute on function public.ad_account_series(timestamptz, integer) to authenticated, service_role;
+
+-- Per-campaign totals for the same window — one row per campaign, no buckets.
+create or replace function public.ad_campaign_totals(
+ p_since timestamptz default null
+)
+returns table (
+ campaign_id uuid,
+ impressions bigint,
+ free_impressions bigint,
+ clicks bigint,
+ free_clicks bigint,
+ spent_cents bigint
+)
+language sql
+stable
+security invoker
+set search_path = public
+as $$
+ with owned as (
+ select id from public.ad_campaigns where owner_id = auth.uid()
+ ),
+ ev as (
+ select i.campaign_id,
+ case when i.tier = 'free' then 0 else 1 end as imp,
+ case when i.tier = 'free' then 1 else 0 end as free_imp,
+ 0 as clk,
+ 0 as free_clk,
+ 0 as spent
+ from public.ad_impressions i
+ where i.campaign_id in (select id from owned)
+ and (p_since is null or i.ts >= p_since)
+ union all
+ select cl.campaign_id,
+ 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 cl.charged_cents else 0 end
+ from public.ad_clicks cl
+ where cl.campaign_id in (select id from owned)
+ and (p_since is null or cl.ts >= p_since)
+ )
+ select campaign_id,
+ sum(imp)::bigint,
+ sum(free_imp)::bigint,
+ sum(clk)::bigint,
+ sum(free_clk)::bigint,
+ sum(spent)::bigint
+ from ev
+ group by campaign_id;
+$$;
+
+grant execute on function public.ad_campaign_totals(timestamptz) to authenticated, service_role;
diff --git a/tests/ads-campaign-status.test.ts b/tests/ads-campaign-status.test.ts
index e271e283..1e324980 100644
--- a/tests/ads-campaign-status.test.ts
+++ b/tests/ads-campaign-status.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
campaignDisplayStatus,
+ campaignTier,
isDailyBudgetReached,
spendTodayCents,
utcToday,
@@ -8,9 +9,11 @@ import {
} from "@/lib/ads/status";
import { CREDIT_CENTS, DEFAULT_BID_CREDITS } from "@/lib/ads/pricing";
-// The two ways a campaign stops serving look identical in the raw status
-// column, so these cases pin down which one the dashboard reports — and, more
-// importantly, whether it tells the advertiser to wait or to take action.
+// A campaign must never go dark for want of money — running out of credits or
+// hitting the daily cap drops it to the free tier, where it backfills unsold
+// inventory at no cost to anyone. These cases pin down that it keeps serving,
+// that it returns to paid delivery on its own, and that the dashboard says
+// which of the two reasons put it there.
const TODAY = "2026-07-30";
@@ -67,43 +70,90 @@ describe("isDailyBudgetReached", () => {
});
});
+describe("campaignTier", () => {
+ it("puts a funded, in-budget campaign on the paid tier", () => {
+ expect(campaignTier(campaign(), TODAY, 100)).toBe("paid");
+ });
+
+ it("drops a campaign that can't cover one click to the free tier", () => {
+ expect(campaignTier(campaign(), TODAY, DEFAULT_BID_CREDITS - 1)).toBe("free");
+ });
+
+ it("keeps a campaign paid when it can cover exactly one more click", () => {
+ expect(campaignTier(campaign(), TODAY, DEFAULT_BID_CREDITS)).toBe("paid");
+ });
+
+ it("drops a campaign over its daily cap to the free tier", () => {
+ expect(campaignTier(campaign({ spend_today_cents: 500 }), TODAY, 1000)).toBe("free");
+ });
+
+ it("treats the legacy exhausted status as free tier, not dark", () => {
+ // Rows written before the free tier existed must not stay dark forever.
+ expect(campaignTier(campaign({ status: "exhausted" }), TODAY, 1000)).toBe("free");
+ });
+
+ it("serves nothing for paused and draft", () => {
+ expect(campaignTier(campaign({ status: "paused" }), TODAY, 1000)).toBe("none");
+ expect(campaignTier(campaign({ status: "draft" }), TODAY, 1000)).toBe("none");
+ });
+
+ it("assumes paid when the balance wasn't looked up", () => {
+ // A caller without the balance to hand must not paint a funded campaign
+ // as broke; ad_charge_click still refuses to bill what isn't there.
+ expect(campaignTier(campaign(), TODAY, undefined)).toBe("paid");
+ expect(campaignTier(campaign(), TODAY, null)).toBe("paid");
+ });
+});
+
describe("campaignDisplayStatus", () => {
it("reports a live campaign as active and serving", () => {
- const d = campaignDisplayStatus(campaign(), TODAY);
+ const d = campaignDisplayStatus(campaign(), TODAY, 1000);
expect(d.label).toBe("active");
expect(d.serving).toBe(true);
+ expect(d.tier).toBe("paid");
});
- it("distinguishes the daily cap as a self-healing pause", () => {
- const d = campaignDisplayStatus(campaign({ spend_today_cents: 500 }), TODAY);
- expect(d.label).toBe("daily budget reached");
- expect(d.serving).toBe(false);
+ it("keeps a capped campaign serving on the free tier", () => {
+ const d = campaignDisplayStatus(campaign({ spend_today_cents: 500 }), TODAY, 1000);
+ expect(d.label).toBe("free tier");
+ expect(d.serving).toBe(true); // still rendering, as backfill
expect(d.resumesAutomatically).toBe(true);
expect(d.hint).toMatch(/00:00 UTC/);
});
- it("reports exhausted credits as needing manual action", () => {
- const d = campaignDisplayStatus(campaign({ status: "exhausted" }), TODAY);
- expect(d.label).toBe("out of credits");
- expect(d.serving).toBe(false);
- expect(d.resumesAutomatically).toBe(false);
- expect(d.hint).toMatch(/Activate/);
+ it("keeps a broke campaign serving instead of deactivating it", () => {
+ // The whole point: running dry must never take a campaign dark, and must
+ // never require the advertiser to press Activate again.
+ const d = campaignDisplayStatus(campaign(), TODAY, 0);
+ expect(d.label).toBe("free tier");
+ expect(d.serving).toBe(true);
+ expect(d.resumesAutomatically).toBe(true);
+ expect(d.hint).toMatch(/Top up/);
+ expect(d.hint).not.toMatch(/Activate/);
+ });
+
+ it("revives a legacy exhausted campaign", () => {
+ const d = campaignDisplayStatus(campaign({ status: "exhausted" }), TODAY, 1000);
+ expect(d.serving).toBe(true);
+ expect(d.tier).toBe("free");
+ expect(d.resumesAutomatically).toBe(true);
});
- it("keeps exhausted distinct from the daily cap even when under budget", () => {
- // Plenty of budget left — the stop is the empty wallet, not the cap.
- const d = campaignDisplayStatus(campaign({ status: "exhausted", spend_today_cents: 0 }), TODAY);
- expect(d.label).toBe("out of credits");
+ it("blames the empty wallet before the daily cap", () => {
+ // Both true at once — the advertiser needs the actionable one.
+ const d = campaignDisplayStatus(campaign({ spend_today_cents: 500 }), TODAY, 0);
+ expect(d.hint).toMatch(/Top up/);
});
it("passes through paused and draft", () => {
- expect(campaignDisplayStatus(campaign({ status: "paused" }), TODAY).label).toBe("paused");
- expect(campaignDisplayStatus(campaign({ status: "draft" }), TODAY).label).toBe("draft");
+ expect(campaignDisplayStatus(campaign({ status: "paused" }), TODAY, 1000).label).toBe("paused");
+ expect(campaignDisplayStatus(campaign({ status: "draft" }), TODAY, 1000).label).toBe("draft");
});
- it("does not report a budget pause for a campaign that isn't live", () => {
- const d = campaignDisplayStatus(campaign({ status: "paused", spend_today_cents: 500 }), TODAY);
+ it("does not report free tier for a campaign that isn't live", () => {
+ const d = campaignDisplayStatus(campaign({ status: "paused", spend_today_cents: 500 }), TODAY, 0);
expect(d.label).toBe("paused");
+ expect(d.serving).toBe(false);
expect(d.resumesAutomatically).toBe(false);
});
});
diff --git a/tests/ads-ranges.test.ts b/tests/ads-ranges.test.ts
new file mode 100644
index 00000000..295cbc42
--- /dev/null
+++ b/tests/ads-ranges.test.ts
@@ -0,0 +1,176 @@
+import { describe, expect, it } from "vitest";
+import {
+ DEFAULT_RANGE,
+ RANGES,
+ bucketAxis,
+ bucketOf,
+ formatBucket,
+ rangeSince,
+ resolveRange,
+ type RangeId,
+} from "@/lib/ads/ranges";
+
+// The range picker drives every number on the dashboard, so the axis it builds
+// has to line up exactly with the buckets Postgres emits. SQL uses
+// date_bin(step, ts, 'epoch') — an epoch-aligned grid — and if the JS axis were
+// aligned to "now" instead, every point would miss its slot by the offset
+// between now and the epoch and the chart would render all zeros.
+
+const NOW = new Date("2026-07-31T12:34:56.000Z");
+const byId = (id: RangeId) => RANGES.find((r) => r.id === id)!;
+
+describe("resolveRange", () => {
+ it("accepts every declared id", () => {
+ for (const r of RANGES) expect(resolveRange(r.id).id).toBe(r.id);
+ });
+
+ it("is case- and whitespace-insensitive", () => {
+ expect(resolveRange(" 1H ").id).toBe("1h");
+ expect(resolveRange("ALL").id).toBe("all");
+ });
+
+ it("falls back to the default for junk rather than throwing", () => {
+ // ?range= is user-controlled, so a bad value must render the dashboard.
+ expect(resolveRange("../../etc/passwd").id).toBe(DEFAULT_RANGE);
+ expect(resolveRange("").id).toBe(DEFAULT_RANGE);
+ expect(resolveRange(undefined).id).toBe(DEFAULT_RANGE);
+ });
+
+ it("takes the first value when the param repeats", () => {
+ expect(resolveRange(["4h", "1y"]).id).toBe("4h");
+ });
+});
+
+describe("rangeSince", () => {
+ it("is null for all time, so the RPC scans everything", () => {
+ expect(rangeSince(byId("all"), NOW)).toBeNull();
+ });
+
+ it("subtracts the window from now", () => {
+ expect(rangeSince(byId("1h"), NOW)).toBe("2026-07-31T11:34:56.000Z");
+ expect(rangeSince(byId("1d"), NOW)).toBe("2026-07-30T12:34:56.000Z");
+ });
+});
+
+describe("bucketAxis", () => {
+ it("aligns buckets to the epoch, not to now", () => {
+ // 12:34:56 with 1-minute buckets must land on :34:00, matching date_bin.
+ const axis = bucketAxis(byId("1h"), NOW);
+ const last = new Date(axis.at(-1)!);
+ expect(last.toISOString()).toBe("2026-07-31T12:34:00.000Z");
+ });
+
+ it("aligns coarser buckets to the epoch grid too", () => {
+ // 5-minute buckets → :30, not :34.
+ const axis = bucketAxis(byId("4h"), NOW);
+ expect(new Date(axis.at(-1)!).toISOString()).toBe("2026-07-31T12:30:00.000Z");
+ });
+
+ it("is sorted oldest first with no gaps", () => {
+ for (const r of RANGES) {
+ if (r.windowSeconds == null) continue;
+ const axis = bucketAxis(r, NOW);
+ const step = r.bucketSeconds * 1000;
+ for (let i = 1; i < axis.length; i++) expect(axis[i] - axis[i - 1]).toBe(step);
+ }
+ });
+
+ it("covers the whole window for every range", () => {
+ for (const r of RANGES) {
+ if (r.windowSeconds == null) continue;
+ const axis = bucketAxis(r, NOW);
+ const span = axis.at(-1)! - axis[0];
+ expect(span).toBeGreaterThanOrEqual(r.windowSeconds * 1000 - r.bucketSeconds * 1000);
+ }
+ });
+
+ it("keeps every range in a readable point band", () => {
+ // Too few and a line reads as a bar chart; too many and buckets fall under
+ // a pixel — and the row count starts approaching PostgREST's 1000 cap.
+ for (const r of RANGES) {
+ if (r.windowSeconds == null) continue;
+ const n = bucketAxis(r, NOW).length;
+ expect(n).toBeGreaterThanOrEqual(24);
+ expect(n).toBeLessThanOrEqual(100);
+ }
+ });
+
+ it("gives all-time a single seed bucket to grow from", () => {
+ expect(bucketAxis(byId("all"), NOW)).toHaveLength(1);
+ });
+});
+
+describe("bucketOf", () => {
+ it("snaps a timestamp onto the same grid the axis uses", () => {
+ const r = byId("1h");
+ const axis = bucketAxis(r, NOW);
+ // Any instant inside the last bucket must map onto that bucket.
+ expect(bucketOf("2026-07-31T12:34:59.999Z", r)).toBe(axis.at(-1));
+ expect(bucketOf("2026-07-31T12:34:00.000Z", r)).toBe(axis.at(-1));
+ });
+
+ it("puts a timestamp one tick earlier in the previous bucket", () => {
+ const r = byId("1h");
+ const axis = bucketAxis(r, NOW);
+ expect(bucketOf("2026-07-31T12:33:59.999Z", r)).toBe(axis.at(-2));
+ });
+
+ it("agrees with the axis for every range", () => {
+ for (const r of RANGES) {
+ if (r.windowSeconds == null) continue;
+ const axis = bucketAxis(r, NOW);
+ const set = new Set(axis);
+ // A sample inside the window must always find a home in the axis,
+ // otherwise its data would be silently dropped from the chart.
+ const midpoint = new Date(NOW.getTime() - (r.windowSeconds * 1000) / 2).toISOString();
+ expect(set.has(bucketOf(midpoint, r))).toBe(true);
+ }
+ });
+});
+
+describe("formatBucket", () => {
+ const t = Date.UTC(2026, 6, 31, 12, 0, 0);
+
+ it("renders something non-empty for every tick style", () => {
+ for (const tick of ["time", "datetime", "date", "month"] as const) {
+ expect(formatBucket(t, tick).length).toBeGreaterThan(0);
+ }
+ });
+
+ it("gets coarser as the range widens", () => {
+ // A 1Y axis labelled with minutes would be unreadable.
+ expect(byId("1h").tick).toBe("time");
+ expect(byId("1y").tick).toBe("month");
+ });
+});
+
+describe("range catalog", () => {
+ it("has unique ids and labels", () => {
+ expect(new Set(RANGES.map((r) => r.id)).size).toBe(RANGES.length);
+ expect(new Set(RANGES.map((r) => r.label)).size).toBe(RANGES.length);
+ });
+
+ it("runs shortest to longest", () => {
+ const windows = RANGES.map((r) => r.windowSeconds ?? Infinity);
+ expect([...windows].sort((a, b) => a - b)).toEqual(windows);
+ });
+
+ it("never buckets below the SQL floor of 60s", () => {
+ // ad_account_series clamps p_bucket_seconds to >= 60; a smaller value here
+ // would make the JS axis finer than the data can ever be.
+ for (const r of RANGES) expect(r.bucketSeconds).toBeGreaterThanOrEqual(60);
+ });
+
+ it("covers 1H through ALL as asked", () => {
+ expect(RANGES.map((r) => r.label)).toEqual([
+ "1H",
+ "4H",
+ "1D",
+ "1W",
+ "1M",
+ "3M",
+ "1Y",
+ "ALL",
+ ]);
+ });
+});