No sites yet — a site is a CrawlProof project.{" "}
diff --git a/components/ads/stats-unavailable.tsx b/components/ads/stats-unavailable.tsx
new file mode 100644
index 00000000..a8c1688f
--- /dev/null
+++ b/components/ads/stats-unavailable.tsx
@@ -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 (
+
+
+ Couldn't load {what}.
+ {" "}
+ The figures below are not zero — they are missing. This is usually a query
+ that took too long; reloading often fixes it.
+
+ );
+}
diff --git a/lib/ads/earnings-data.ts b/lib/ads/earnings-data.ts
index ebea2502..486d3370 100644
--- a/lib/ads/earnings-data.ts
+++ b/lib/ads/earnings-data.ts
@@ -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.
@@ -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,
@@ -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",
@@ -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,
@@ -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,
),
},
diff --git a/lib/ads/earnings-report.ts b/lib/ads/earnings-report.ts
index 19736897..f4e5598e 100644
--- a/lib/ads/earnings-report.ts
+++ b/lib/ads/earnings-report.ts
@@ -127,6 +127,14 @@ export function buildEarningsReportHtml(input: {
Account: ${esc(account)} · Period: ${esc(from)} → ${esc(to)} (${model.rangeDays} days) · Generated ${esc(
gen.toLocaleString(),
)}
+ ${
+ // 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
+ ? `
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.
`
+ : ""
+ }
Total earned
${esc(dollars(t.earnedCents))}
diff --git a/lib/ads/series.ts b/lib/ads/series.ts
index 38fb3380..30a460ef 100644
--- a/lib/ads/series.ts
+++ b/lib/ads/series.ts
@@ -27,6 +27,36 @@ export const EMPTY_TOTALS: RangeTotals = {
spentCents: 0,
};
+/**
+ * A loader's result together with whether the query behind it actually ran.
+ *
+ * Every loader below zero-fills rather than throwing, so one failing panel
+ * cannot take the whole page down with it. That part is deliberate and stays.
+ * What it cost is a way to tell the two apart: a cancelled query and a
+ * genuinely quiet range produced byte-identical output, so the dashboard could
+ * report four zeros over a network delivering six figures and say nothing was
+ * wrong. It has now done exactly that three times, for three unrelated reasons
+ * — the paid-only measure (#199), the same bug on two more surfaces (#225), and
+ * an RPC being cancelled by statement_timeout. Each time the zeros were read as
+ * a dead pipeline and diagnosed from scratch.
+ *
+ * `failed` is how a caller tells "we could not load this" from "this is 0".
+ */
+export type Loaded
= { data: T; failed: boolean };
+
+/**
+ * Record an RPC failure and report whether there was one.
+ *
+ * Logs, because the error was previously discarded at the point of failure:
+ * the only surviving evidence that a query had been cancelled was in Postgres'
+ * own logs, which is a long way to go to explain a tile reading 0.
+ */
+function rpcFailed(name: string, error: { message?: string } | null): boolean {
+ if (!error) return false;
+ console.error(`[ads] RPC ${name} failed: ${error.message ?? "unknown error"}`);
+ return true;
+}
+
type AccountSeriesRow = {
bucket: string;
impressions: number | string;
@@ -49,13 +79,14 @@ export async function getAccountSeries(
supabase: SupabaseClient,
range: RangeDef,
now: Date = new Date(),
-): Promise {
+): Promise> {
const { data, error } = await supabase.rpc("ad_account_series", {
p_since: rangeSince(range, now),
p_bucket_seconds: range.bucketSeconds,
});
- const rows = error ? [] : ((data as AccountSeriesRow[]) ?? []);
+ const failed = rpcFailed("ad_account_series", error);
+ const rows = failed ? [] : ((data as AccountSeriesRow[]) ?? []);
// "All time" has no fixed start, so the axis runs from the oldest bucket that
// actually has data rather than from a window offset.
@@ -86,7 +117,7 @@ export async function getAccountSeries(
point.spentCents += Number(row.spent_cents) || 0;
}
- return [...byBucket.values()].sort((a, b) => a.t - b.t);
+ return { data: [...byBucket.values()].sort((a, b) => a.t - b.t), failed };
}
function allTimeAxis(rows: AccountSeriesRow[], range: RangeDef, now: Date): number[] {
@@ -168,7 +199,7 @@ export async function getCampaignRangeTotals(
supabase: SupabaseClient,
range: RangeDef,
now: Date = new Date(),
-): Promise