Units sold
Twelve weeks of units, a column a week, with the total and the weekly average above them; reads productRecord(id).
Preview
"use client"
import { formatNumber } from "@/lib/format"
import { BarChart } from "@/components/ui/bar-chart"
import { ChartCard } from "@/components/ui/chart-card"
import type { ChartSeries } from "@/components/ui/chart-core"
import { type WeekPoint } from "../data"
// Fixed to UTC so the axis reads the same wherever the page is rendered.
const WEEK_LABEL = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
const SERIES: ChartSeries[] = [{ key: "units", label: "Units", color: "chart-1" }]
/**
* Twelve weeks of units, one grey column a week — a single series, so no hue.
* The caption carries the weekly average, which is what turns a row of
* columns into "this week was a good one" without a rule drawn through them.
*/
export function UnitsChart({ weeks }: { weeks: WeekPoint[] }) {
const total = weeks.reduce((sum, week) => sum + week.units, 0)
const average = weeks.length === 0 ? 0 : total / weeks.length
return (
<ChartCard
data-widget="widget-ecommerce-product-units-chart"
title={`Units sold, last ${weeks.length} weeks`}
description={`${formatNumber(total)} units, ${formatNumber(average, { maximumFractionDigits: 1 })} a week on average`}
empty={total === 0}
emptyMessage="Nothing sold in these weeks."
height={288}
className="h-full"
>
<BarChart
data={weeks}
index="week"
series={SERIES}
height={288}
valueFormatter={(value) => formatNumber(value, { maximumFractionDigits: 1 })}
indexFormatter={(value) => WEEK_LABEL.format(new Date(`${value}T00:00:00Z`))}
/>
</ChartCard>
)
}Install
$
npx shadcn@latest add @vibra/widget-ecommerce-product-units-chartNeeds the @vibra registry in your components.json — set it up once.
Source
"use client"
import { formatNumber } from "@/lib/format"
import { BarChart } from "@/components/ui/bar-chart"
import { ChartCard } from "@/components/ui/chart-card"
import type { ChartSeries } from "@/components/ui/chart-core"
import { type WeekPoint } from "../data"
// Fixed to UTC so the axis reads the same wherever the page is rendered.
const WEEK_LABEL = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
const SERIES: ChartSeries[] = [{ key: "units", label: "Units", color: "chart-1" }]
/**
* Twelve weeks of units, one grey column a week — a single series, so no hue.
* The caption carries the weekly average, which is what turns a row of
* columns into "this week was a good one" without a rule drawn through them.
*/
export function UnitsChart({ weeks }: { weeks: WeekPoint[] }) {
const total = weeks.reduce((sum, week) => sum + week.units, 0)
const average = weeks.length === 0 ? 0 : total / weeks.length
return (
<ChartCard
data-widget="widget-ecommerce-product-units-chart"
title={`Units sold, last ${weeks.length} weeks`}
description={`${formatNumber(total)} units, ${formatNumber(average, { maximumFractionDigits: 1 })} a week on average`}
empty={total === 0}
emptyMessage="Nothing sold in these weeks."
height={288}
className="h-full"
>
<BarChart
data={weeks}
index="week"
series={SERIES}
height={288}
valueFormatter={(value) => formatNumber(value, { maximumFractionDigits: 1 })}
indexFormatter={(value) => WEEK_LABEL.format(new Date(`${value}T00:00:00Z`))}
/>
</ChartCard>
)
}/**
* What one product's page reads. The product is a `db.products` row; what it
* sold is read off the lines of `db.orders`, because the catalogue row keeps
* no sales of its own, by the dashboard's own rule for a sale
* (`salesBetween`): paid and fulfilled orders, so a cancelled cart and a
* refund are not sales, while every order that holds the product is listed
* whatever became of it. "Now" is `REFERENCE_DATE`; nothing here reads a clock.
*/
import { salesBetween } from "@/lib/dashboards/ecommerce/vocabulary"
import { formatDate, getInitials } from "@/lib/format"
import { db, REFERENCE_DATE, type Member, type Order, type Product } from "@/lib/sample-data"
const DAY_MS = 86_400_000
const WEEK_MS = 7 * DAY_MS
/** How many weeks the chart and the headline sales figures cover. */
export const WEEKS = 12
const SPAN_MS = WEEKS * WEEK_MS
/**
* The product the page falls back to when it is rendered with no route param —
* which is what the docs preview does: the active product that sold the most
* units in the last twelve weeks, because a product page is worth looking at
* when its chart has something on it. Read per request, like every row here.
*/
export function fallbackId(): string {
const now = REFERENCE_DATE.getTime()
const sales = salesBetween(db.orders.all(), now - SPAN_MS, now)
const products = db.products.all()
const active = products.filter((product) => product.status === "active")
let best = active[0] ?? products[0]
let most = -1
for (const product of active) {
const units = sales.get(product.id)?.units ?? 0
if (units > most) {
best = product
most = units
}
}
return best.id
}
/** Every product id, for `generateStaticParams`. */
export function productIds(): string[] {
return db.products.all().map((product) => product.id)
}
/** One week of the chart: its first day, and the units sold in it. */
export type WeekPoint = { week: string; units: number }
/** One order that holds the product, with the line that holds it. */
export type HoldingOrder = {
id: string
number: string
status: Order["status"]
placedAt: Date
customer: { name: string; company: string; avatarUrl?: string }
qty: number
/** What the line charged, in whole dollars — the price at the time, not today's. */
lineTotal: number
}
/** The product and everything the page says about it, gathered in one read. */
export type ProductRecord = {
product: Product
/** The twelve weeks, oldest first, the last ending at "now". */
weeks: WeekPoint[]
sales: { units: number; revenue: number }
/** The same figures over the twelve weeks before, for the pills. */
previous: { units: number; revenue: number }
orders: HoldingOrder[]
}
/** Midnight UTC on the day `ms` falls in. */
const dayOf = (ms: number) => {
const date = new Date(ms)
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
}
type WeekSales = { from: number; units: number; cents: number }
/**
* One product's sales week by week, over the twelve weeks ending `offset`
* spans before "now", oldest first. The chart draws these weeks and the
* headline figures are their sums, so the bars always add up to the tile.
*/
function weeklySales(productId: string, offset: number): WeekSales[] {
const end = REFERENCE_DATE.getTime() - offset * SPAN_MS
const orders = db.orders.all()
return Array.from({ length: WEEKS }, (_, index) => {
const from = end - (WEEKS - index) * WEEK_MS
const sale = salesBetween(orders, from, from + WEEK_MS).get(productId)
return { from, units: sale?.units ?? 0, cents: sale?.cents ?? 0 }
})
}
/** A run of weeks summed into the headline's units and revenue. */
function summed(weeks: WeekSales[]): { units: number; revenue: number } {
return {
units: weeks.reduce((sum, week) => sum + week.units, 0),
revenue: weeks.reduce((sum, week) => sum + week.cents, 0) / 100,
}
}
/**
* The whole record, or undefined when the id names no product. The orders are
* every one that holds it, newest first, whatever became of them — the list is
* the product's history, and a refund is part of that.
*/
export async function productRecord(id: string): Promise<ProductRecord | undefined> {
const product = await db.products.get(id)
if (!product) return undefined
const customers = new Map(db.customers.all().map((customer) => [customer.id, customer]))
const orders = db.orders
.all()
.filter((order) => order.items.some((item) => item.productId === id))
.sort((a, b) => b.placedAt.getTime() - a.placedAt.getTime())
.map((order): HoldingOrder => {
const line = order.items.find((item) => item.productId === id)!
const customer = customers.get(order.customerId)
return {
id: order.id,
number: order.number,
status: order.status,
placedAt: order.placedAt,
customer: {
// A sale the till rang up for nobody in particular has no customer.
name: customer?.name ?? (order.customerId ? "Unknown customer" : "Walk-in"),
company: customer?.company ?? "—",
avatarUrl: customer?.avatarUrl,
},
qty: line.qty,
lineTotal: (line.qty * line.unitCents) / 100,
}
})
const weeks = weeklySales(id, 0)
return {
product,
weeks: weeks.map((week) => ({
week: new Date(dayOf(week.from + DAY_MS)).toISOString().slice(0, 10),
units: week.units,
})),
sales: summed(weeks),
previous: summed(weeklySales(id, 1)),
orders,
}
}
/** 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 }))
}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 Product record page