Campaign Pacing
Cumulative spend against an ideal pace line, projecting end-of-flight spend and whether a campaign is over- or under-pacing its budget.
Q2 2026 · Campaign Pacing
106%
of pace · $46k of $65k
- Actual
- Ideal
Spend is tracking 106% of plan in this flight, running 6% ahead.
| Date | Cumulative spend |
|---|---|
| May 20, 2026 | $2k |
| May 21, 2026 | $4k |
| May 22, 2026 | $7k |
| May 23, 2026 | $9k |
| May 24, 2026 | $11k |
| May 25, 2026 | $13k |
| May 26, 2026 | $14k |
| May 27, 2026 | $16k |
| May 28, 2026 | $18k |
| May 29, 2026 | $20k |
| May 30, 2026 | $22k |
| May 31, 2026 | $24k |
| Jun 1, 2026 | $27k |
| Jun 2, 2026 | $29k |
| Jun 3, 2026 | $32k |
| Jun 4, 2026 | $35k |
| Jun 5, 2026 | $38k |
| Jun 6, 2026 | $40k |
| Jun 7, 2026 | $43k |
| Jun 8, 2026 | $46k |
Acme analytics · synced from ad platforms hourly
Demo data is illustrative. Replace with your own typed data prop.
How it computes
Burn-pace decomposition: ideal linear spend vs cumulative actual vs constant run-rate projection to flight end.
ideal(d) = budget·d/totalDays; pace = cumToDate / idealToDate; projected = (cumToDate / elapsed) · totalDaysAssumptions
- Budget is meant to be spent uniformly across the flight.
- The current run-rate holds for the remainder (linear extrapolation).
- Flight endpoints are inclusive: totalDays = days(start, end) + 1.
Honest about
- Days with no record contribute 0 spend rather than being interpolated.
- The projected tail is explicitly a dashed extrapolation anchored at today's cumulative spend — not measured data.
- Degenerate flights (totalDays = 0) fall back to pace = 0 instead of dividing by zero.
Use it with your data
Data contract
Passed via the data prop — an object: validated at runtime, so a bad shape degrades to the error state rather than crashing.
| Field | Type | Required |
|---|---|---|
| flightStart | string | yes |
| flightEnd | string | yes |
| asOf | string | yes |
| budget | number | yes |
| daily | array | yes |
Static props
import { MarketingCampaignPacing } from "@/components/blocks/marketing-campaign-pacing/marketing-campaign-pacing";
<MarketingCampaignPacing data={myData} />Client fetch (SWR)
const { data, isLoading, error } = useSWR("/api/metric", fetcher);
// route isLoading/error through <ChartStates>, then:
<MarketingCampaignPacing data={data} />Server component (RSC)
const data = await fetch(url, {
next: { revalidate: 3600 },
}).then((r) => r.json());
<MarketingCampaignPacing data={data} />Map arbitrary rows
import { mapRows } from "@/lib/sextant/column-map";
const out = mapRows(rows, { /* contractKey: "yourColumn" */ }, dataSchema);
if (out.ok) <MarketingCampaignPacing data={out.data} />;Make it yours
Every customization below is an optional prop — omit them all and you get the defaults shown above. Recolor, reformat, restyle, or drop the card chrome without forking the component.
Recolor or rename a series (actual, ideal, projected)
<MarketingCampaignPacing
series={{ actual: { color: "var(--chart-3)", label: "…" } }}
/>Format numbers (currency / locale)
const eur = new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
notation: "compact",
});
<MarketingCampaignPacing valueFormatter={(n) => eur.format(n)} />Replace or hide the narrative
<MarketingCampaignPacing narrative="Your own one-liner." />
// …or hide it entirely:
<MarketingCampaignPacing narrative={false} />Restyle any region
<MarketingCampaignPacing
className="max-w-xl"
classNames={{ body: "bg-muted/20" }}
/>Drop the card chrome (headless plot)
import { MarketingCampaignPacingPlot } from "@/components/blocks/marketing-campaign-pacing/marketing-campaign-pacing";
// Just the chart body — bring your own card/layout:
<MarketingCampaignPacingPlot data={data} config={config} />