Repository navigation
feat(ads): free-tier backfill + stock-chart time ranges - #171
Merged
Merged
Conversation
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>
vu1nz Security Review0 finding(s) in PR #? No security issues found. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_clickflippedstatusto'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.tierrecord 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 byserveAd()andcampaignTier().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_seriesonly 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:ad_account_seriesad_campaign_totalsBoth are
security invokerwith an explicitowner_id = auth.uid()scope, so publisher-side read grants onad_impressions/ad_clickscan't leak another advertiser's campaigns in.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.tspins that agreement per range.--color-chart-1/2rather 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:
charged=0 earn=0 tier=free status=active← did not deactivatecharged=20 earn=5 tier=paidcharged=0 tier=free status=activeRPCs under a real user's RLS context:
Re-verified after the cherry-pick onto current master:
tsc --noEmitclean, 1217 passed / 0 failures,next buildcompiles with/adsrendering.🤖 Generated with Claude Code