diff --git a/app/(app)/ads/[id]/page.tsx b/app/(app)/ads/[id]/page.tsx index f8760918..6b057712 100644 --- a/app/(app)/ads/[id]/page.tsx +++ b/app/(app)/ads/[id]/page.tsx @@ -6,6 +6,7 @@ import { AdPreview } from "@/components/ads/ad-preview"; import { CampaignActions, RegenerateButton } from "@/components/ads/campaign-actions"; import { CampaignTrend } from "@/components/ads/campaign-trend"; import { getCampaignDailySeries } from "@/lib/ads/series"; +import { campaignDisplayStatus, spendTodayCents, utcToday } from "@/lib/ads/status"; export const metadata = { title: "Campaign" }; @@ -47,7 +48,7 @@ export default async function CampaignDetailPage({ const { data: campaign } = await supabase .from("ad_campaigns") .select( - "id, name, destination_url, destination_domain, daily_budget_cents, bid_credits, status, ref_slug, created_at, spend_today_cents, total_spent_cents", + "id, name, destination_url, destination_domain, daily_budget_cents, bid_credits, status, ref_slug, created_at, spend_today_cents, spend_date, total_spent_cents", ) .eq("id", id) .eq("owner_id", user.id) @@ -73,6 +74,8 @@ export default async function CampaignDetailPage({ const impressions = (stats?.impressions as number) ?? 0; const clicks = (stats?.clicks as number) ?? 0; const daily = series.get(id) ?? []; + const today = utcToday(); + const display = campaignDisplayStatus(campaign, today); const creatives: (AdCreative & { id: string })[] = ((creativeRows as CreativeRow[]) ?? []).map( (r) => ({ @@ -115,7 +118,9 @@ export default async function CampaignDetailPage({

- {campaign.status} + + {display.label} + Edit @@ -124,12 +129,18 @@ export default async function CampaignDetailPage({
+ {!display.serving && ( +

+ {display.hint} +

+ )} +
- +
diff --git a/app/(app)/ads/page.tsx b/app/(app)/ads/page.tsx index fb976f95..01b7781b 100644 --- a/app/(app)/ads/page.tsx +++ b/app/(app)/ads/page.tsx @@ -3,6 +3,7 @@ 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 { campaignDisplayStatus, spendTodayCents, utcToday } from "@/lib/ads/status"; export const metadata = { title: "Ad campaigns" }; @@ -12,6 +13,9 @@ type CampaignRow = { name: string; destination_domain: string | null; daily_budget_cents: number; + bid_credits: number | null; + spend_today_cents: number | null; + spend_date: string | null; status: string; created_at: string; }; @@ -21,7 +25,6 @@ type StatRow = { impressions: number; clicks: number; spent_cents: number; - spend_today_cents: number; total_spent_cents: number; }; @@ -47,11 +50,13 @@ export default async function AdsPage() { const [{ data }, { data: stats }] = await Promise.all([ supabase .from("ad_campaigns") - .select("id, ref_slug, name, destination_domain, daily_budget_cents, status, created_at") + .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, spend_today_cents, total_spent_cents"), + .select("campaign_id, impressions, clicks, spent_cents, total_spent_cents"), ]); campaigns = (data as CampaignRow[]) ?? []; for (const s of (stats as StatRow[]) ?? []) statsById.set(s.campaign_id, s); @@ -62,6 +67,8 @@ export default async function AdsPage() { ); } + const today = utcToday(); + const totals = [...statsById.values()].reduce( (a, s) => ({ impressions: a.impressions + (s.impressions ?? 0), @@ -115,6 +122,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); return (
  • @@ -131,7 +139,9 @@ export default async function AdsPage() { - {c.status} + + {display.label} + Edit @@ -143,8 +153,14 @@ export default async function AdsPage() { - +
    + {!display.serving && ( +

    {display.hint}

    + )}
  • ); })} diff --git a/lib/ads/status.ts b/lib/ads/status.ts new file mode 100644 index 00000000..af89d16f --- /dev/null +++ b/lib/ads/status.ts @@ -0,0 +1,100 @@ +// 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: +// +// * 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. +// +// 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. + +import { CREDIT_CENTS, DEFAULT_BID_CREDITS } from "./pricing"; + +export type CampaignBudgetFields = { + status: string; + daily_budget_cents: number; + spend_today_cents?: number | null; + spend_date?: string | null; + bid_credits?: number | null; +}; + +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. */ + serving: boolean; + /** Comes back without the advertiser doing anything. */ + resumesAutomatically: boolean; +}; + +/** The day boundary the budget counter resets on — UTC, matching SQL current_date. */ +export function utcToday(now: Date = new Date()): string { + return now.toISOString().slice(0, 10); +} + +// Spend that counts against today's cap. The stored counter is only meaningful +// when spend_date is today; on a new UTC day it's last-active-day's total that +// nothing has zeroed yet (both serveAd and ad_charge_click reset it lazily). +export function spendTodayCents(c: CampaignBudgetFields, today: string = utcToday()): number { + return c.spend_date === today ? (c.spend_today_cents ?? 0) : 0; +} + +/** True when one more click at this campaign's bid would exceed the daily cap. */ +export function isDailyBudgetReached( + c: CampaignBudgetFields, + today: string = utcToday(), +): boolean { + const bid = c.bid_credits ?? DEFAULT_BID_CREDITS; + // Mirrors the eligibility filter in serveAd() — keep the two in step. + return spendTodayCents(c, today) + bid * CREDIT_CENTS > c.daily_budget_cents; +} + +export function campaignDisplayStatus( + c: CampaignBudgetFields, + today: string = utcToday(), +): 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, + }; + } + + if (c.status !== "active") { + return { + label: c.status, + hint: + c.status === "paused" + ? "Paused by you. Press Activate to resume." + : "Not live yet. Press Activate to start serving.", + serving: false, + resumesAutomatically: false, + }; + } + + if (isDailyBudgetReached(c, today)) { + 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, + resumesAutomatically: true, + }; + } + + return { + label: "active", + hint: "Live and eligible to serve.", + serving: true, + resumesAutomatically: true, + }; +} diff --git a/tests/ads-campaign-status.test.ts b/tests/ads-campaign-status.test.ts new file mode 100644 index 00000000..e271e283 --- /dev/null +++ b/tests/ads-campaign-status.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { + campaignDisplayStatus, + isDailyBudgetReached, + spendTodayCents, + utcToday, + type CampaignBudgetFields, +} 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. + +const TODAY = "2026-07-30"; + +const campaign = (over: Partial = {}): CampaignBudgetFields => ({ + status: "active", + daily_budget_cents: 500, + spend_today_cents: 0, + spend_date: TODAY, + bid_credits: DEFAULT_BID_CREDITS, + ...over, +}); + +describe("spendTodayCents", () => { + it("counts spend recorded today", () => { + expect(spendTodayCents(campaign({ spend_today_cents: 320 }), TODAY)).toBe(320); + }); + + it("ignores a stale counter from an earlier UTC day", () => { + // ad_charge_click/serveAd both reset lazily, so the stored column still + // holds yesterday's total until the next click lands. + expect( + spendTodayCents(campaign({ spend_today_cents: 500, spend_date: "2026-07-29" }), TODAY), + ).toBe(0); + }); + + it("treats a never-spent campaign as zero", () => { + expect(spendTodayCents(campaign({ spend_today_cents: null, spend_date: null }), TODAY)).toBe(0); + }); +}); + +describe("isDailyBudgetReached", () => { + it("is false while one more click still fits under the cap", () => { + // 500c cap, 480c spent, 4-credit bid = 20c → exactly fits. + expect(isDailyBudgetReached(campaign({ spend_today_cents: 480 }), TODAY)).toBe(false); + }); + + it("is true once the next click would exceed the cap", () => { + expect(isDailyBudgetReached(campaign({ spend_today_cents: 481 }), TODAY)).toBe(true); + }); + + it("trips sooner for a higher bid", () => { + const c = campaign({ spend_today_cents: 400, bid_credits: 40 }); // 40 × 5c = 200c + expect(isDailyBudgetReached(c, TODAY)).toBe(true); + }); + + it("falls back to the default bid when the column is missing", () => { + const c = campaign({ spend_today_cents: 500 - DEFAULT_BID_CREDITS * CREDIT_CENTS, bid_credits: null }); + expect(isDailyBudgetReached(c, TODAY)).toBe(false); + }); + + it("clears on the next UTC day even with a maxed-out stale counter", () => { + const c = campaign({ spend_today_cents: 500, spend_date: "2026-07-29" }); + expect(isDailyBudgetReached(c, TODAY)).toBe(false); + }); +}); + +describe("campaignDisplayStatus", () => { + it("reports a live campaign as active and serving", () => { + const d = campaignDisplayStatus(campaign(), TODAY); + expect(d.label).toBe("active"); + expect(d.serving).toBe(true); + }); + + 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); + 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 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("passes through paused and draft", () => { + expect(campaignDisplayStatus(campaign({ status: "paused" }), TODAY).label).toBe("paused"); + expect(campaignDisplayStatus(campaign({ status: "draft" }), TODAY).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); + expect(d.label).toBe("paused"); + expect(d.resumesAutomatically).toBe(false); + }); +}); + +describe("utcToday", () => { + it("uses the UTC calendar day, matching SQL current_date", () => { + // 23:30 UTC-adjacent local times must not roll the day forward/back. + expect(utcToday(new Date("2026-07-30T23:30:00Z"))).toBe("2026-07-30"); + expect(utcToday(new Date("2026-07-31T00:10:00Z"))).toBe("2026-07-31"); + }); +});