From a5aebd4bfdd2d3439dbf0741e98e869508c7f625 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 31 Jul 2026 06:16:42 +0000 Subject: [PATCH 1/2] feat(ads): free tier backfill instead of deactivating broke campaigns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 never touched campaign status — so the campaign died silently and needed a manual Activate. Meanwhile the slot that would have shown it fell back to a CrawlProof house ad, which earns the publisher exactly as little as the advertiser's ad would have. A dry campaign now keeps serving as free backfill: it fills requests no paying campaign wanted, bills nobody, and accrues nothing. 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 inventory full. Paid delivery resumes on its own the moment credits arrive or the daily budget rolls over at 00:00 UTC. Serving is now two-tier. serveAd() partitions candidates by whether the owner can actually cover a click at the campaign's bid, runs the bid-weighted auction over the paid set only, and falls through to a uniform pick from the free set. Paid inventory is never displaced: free-tier ads only ever fill what the auction left empty, so this cannot cannibalise publisher earnings. The funds check is new at serve time — without it a broke campaign would win the auction and hand the publisher an unbillable click on inventory a funded advertiser wanted. ad_impressions.tier and ad_clicks.tier record which inventory each event came from, and the stats views report paid and free separately. Without that split, free delivery would inflate impressions while spend and earnings stayed flat, and both sides would think their rates collapsed. 'exhausted' is no longer written; existing rows are reactivated by the migration and tolerated as free tier by serveAd() and campaignTier(). Solvency is untouched: free-tier clicks charge 0 and accrue 0, so they add no publisher liability. Co-Authored-By: Claude Opus 5 (1M context) --- app/(app)/ads/[id]/page.tsx | 29 ++- app/(app)/ads/page.tsx | 13 +- lib/ads/house.ts | 1 + lib/ads/serve.ts | 107 ++++++-- lib/ads/status.ts | 97 ++++--- .../20260731130000_ad_free_tier.sql | 243 ++++++++++++++++++ tests/ads-campaign-status.test.ts | 94 +++++-- 7 files changed, 500 insertions(+), 84 deletions(-) create mode 100644 supabase/migrations/20260731130000_ad_free_tier.sql 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..24440dcb 100644 --- a/app/(app)/ads/page.tsx +++ b/app/(app)/ads/page.tsx @@ -46,8 +46,11 @@ export default async function AdsPage() { let campaigns: CampaignRow[] = []; const statsById = new Map(); let seriesById = 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: stats }, { data: profile }] = await Promise.all([ supabase .from("ad_campaigns") .select( @@ -57,7 +60,13 @@ export default async function AdsPage() { supabase .from("ad_campaign_stats") .select("campaign_id, impressions, clicks, spent_cents, total_spent_cents"), + supabase + .from("profiles") + .select("credits_balance, ad_bonus_credits") + .eq("id", user.id) + .maybeSingle(), ]); + 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); seriesById = await getCampaignDailySeries( @@ -122,7 +131,7 @@ export default async function AdsPage() { const s = statsById.get(c.id); const impr = s?.impressions ?? 0; const clk = s?.clicks ?? 0; - const display = campaignDisplayStatus(c, today); + const display = campaignDisplayStatus(c, today, creditsAvailable); 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/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/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); }); }); From 27dd245ab6b3a9ac45a3edfb46295a258c0a330e Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 31 Jul 2026 07:16:47 +0000 Subject: [PATCH 2/2] feat(ads): stock-chart time ranges on the campaigns dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 1H · 4H · 1D · 1W · 1M · 3M · 1Y · ALL to /ads, with a delivery chart and range-scoped stats above the campaign list. ad_campaign_daily_series only buckets by UTC calendar day, so it can't answer "the last hour in one-minute steps". Two new RPCs take the window and bucket width as parameters instead, split deliberately because of the PostgREST 1000-row cap that forced server-side aggregation in the first place: * 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 unbucketed, so it's (campaigns) rows. Enough to make the list obey the same range without re-introducing the product. Both are security invoker with 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. Ranges are sized to land in a 24–100 point band: fewer and a line reads as a bar chart, more and buckets fall under a pixel. The JS axis is aligned to the epoch to match SQL's date_bin(step, ts, 'epoch') — aligning to "now" instead would offset every point off its slot and render an all-zero chart. Tests pin that agreement down per range. Range lives in the URL so it survives a refresh and can be linked, and scopes everything below it — header stats, chart and per-campaign rows all read the same slice, so the numbers on the page agree. The chart plots impressions only, paid vs free stacked: one measure, one unit, one axis. Clicks run ~1% of impressions and spend is money, so either as a second line would need a second y-scale, and a dual-axis chart invites the false correlations it appears to show. Both live in the stat tiles instead, with sparklines. Chart series get their own tokens rather than reusing --color-accent / --color-warn: the brand pair sits at OKLCH L ~0.84, outside the 0.48–0.67 band a dark chart surface wants. --color-chart-1/2 are the same hues stepped darker and pass all six palette checks against --color-card — lightness band, chroma floor, CVD separation (ΔE 10.4 deutan), normal-vision separation (ΔE 21.2) and 3:1 contrast. Co-Authored-By: Claude Opus 5 (1M context) --- app/(app)/ads/page.tsx | 135 ++++++++++---- app/globals.css | 8 + components/ads/account-trend.tsx | 118 ++++++++++++ components/ads/range-tabs.tsx | 63 +++++++ components/ads/stat-spark.tsx | 62 ++++++ lib/ads/ranges.ts | 97 ++++++++++ lib/ads/series.ts | 147 +++++++++++++++ .../20260731140000_ad_range_series.sql | 136 ++++++++++++++ tests/ads-ranges.test.ts | 176 ++++++++++++++++++ 9 files changed, 905 insertions(+), 37 deletions(-) create mode 100644 components/ads/account-trend.tsx create mode 100644 components/ads/range-tabs.tsx create mode 100644 components/ads/stat-spark.tsx create mode 100644 lib/ads/ranges.ts create mode 100644 supabase/migrations/20260731140000_ad_range_series.sql create mode 100644 tests/ads-ranges.test.ts diff --git a/app/(app)/ads/page.tsx b/app/(app)/ads/page.tsx index 24440dcb..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,38 +43,44 @@ 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 }, { data: profile }] = await Promise.all([ + const [{ data }, { data: profile }, accountSeries, campaignTotals] = await Promise.all([ supabase .from("ad_campaigns") .select( "id, ref_slug, name, destination_domain, daily_budget_cents, bid_credits, spend_today_cents, spend_date, status, created_at", ) .order("created_at", { ascending: false }), - supabase - .from("ad_campaign_stats") - .select("campaign_id, impressions, clicks, spent_cents, total_spent_cents"), supabase .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), @@ -77,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 (
    @@ -109,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 ? ( @@ -128,9 +176,10 @@ export default async function AdsPage() { ) : (
      {campaigns.map((c) => { - const s = statsById.get(c.id); - const impr = s?.impressions ?? 0; - const clk = s?.clicks ?? 0; + // 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 (
    • @@ -161,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 ( + + ); + })} +
    + ); +} 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