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
2 changes: 1 addition & 1 deletion app/(app)/dashboard/ads/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export default async function CampaignDetailPage({
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 daily = series.data.get(id) ?? [];
const today = utcToday();
const creditsAvailable = (profile?.credits_balance ?? 0) + (profile?.ad_bonus_credits ?? 0);
const display = campaignDisplayStatus(campaign, today, creditsAvailable);
Expand Down
7 changes: 7 additions & 0 deletions app/(app)/dashboard/ads/earnings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import dynamic from "next/dynamic";
import { createClient } from "@/lib/supabase/server";
import { loadEarnings, dollars, type EarningsModel } from "@/lib/ads/earnings-data";
import { EarningsPdfButton } from "@/components/ads/earnings-pdf-button";
import { StatsUnavailable } from "@/components/ads/stats-unavailable";

// recharts is client-only; keep it out of the server bundle.
const MoneyTrend = dynamic(() => import("@/components/ads/money-trend").then((m) => m.MoneyTrend));
Expand All @@ -17,6 +18,8 @@ function ctr(clicks: number, impressions: number): string {

const EMPTY: EarningsModel = {
rangeDays: RANGE,
// Signed out: nothing was attempted, so nothing failed.
statsUnavailable: false,
totals: {
spentCents: 0,
earnedCents: 0,
Expand Down Expand Up @@ -72,6 +75,10 @@ export default async function EarningsPage() {
you spend as an advertiser. Download a PDF report for your accountant or team.
</p>

{model.statsUnavailable && (
<StatsUnavailable what="delivery figures for this period" />
)}

{/* Balances, not a period. "Available to withdraw" is lifetime earnings
minus lifetime payouts; clipping it to the last {RANGE} days would
under-report money the account is owed. The tables below are the ones
Expand Down
15 changes: 12 additions & 3 deletions app/(app)/dashboard/ads/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { MiniTrend } from "@/components/ads/mini-trend";
import { AccountTrend } from "@/components/ads/account-trend";
import { RangeTabs } from "@/components/ads/range-tabs";
import { StatSpark } from "@/components/ads/stat-spark";
import { StatsUnavailable } from "@/components/ads/stats-unavailable";
import {
deliveredClicks,
deliveredImpressions,
Expand Down Expand Up @@ -66,6 +67,10 @@ export default async function AdsPage({
// 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;
// A failed stats query zero-fills, so without this the page would report a
// confident 0 for a range it simply could not read. See Loaded<> in
// lib/ads/series.ts.
let statsFailed = false;
if (user) {
const [{ data }, { data: profile }, accountSeries, campaignTotals] = await Promise.all([
supabase
Expand All @@ -84,13 +89,15 @@ export default async function AdsPage({
]);
creditsAvailable = (profile?.credits_balance ?? 0) + (profile?.ad_bonus_credits ?? 0);
campaigns = (data as CampaignRow[]) ?? [];
series = accountSeries;
rangeById = campaignTotals;
seriesById = await getCampaignDailySeries(
series = accountSeries.data;
rangeById = campaignTotals.data;
const daily = await getCampaignDailySeries(
supabase,
campaigns.map((c) => c.id),
30,
);
seriesById = daily.data;
statsFailed = accountSeries.failed || campaignTotals.failed || daily.failed;
}

const today = utcToday();
Expand Down Expand Up @@ -128,6 +135,8 @@ export default async function AdsPage({
<span className="text-sm text-[var(--color-muted)]">{range.hint}</span>
</div>

{statsFailed && <StatsUnavailable what="delivery stats" />}

{/* Delivery first, revenue second. The tiles count every ad actually
shown — paid inventory plus free backfill — and name the split
underneath, so a range in which nothing was billable reports the
Expand Down
8 changes: 7 additions & 1 deletion app/(app)/dashboard/ads/slots/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 { env } from "@/lib/env";
import { fetchSupportedTokens } from "@/lib/coinpay-tokens";
import { SlotManager } from "@/components/ads/slot-manager";
import { StatsUnavailable } from "@/components/ads/stats-unavailable";
import {
deliveredClicks,
deliveredImpressions,
Expand Down Expand Up @@ -53,6 +54,8 @@ export default async function SlotsPage() {
const withdrawnBySlot = new Map<string, number>();
const payoutsBySlot = new Map<string, Payout[]>();
let statsBySlot = new Map<string, SlotTotals>();
// Distinguishes "no earnings yet" from "could not read them".
let statsFailed = false;
if (user) {
const [{ data: p }, { data: s }, { data: ledger }, { data: payouts }, slotStats] =
await Promise.all([
Expand All @@ -77,7 +80,8 @@ export default async function SlotsPage() {
]);
projects = (p as Project[]) ?? [];
slots = (s as Slot[]) ?? [];
statsBySlot = slotStats;
statsBySlot = slotStats.data;
statsFailed = slotStats.failed;
for (const row of (ledger as { slot_id: string; amount_cents: number }[]) ?? []) {
if (row.slot_id) earnedBySlot.set(row.slot_id, (earnedBySlot.get(row.slot_id) ?? 0) + (row.amount_cents ?? 0));
}
Expand Down Expand Up @@ -124,6 +128,8 @@ export default async function SlotsPage() {
for the clicks.
</p>

{statsFailed && <StatsUnavailable what="your delivery and earnings figures" />}

{projects.length === 0 ? (
<div className="card mt-6 p-8 text-center text-[var(--color-muted)]">
No sites yet — a site is a CrawlProof project.{" "}
Expand Down
23 changes: 23 additions & 0 deletions components/ads/stats-unavailable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* Shown when a stats query failed, in place of the zeros it would otherwise
* have rendered.
*
* The ad dashboards zero-fill on failure so one bad panel cannot take the page
* down. Without this banner that choice is indistinguishable from a real run of
* no delivery, which is how a live network reporting six figures of impressions
* came to show four zeros and read as a dead pipeline.
*/
export function StatsUnavailable({ what = "these figures" }: { what?: string }) {
return (
<p
role="status"
className="mt-3 rounded border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-sm text-[var(--color-muted)]"
>
<span className="font-semibold text-[var(--color-fg)]">
Couldn&apos;t load {what}.
</span>{" "}
The figures below are not zero — they are missing. This is usually a query
that took too long; reloading often fixes it.
</p>
);
}
22 changes: 18 additions & 4 deletions lib/ads/earnings-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ export type EarningsPayoutRow = {

export type EarningsModel = {
rangeDays: number;
/**
* True when a delivery query failed and its figures were zero-filled.
*
* Worth carrying all the way into the model because this feeds the PDF report
* as well as the page: without it a cancelled query ships as a document
* stating the account delivered nothing, which is a stronger claim than
* anything we actually know.
*/
statsUnavailable: boolean;
/**
* Money is a balance and delivery is a rate, so they answer different
* questions and cannot share a window.
Expand Down Expand Up @@ -157,7 +166,7 @@ export async function loadEarnings(
// campaigns dashboard reports. A free-tier impression is still an impression;
// what it is not is revenue, and the money columns say so on their own.
const campaignRows: EarningsCampaignRow[] = campaigns.map((c) => {
const s = campaignTotals.get(c.id) ?? EMPTY_TOTALS;
const s = campaignTotals.data.get(c.id) ?? EMPTY_TOTALS;
return {
id: c.id,
name: c.name,
Expand All @@ -169,7 +178,7 @@ export async function loadEarnings(
});

const slotRows: EarningsSlotRow[] = slots.map((sl) => {
const s = slotTotals.get(sl.id) ?? EMPTY_SLOT_TOTALS;
const s = slotTotals.data.get(sl.id) ?? EMPTY_SLOT_TOTALS;
return {
id: sl.id,
name: projectsById.get(sl.project_id)?.name ?? "Site",
Expand Down Expand Up @@ -199,11 +208,16 @@ export async function loadEarnings(
getCampaignDailySeries(supabase, campaignRows.map((c) => c.id), days),
getSlotDailySeries(supabase, slotRows.map((s) => s.id), days),
]);
const daily = mergeMoneySeries(campaignSeries, slotSeries, days);
const daily = mergeMoneySeries(campaignSeries.data, slotSeries.data, days);
const earnedTodayCents = daily.length ? daily[daily.length - 1].earnedCents : 0;

return {
rangeDays: days,
statsUnavailable:
campaignTotals.failed ||
slotTotals.failed ||
campaignSeries.failed ||
slotSeries.failed,
totals: {
spentCents,
earnedCents,
Expand All @@ -217,7 +231,7 @@ export async function loadEarnings(
pubImpressions: slotRows.reduce((a, s) => a + s.impressions, 0),
pubClicks: slotRows.reduce((a, s) => a + s.clicks, 0),
invalidClicks: slots.reduce(
(a, sl) => a + (slotTotals.get(sl.id)?.invalidClicks ?? 0),
(a, sl) => a + (slotTotals.data.get(sl.id)?.invalidClicks ?? 0),
0,
),
},
Expand Down
8 changes: 8 additions & 0 deletions lib/ads/earnings-report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,14 @@ export function buildEarningsReportHtml(input: {
<div class="sub">Account: ${esc(account)} · Period: ${esc(from)} → ${esc(to)} (${model.rangeDays} days) · Generated ${esc(
gen.toLocaleString(),
)}</div>
${
// A report that says "0 impressions" because a query was cancelled is
// worse than one that admits it could not read them: this document gets
// handed to accountants and teams, where a silent zero is taken as fact.
model.statsUnavailable
? `<div class="sub" style="color:#b45309">Delivery figures for this period could not be loaded and are shown as zero. Regenerate this report before relying on the impression and click columns.</div>`
: ""
}

<div class="tiles">
<div class="tile"><div class="k">Total earned</div><div class="v pos">${esc(dollars(t.earnedCents))}</div></div>
Expand Down
Loading
Loading