Spend and conversions
What every live campaign spent a day, as columns, against the conversions it bought on their own axis; reads spendAndConversions().
Preview
"use client"
import { formatCurrency, formatNumber } from "@/lib/format"
import { ChartCard } from "@/components/ui/chart-card"
import { ComposedChart } from "@/components/ui/composed-chart"
import { type DayPoint } from "../data"
// Spend is money and conversions are people, so they never share a scale:
// spend is the column, conversions the line on the right-hand axis.
const BARS = [{ key: "spend", label: "Spend", color: "chart-1" as const }]
const LINES = [{ key: "conversions", label: "Conversions", color: "chart-4" as const }]
const RIGHT_AXIS = ["conversions"]
const DAY_LABEL = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
timeZone: "UTC",
})
export type SpendConversionsChartProps = {
/** One point a day across the window, oldest first. */
points: DayPoint[]
/** The window written out, e.g. "Aug 25, 2026 – Sep 23, 2026 UTC". */
window: string
}
/** What the window spent, day by day, against the conversions it bought. */
export function SpendConversionsChart({ points, window }: SpendConversionsChartProps) {
const spend = points.reduce((total, point) => total + point.spend, 0)
const conversions = points.reduce((total, point) => total + point.conversions, 0)
return (
<ChartCard
data-widget="widget-crm-marketing-spend-conversions-chart"
title="Spend and conversions"
description={`Every campaign that was live, day by day — ${window}`}
height={360}
className="h-full"
footer={`${formatCurrency(spend, "USD", { maximumFractionDigits: 0 })} bought ${formatNumber(conversions, { maximumFractionDigits: 0 })} conversions over ${points.length} days.`}
>
<ComposedChart
data={points}
index="date"
series={[...BARS, ...LINES]}
bars={BARS}
lines={LINES}
rightAxisKeys={RIGHT_AXIS}
height={360}
showYAxis
indexFormatter={(value) => DAY_LABEL.format(new Date(`${value}T00:00:00Z`))}
valueFormatter={(value) => formatNumber(value, { maximumFractionDigits: 0 })}
/>
</ChartCard>
)
}Install
$
npx shadcn@latest add @vibra/widget-crm-marketing-spend-conversions-chartNeeds the @vibra registry in your components.json — set it up once.
Source
"use client"
import { formatCurrency, formatNumber } from "@/lib/format"
import { ChartCard } from "@/components/ui/chart-card"
import { ComposedChart } from "@/components/ui/composed-chart"
import { type DayPoint } from "../data"
// Spend is money and conversions are people, so they never share a scale:
// spend is the column, conversions the line on the right-hand axis.
const BARS = [{ key: "spend", label: "Spend", color: "chart-1" as const }]
const LINES = [{ key: "conversions", label: "Conversions", color: "chart-4" as const }]
const RIGHT_AXIS = ["conversions"]
const DAY_LABEL = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
timeZone: "UTC",
})
export type SpendConversionsChartProps = {
/** One point a day across the window, oldest first. */
points: DayPoint[]
/** The window written out, e.g. "Aug 25, 2026 – Sep 23, 2026 UTC". */
window: string
}
/** What the window spent, day by day, against the conversions it bought. */
export function SpendConversionsChart({ points, window }: SpendConversionsChartProps) {
const spend = points.reduce((total, point) => total + point.spend, 0)
const conversions = points.reduce((total, point) => total + point.conversions, 0)
return (
<ChartCard
data-widget="widget-crm-marketing-spend-conversions-chart"
title="Spend and conversions"
description={`Every campaign that was live, day by day — ${window}`}
height={360}
className="h-full"
footer={`${formatCurrency(spend, "USD", { maximumFractionDigits: 0 })} bought ${formatNumber(conversions, { maximumFractionDigits: 0 })} conversions over ${points.length} days.`}
>
<ComposedChart
data={points}
index="date"
series={[...BARS, ...LINES]}
bars={BARS}
lines={LINES}
rightAxisKeys={RIGHT_AXIS}
height={360}
showYAxis
indexFormatter={(value) => DAY_LABEL.format(new Date(`${value}T00:00:00Z`))}
valueFormatter={(value) => formatNumber(value, { maximumFractionDigits: 0 })}
/>
</ChartCard>
)
}/**
* What this page reads. Every campaign, its spend and its conversions come
* from `db.campaigns`, and a conversion is valued at the whole paying book in
* `db.customers` divided by every conversion that ever bought it — so CAC and
* ROAS are the data's own numbers, not invented ones.
*
* The daily series behind the chart has no entity of its own. It is derived,
* not seeded: a campaign's spend and conversions are spread across the days it
* was actually live, then shaped by a weekday rule (a weekend runs at 0.55 of
* a weekday) and rescaled so the window still totals what the campaigns spent.
* "Now" is `REFERENCE_DATE`; nothing here reads a clock.
*/
import { formatCurrency, formatNumber, getInitials } from "@/lib/format"
import {
db,
REFERENCE_DATE,
slugify,
type Campaign,
type Member,
} from "@/lib/sample-data"
export type { Campaign }
const DAY_MS = 86_400_000
/** The window every headline number and the chart are measured over. */
export const WINDOW_DAYS = 30
/** Midnight UTC on the last complete day before "now". */
const LAST_DAY =
Date.UTC(
REFERENCE_DATE.getUTCFullYear(),
REFERENCE_DATE.getUTCMonth(),
REFERENCE_DATE.getUTCDate()
) - DAY_MS
// A campaign with no end date is still running, so it counts up to today.
const endOf = (campaign: Campaign): number => (campaign.endedAt ?? REFERENCE_DATE).getTime()
// Marketing runs on the working week: a weekend day carries a little over half
// a weekday's spend. The series is rescaled afterwards, so this only moves
// budget between days — it never invents any.
const WEEKEND_FACTOR = 0.55
export type DayPoint = { date: string; spend: number; conversions: number }
/** The `WINDOW_DAYS` days ending `offset` windows back, oldest first. */
function windowDays(offset: number): number[] {
const end = LAST_DAY - offset * WINDOW_DAYS * DAY_MS
return Array.from({ length: WINDOW_DAYS }, (_, index) => end - (WINDOW_DAYS - 1 - index) * DAY_MS)
}
/**
* A window's daily spend and conversions: each campaign's totals divided by
* the days it ran, counted only on the days inside the window.
*/
function dailySeries(offset = 0): DayPoint[] {
const days = windowDays(offset)
const campaigns = db.campaigns.all()
const raw = days.map((day) => {
let spend = 0
let conversions = 0
for (const campaign of campaigns) {
const started = campaign.startedAt.getTime()
const ended = endOf(campaign)
if (day < started || day > ended) continue
const liveDays = Math.max(1, Math.round((ended - started) / DAY_MS))
spend += campaign.spendCents / 100 / liveDays
conversions += campaign.conversions / liveDays
}
return { spend, conversions }
})
const shaped = days.map((day, index) => {
const weekday = new Date(day).getUTCDay()
const factor = weekday === 0 || weekday === 6 ? WEEKEND_FACTOR : 1
return { spend: raw[index].spend * factor, conversions: raw[index].conversions * factor }
})
const total = (rows: { spend: number; conversions: number }[], key: "spend" | "conversions") =>
rows.reduce((sum, row) => sum + row[key], 0)
const spendScale = total(shaped, "spend") > 0 ? total(raw, "spend") / total(shaped, "spend") : 1
const conversionScale =
total(shaped, "conversions") > 0 ? total(raw, "conversions") / total(shaped, "conversions") : 1
return days.map((day, index) => ({
date: new Date(day).toISOString().slice(0, 10),
spend: Math.round(shaped[index].spend * spendScale),
conversions: Math.round(shaped[index].conversions * conversionScale),
}))
}
/** The daily spend and conversions inside the window, oldest first — read per call, like every row here. */
export function spendAndConversions(): DayPoint[] {
return dailySeries(0)
}
// What one conversion is worth, blended: the whole paying book in
// `db.customers` divided by every conversion in `db.campaigns` that ever
// bought it. Most conversions are a trial that never bills, which is why this
// lands two orders of magnitude below the mean MRR of an account that does.
function conversionValue(): number {
const bookCents = db.customers.all().reduce((sum, customer) => sum + customer.mrrCents, 0)
const conversions = db.campaigns.all().reduce((sum, campaign) => sum + campaign.conversions, 0)
return conversions === 0 ? 0 : bookCents / 100 / conversions
}
export type MarketingStat = {
key: string
label: string
value: string
delta: number
description: string
/** False where down is the win — cost per acquisition. */
positiveIsGood: boolean
}
const sum = (rows: DayPoint[], key: "spend" | "conversions"): number =>
rows.reduce((total, row) => total + row[key], 0)
const ratio = (current: number, previous: number): number =>
previous === 0 ? 0 : current / previous - 1
/** Spend, conversions, cost per acquisition and return on ad spend, each against the previous window. */
export function marketingStats(): MarketingStat[] {
const thisWindow = dailySeries(0)
const lastWindow = dailySeries(1)
const value = conversionValue()
const spend = sum(thisWindow, "spend")
const conversions = sum(thisWindow, "conversions")
const wasSpend = sum(lastWindow, "spend")
const wasConversions = sum(lastWindow, "conversions")
const cac = conversions === 0 ? 0 : spend / conversions
const wasCac = wasConversions === 0 ? 0 : wasSpend / wasConversions
const roas = spend === 0 ? 0 : (conversions * value) / spend
const wasRoas = wasSpend === 0 ? 0 : (wasConversions * value) / wasSpend
const against = `vs previous ${WINDOW_DAYS} days`
return [
{
key: "spend",
label: "Spend",
value: formatCurrency(spend, "USD", { maximumFractionDigits: 0 }),
delta: ratio(spend, wasSpend),
description: against,
positiveIsGood: true,
},
{
key: "conversions",
label: "Conversions",
value: formatNumber(conversions, { maximumFractionDigits: 0 }),
delta: ratio(conversions, wasConversions),
description: against,
positiveIsGood: true,
},
{
key: "cac",
label: "Cost per acquisition",
value: formatCurrency(cac, "USD", { maximumFractionDigits: 2 }),
delta: ratio(cac, wasCac),
description: against,
positiveIsGood: false,
},
{
key: "roas",
label: "Return on ad spend",
value: `${formatNumber(roas, { maximumFractionDigits: 2 })}x`,
delta: ratio(roas, wasRoas),
description: `${formatCurrency(value, "USD", { maximumFractionDigits: 2 })} a conversion`,
positiveIsGood: true,
},
]
}
// How a channel is written for a reader, and the order the mix is drawn in.
const CHANNEL_LABELS: Record<Campaign["channel"], string> = {
search: "Paid search",
social: "Paid social",
email: "Email",
referral: "Referral",
organic: "Organic",
}
/** How a campaign's channel is written on this page. */
export function channelLabel(channel: Campaign["channel"]): string {
return CHANNEL_LABELS[channel]
}
/** Lifetime spend by channel, largest first. */
export function channelMix(): { name: string; value: number }[] {
const totals = new Map<Campaign["channel"], number>()
for (const campaign of db.campaigns.all()) {
totals.set(campaign.channel, (totals.get(campaign.channel) ?? 0) + campaign.spendCents / 100)
}
return [...totals]
.map(([channel, value]) => ({ name: CHANNEL_LABELS[channel], value: Math.round(value) }))
.sort((a, b) => b.value - a.value)
}
export type CampaignRow = Campaign & {
/** Spend divided by conversions; null when the campaign has converted nobody. */
cpaCents: number | null
/** Conversions divided by clicks. */
conversionRate: number
}
const withRates = (campaign: Campaign): CampaignRow => ({
...campaign,
cpaCents: campaign.conversions === 0 ? null : campaign.spendCents / campaign.conversions,
conversionRate: campaign.clicks === 0 ? 0 : campaign.conversions / campaign.clicks,
})
/** The campaigns that spent the most, biggest budget first. */
export function topCampaigns(limit = 8): CampaignRow[] {
return db.campaigns
.all()
.sort((a, b) => b.spendCents - a.spendCents)
.slice(0, limit)
.map(withRates)
}
// The source and medium a channel is tagged with in a link — the vocabulary
// an analytics tool expects, not a naming scheme of our own. The source is the
// kind of place the link ran, never the company that ran it.
const UTM_TAGS: Record<Campaign["channel"], readonly [string, string]> = {
search: ["search", "cpc"],
social: ["social", "paid_social"],
email: ["newsletter", "email"],
referral: ["partners", "referral"],
organic: ["search", "organic"],
}
export type UtmRow = {
id: string
source: string
medium: string
campaign: string
sessions: number
conversions: number
conversionRate: number
}
/**
* The tagged links behind the campaigns, busiest first. Source, medium and
* campaign are the campaign row's own channel and name; sessions are its
* clicks.
*/
export function utmRows(limit = 8): UtmRow[] {
return db.campaigns
.all()
.sort((a, b) => b.clicks - a.clicks)
.slice(0, limit)
.map((campaign) => ({
id: campaign.id,
source: UTM_TAGS[campaign.channel][0],
medium: UTM_TAGS[campaign.channel][1],
campaign: slugify(campaign.name),
sessions: campaign.clicks,
conversions: campaign.conversions,
conversionRate: campaign.clicks === 0 ? 0 : campaign.conversions / campaign.clicks,
}))
}
/** The bell's contents: the newest notifications, unread first in the panel. */
export function shellNotifications() {
return db.notifications
.all()
.sort((a, b) => b.at.getTime() - a.at.getTime())
.slice(0, 6)
.map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}
function ownerRow(): Member {
return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}
/** The person looking at the page: whoever owns this workspace. */
export function currentUser() {
const owner = ownerRow()
return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}
// Fixed to UTC so the line reads the same wherever the page is rendered.
const WINDOW_LABEL = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeZone: "UTC" })
/** The window the page covers, written out. */
export function windowLabel(): string {
const first = new Date(LAST_DAY - (WINDOW_DAYS - 1) * DAY_MS)
const last = new Date(LAST_DAY)
return `${WINDOW_LABEL.format(first)} – ${WINDOW_LABEL.format(last)} UTC`
}Its page
On its page the card sits among the rest of the dashboard and shares its range and its data with them.
From the Marketing dashboard page