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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions app/(app)/ads/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" };

Expand Down Expand Up @@ -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)
Expand All @@ -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) => ({
Expand Down Expand Up @@ -115,7 +118,9 @@ export default async function CampaignDetailPage({
</p>
</div>
<div className="flex items-center gap-2">
<span className="badge whitespace-nowrap">{campaign.status}</span>
<span className="badge whitespace-nowrap" title={display.hint}>
{display.label}
</span>
<Link href={`/ads/${id}/edit`} className="btn text-sm">
Edit
</Link>
Expand All @@ -124,12 +129,18 @@ export default async function CampaignDetailPage({
</div>
</div>

{!display.serving && (
<p className="mt-4 rounded-md border border-[var(--color-border)] bg-[var(--color-surface,transparent)] p-3 text-sm text-[var(--color-muted)]">
{display.hint}
</p>
)}

<div className="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<Stat label="Impressions" value={impressions.toLocaleString()} />
<Stat label="Clicks" value={clicks.toLocaleString()} />
<Stat label="CTR" value={ctr(clicks, impressions)} />
<Stat label="Total spend" value={dollars(campaign.total_spent_cents)} />
<Stat label="Today" value={dollars(campaign.spend_today_cents)} />
<Stat label="Today" value={dollars(spendTodayCents(campaign, today))} />
<Stat label="Daily budget" value={dollars(campaign.daily_budget_cents)} />
</div>

Expand Down
26 changes: 21 additions & 5 deletions app/(app)/ads/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" };

Expand All @@ -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;
};
Expand All @@ -21,7 +25,6 @@ type StatRow = {
impressions: number;
clicks: number;
spent_cents: number;
spend_today_cents: number;
total_spent_cents: number;
};

Expand All @@ -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);
Expand All @@ -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),
Expand Down Expand Up @@ -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 (
<li key={c.id} className="card p-4">
<div className="flex items-center justify-between gap-4">
Expand All @@ -131,7 +139,9 @@ export default async function AdsPage() {
<Link href={`/ads/${c.id}`} className="hidden sm:block" aria-label="View campaign">
<MiniTrend data={seriesById.get(c.id) ?? []} />
</Link>
<span className="badge whitespace-nowrap">{c.status}</span>
<span className="badge whitespace-nowrap" title={display.hint}>
{display.label}
</span>
<Link href={`/ads/${c.id}/edit`} className="btn text-sm">
Edit
</Link>
Expand All @@ -143,8 +153,14 @@ export default async function AdsPage() {
<MiniStat label="Clicks" value={clk.toLocaleString()} />
<MiniStat label="CTR" value={ctr(clk, impr)} />
<MiniStat label="Spent" value={dollars(s?.total_spent_cents ?? 0)} />
<MiniStat label="Today" value={dollars(s?.spend_today_cents ?? 0)} />
<MiniStat
label="Today"
value={`${dollars(spendTodayCents(c, today))} / ${dollars(c.daily_budget_cents)}`}
/>
</div>
{!display.serving && (
<p className="mt-2 text-sm text-[var(--color-muted)]">{display.hint}</p>
)}
</li>
);
})}
Expand Down
100 changes: 100 additions & 0 deletions lib/ads/status.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
117 changes: 117 additions & 0 deletions tests/ads-campaign-status.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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");
});
});
Loading