Skip to content

feat(ads): free-tier backfill + stock-chart time ranges - #171

Merged
ralyodio merged 2 commits into
masterfrom
feat/ad-free-tier-and-ranges
Jul 31, 2026
Merged

ralyodio merged 2 commits into
masterfrom
feat/ad-free-tier-and-ranges

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Follow-on to #170. That PR was merged while the branch held only its first commit, so these two were left stranded on the merged branch — both of their migrations are already applied to production, but their application code never reached master. Cherry-picked onto current master; clean, no conflicts.


1. Campaigns never go dark — they drop to a free tier

Running out of money used to kill a campaign: ad_charge_click flipped status to 'exhausted' and nothing ever flipped it back (a deposit grants credits but doesn't touch campaign status), so it needed a manual Activate. The slot then 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, accrues nothing. Better than a house ad for everyone — the advertiser keeps getting traffic and a reason to top up, the publisher shows a real ad, the network keeps inventory full. Paid delivery resumes on its own when credits arrive or the budget rolls over at 00:00 UTC.

Two-tier serving. serveAd() partitions candidates by whether the owner can cover a click at their bid, runs the bid-weighted auction over the paid set only, then falls through to a uniform pick from the free set (no bid weighting — nobody's paying, so a high bid buys no priority).

Paid inventory is never displaced: free-tier ads only fill what the auction left empty, so this cannot cannibalise publisher earnings. The serve-time funds check is new and load-bearing — 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 / 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.

2. Stock-chart time ranges on /ads

1H · 4H · 1D · 1W · 1M · 3M · 1Y · ALL, 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 — split deliberately because of the PostgREST 1000-row cap that forced server-side aggregation originally:

RPC Rows Why
ad_account_series (buckets) Account-wide. 34 campaigns × 90 daily buckets = 3,060 rows would silently truncate; account-wide it's 90.
ad_campaign_totals (campaigns) Per campaign but unbucketed — lets 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.

  • Epoch-aligned axis. The JS axis matches 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/ads-ranges.test.ts pins that agreement per range.
  • Range lives in the URL, survives refresh, is linkable, and scopes everything below it — header stats, chart and per-campaign rows all read the same slice.
  • Single axis. The chart plots impressions only, paid vs free stacked — one measure, one unit. Clicks run ~1% of impressions and spend is money, so either as a second line needs a second y-scale, and a dual-axis chart invites the false correlations it appears to show. Both live in stat tiles with sparklines instead.
  • Chart tokens. --color-chart-1/2 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. The new pair is the same hues stepped darker and passes all six palette checks against --color-card — lightness band, chroma floor, CVD separation (ΔE 10.4 deutan), normal-vision separation (ΔE 21.2), 3:1 contrast. Brand tokens untouched elsewhere.

Verification

Migrations already applied to production. Guards probed live in rolled-back transactions:

Probe Result
Advertiser broke charged=0 earn=0 tier=free status=active ← did not deactivate
Advertiser funded charged=20 earn=5 tier=paid
Over daily budget charged=0 tier=free status=active

RPCs under a real user's RLS context:

DAILY: 07-31 imp=283 | 07-30 imp=2351 | 07-29 imp=818 clk=1 spent=25
ALL (weekly, 5 buckets): 07-30 imp=2634 | 07-23 imp=7266 | 07-16 imp=4687
TOTALS: imp=1108 clk=2 spent=40 | imp=1073 clk=4 spent=80

Re-verified after the cherry-pick onto current master: tsc --noEmit clean, 1217 passed / 0 failures, next build compiles with /ads rendering.

🤖 Generated with Claude Code

ralyodio and others added 2 commits July 31, 2026 07:28
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

vu1nz Security Review

0 finding(s) in PR #?

No security issues found.

@ralyodio
ralyodio marked this pull request as ready for review July 31, 2026 07:41
@ralyodio
ralyodio merged commit adeee44 into master Jul 31, 2026
8 checks passed
@ralyodio
ralyodio deleted the feat/ad-free-tier-and-ranges branch July 31, 2026 07:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant