Spend by channel
A year of spend a month, one stack a month and one segment a channel, each campaign's spend spread over the days it ran; reads monthlySpend().
Preview
"use client"
import { formatCurrency } from "@/lib/format"
import { ChartCard } from "@/components/ui/chart-card"
import { StackedBarChart } from "@/components/ui/stacked-bar-chart"
import { type MonthSpend } from "../data"
import { CHANNEL_SERIES } from "./campaigns-vocabulary"
// Fixed to UTC so the axis reads the same wherever the page is rendered.
const MONTH = new Intl.DateTimeFormat("en-US", { month: "short", timeZone: "UTC" })
const SPAN = new Intl.DateTimeFormat("en-US", { month: "long", year: "numeric", timeZone: "UTC" })
const dollars = (value: number) => formatCurrency(value, "USD", { maximumFractionDigits: 0 })
/**
* Where the money went, month by month: one stack a month, one segment a
* channel. Each campaign's lifetime spend is spread over the days it ran, so
* a month holds only what was live in it.
*/
export function SpendChart({ months }: { months: MonthSpend[] }) {
const first = new Date(`${months[0].month}-01T00:00:00Z`)
const last = new Date(`${months[months.length - 1].month}-01T00:00:00Z`)
return (
<ChartCard
data-widget="widget-crm-campaigns-spend-chart"
title="Spend by channel"
description={`${SPAN.format(first)} to ${SPAN.format(last)}, by month`}
height={260}
>
<StackedBarChart
data={months}
index="month"
series={CHANNEL_SERIES}
height={260}
valueFormatter={dollars}
indexFormatter={(value) => MONTH.format(new Date(`${value}-01T00:00:00Z`))}
/>
</ChartCard>
)
}Install
$
npx shadcn@latest add @vibra/widget-crm-campaigns-spend-chartNeeds the @vibra registry in your components.json — set it up once.
Source
"use client"
import { formatCurrency } from "@/lib/format"
import { ChartCard } from "@/components/ui/chart-card"
import { StackedBarChart } from "@/components/ui/stacked-bar-chart"
import { type MonthSpend } from "../data"
import { CHANNEL_SERIES } from "./campaigns-vocabulary"
// Fixed to UTC so the axis reads the same wherever the page is rendered.
const MONTH = new Intl.DateTimeFormat("en-US", { month: "short", timeZone: "UTC" })
const SPAN = new Intl.DateTimeFormat("en-US", { month: "long", year: "numeric", timeZone: "UTC" })
const dollars = (value: number) => formatCurrency(value, "USD", { maximumFractionDigits: 0 })
/**
* Where the money went, month by month: one stack a month, one segment a
* channel. Each campaign's lifetime spend is spread over the days it ran, so
* a month holds only what was live in it.
*/
export function SpendChart({ months }: { months: MonthSpend[] }) {
const first = new Date(`${months[0].month}-01T00:00:00Z`)
const last = new Date(`${months[months.length - 1].month}-01T00:00:00Z`)
return (
<ChartCard
data-widget="widget-crm-campaigns-spend-chart"
title="Spend by channel"
description={`${SPAN.format(first)} to ${SPAN.format(last)}, by month`}
height={260}
>
<StackedBarChart
data={months}
index="month"
series={CHANNEL_SERIES}
height={260}
valueFormatter={dollars}
indexFormatter={(value) => MONTH.format(new Date(`${value}-01T00:00:00Z`))}
/>
</ChartCard>
)
}/**
* What the campaigns page reads: every `db.campaigns` row, and the monthly
* spend behind the chart. A campaign records one lifetime spend, not a
* ledger, so the months are derived rather than seeded: each campaign's spend
* is spread evenly over the days it actually ran — from its start to its end,
* or to "now" while it is still running — and summed by month and channel.
* Nothing is invented and nothing is lost inside the window: the months add
* up to what the campaigns spent in them. "Now" is `REFERENCE_DATE`.
*/
import { formatDate, getInitials } from "@/lib/format"
import { db, REFERENCE_DATE, type Campaign, type Member } from "@/lib/sample-data"
const DAY_MS = 86_400_000
/** How many months the chart covers, ending with the month "now" falls in. */
export const MONTHS = 12
/** The channels in the order the chart stacks them — the islands' vocabulary lists them the same way. */
const CHANNELS: Campaign["channel"][] = ["search", "social", "email", "referral", "organic"]
/** One row of the campaigns table. */
export type CampaignRow = {
id: string
name: string
channel: Campaign["channel"]
status: Campaign["status"]
/** Lifetime spend in whole dollars. */
spend: number
clicks: number
conversions: number
startedAt: Date
endedAt?: Date
}
/** Every campaign, biggest spend first; the table filters, sorts and pages them itself. */
export function campaignRows(): CampaignRow[] {
return db.campaigns
.all()
.sort((a, b) => b.spendCents - a.spendCents)
.map((campaign) => ({
id: campaign.id,
name: campaign.name,
channel: campaign.channel,
status: campaign.status,
spend: campaign.spendCents / 100,
clicks: campaign.clicks,
conversions: campaign.conversions,
startedAt: campaign.startedAt,
...(campaign.endedAt ? { endedAt: campaign.endedAt } : {}),
}))
}
/** One month of the chart: "2026-09", and the dollars each channel spent in it. */
export type MonthSpend = { month: string } & Record<Campaign["channel"], number>
/** The `MONTHS` months ending with the one "now" falls in, oldest first, as "YYYY-MM". */
function monthKeys(): string[] {
const current = Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth(), 1)
return Array.from({ length: MONTHS }, (_, index) => {
const start = new Date(current)
start.setUTCMonth(start.getUTCMonth() - (MONTHS - 1 - index))
return start.toISOString().slice(0, 7)
})
}
/** Spend by month and channel, each campaign spread evenly over the days it ran. */
export function monthlySpend(): MonthSpend[] {
const keys = monthKeys()
const totals = new Map(keys.map((month) => [month, Object.fromEntries(CHANNELS.map((channel) => [channel, 0]))]))
for (const campaign of db.campaigns.all()) {
const from = campaign.startedAt.getTime()
const to = (campaign.endedAt ?? REFERENCE_DATE).getTime()
const days = Math.max(1, Math.ceil((to - from) / DAY_MS))
const daily = campaign.spendCents / 100 / days
for (let day = 0; day < days; day++) {
const month = totals.get(new Date(from + day * DAY_MS).toISOString().slice(0, 7))
if (month) month[campaign.channel] += daily
}
}
return keys.map((month) => {
const spend = totals.get(month)!
return {
month,
...(Object.fromEntries(CHANNELS.map((channel) => [channel, Math.round(spend[channel])])) as Record<
Campaign["channel"],
number
>),
}
})
}
/** The freshness line under the title, measured against REFERENCE_DATE. */
export function lastUpdated(): string {
return `Synced ${formatDate(REFERENCE_DATE, "medium", { timeZone: "UTC" })}`
}
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 }
}
/** 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 }))
}import { type ChartSeries } from "@/components/ui/chart-core"
import { type StatusVariant } from "@/components/ui/status-badge"
/**
* The words and colours this page puts on a channel and a status. Vocabulary,
* not data: it lives beside the islands that print it, because `data.ts`
* reads `db` and must never reach the browser. The marketing page writes the
* channels the same way.
*/
export const CHANNEL_LABELS: Record<string, string> = {
search: "Paid search",
social: "Paid social",
email: "Email",
referral: "Referral",
organic: "Organic",
}
/**
* The chart's series, in the order they stack. Paid search, the biggest line
* of spend, takes the first slot — the grey the palette opens on — and the
* hues start at the second series, as they do on every chart in the kit.
*/
export const CHANNEL_SERIES: ChartSeries[] = [
{ key: "search", label: "Paid search", color: "chart-1" },
{ key: "social", label: "Paid social", color: "chart-2" },
{ key: "email", label: "Email", color: "chart-3" },
{ key: "referral", label: "Referral", color: "chart-4" },
{ key: "organic", label: "Organic", color: "chart-5" },
]
export const STATUS_LABELS: Record<string, string> = { active: "Active", paused: "Paused", completed: "Completed" }
export const STATUS_MAP: Record<string, StatusVariant> = { active: "success", paused: "warning", completed: "neutral" }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 Campaigns page