Headline numbers
Visitors, signups, signup rate and median session over the range, each with its change and a sparkline, as tiles that pick what the traffic chart plots; reads overviewStatsByRange().
Preview
"use client"
import { MetricValue } from "@/components/ui/metric-value"
import { Sparkline } from "@/components/ui/sparkline"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
import { type MetricKey, type OverviewStat, type RangeKey } from "../data"
import { useOverviewActions, useOverviewLinked, useOverviewView } from "./overview-range"
/**
* The four headline numbers, and the row that picks what the chart under them
* plots.
*
* The range is client state, so this is an island — but the numbers themselves
* arrive computed, each with the sampled rows behind it, from the server
* component above. On the page, pressing a tile re-binds the chart rather than
* opening a second view. The tiles are toggle buttons in a group, not tabs:
* each one carries its own Explain button and a sparkline, and a tablist may
* hold nothing but its tabs. The chart below is a region named by its title,
* which follows the pressed tile.
*/
export function OverviewStats({ stats }: { stats: Record<RangeKey, OverviewStat[]> }) {
const { range, metric } = useOverviewView()
const { setMetric } = useOverviewActions()
const linked = useOverviewLinked()
return (
<StatCardGroup
data-widget="widget-saas-overview-overview-stats"
columns={4}
selectable
value={metric}
onValueChange={(value) => setMetric(value as MetricKey)}
aria-label={linked ? "Measure plotted below" : "Headline numbers"}
>
{stats[range].map((stat) => (
<StatCard
key={stat.key}
id={stat.key}
label={stat.label}
value={
<MetricValue
metric={stat.metric}
previous={stat.previous}
compareLabel={stat.compareLabel}
// Names this card's explain button, so the four of them read as
// four different buttons rather than four copies of one.
label={stat.label}
provenance={stat.provenance}
/>
}
footer={
<Sparkline
data={stat.spark}
type={stat.sparkType}
height={32}
showLast
// One point a day, so the series is its own count of days.
aria-label={`${stat.label}, last ${stat.spark.length} days`}
/>
}
/>
))}
</StatCardGroup>
)
}Install
$
npx shadcn@latest add @vibra/widget-saas-overview-overview-statsNeeds the @vibra registry in your components.json — set it up once.
Source
"use client"
import { MetricValue } from "@/components/ui/metric-value"
import { Sparkline } from "@/components/ui/sparkline"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
import { type MetricKey, type OverviewStat, type RangeKey } from "../data"
import { useOverviewActions, useOverviewLinked, useOverviewView } from "./overview-range"
/**
* The four headline numbers, and the row that picks what the chart under them
* plots.
*
* The range is client state, so this is an island — but the numbers themselves
* arrive computed, each with the sampled rows behind it, from the server
* component above. On the page, pressing a tile re-binds the chart rather than
* opening a second view. The tiles are toggle buttons in a group, not tabs:
* each one carries its own Explain button and a sparkline, and a tablist may
* hold nothing but its tabs. The chart below is a region named by its title,
* which follows the pressed tile.
*/
export function OverviewStats({ stats }: { stats: Record<RangeKey, OverviewStat[]> }) {
const { range, metric } = useOverviewView()
const { setMetric } = useOverviewActions()
const linked = useOverviewLinked()
return (
<StatCardGroup
data-widget="widget-saas-overview-overview-stats"
columns={4}
selectable
value={metric}
onValueChange={(value) => setMetric(value as MetricKey)}
aria-label={linked ? "Measure plotted below" : "Headline numbers"}
>
{stats[range].map((stat) => (
<StatCard
key={stat.key}
id={stat.key}
label={stat.label}
value={
<MetricValue
metric={stat.metric}
previous={stat.previous}
compareLabel={stat.compareLabel}
// Names this card's explain button, so the four of them read as
// four different buttons rather than four copies of one.
label={stat.label}
provenance={stat.provenance}
/>
}
footer={
<Sparkline
data={stat.spark}
type={stat.sparkType}
height={32}
showLast
// One point a day, so the series is its own count of days.
aria-label={`${stat.label}, last ${stat.spark.length} days`}
/>
}
/>
))}
</StatCardGroup>
)
}/**
* What this page reads. Rows come from `db`, so swapping a repository for a
* real store is the whole migration; the daily series behind the charts have
* no entity of their own, so they are generated once from `seeded("dashboard-01")`
* — deterministic, measured against `REFERENCE_DATE`, never a clock.
*/
import { getInitials } from "@/lib/format"
import {
previousPeriod,
traced,
trailingPeriod,
type Metric,
type Provenance,
} from "@/lib/metric"
import {
db,
REFERENCE_DATE,
seeded,
type Customer,
type Member,
} from "@/lib/sample-data"
export type { Customer }
export type RangeKey = "7d" | "30d" | "90d"
const RANGE_DAYS: Record<RangeKey, number> = { "7d": 7, "30d": 30, "90d": 90 }
/** How many days a range covers, for copy like "vs previous 30 days". */
export function rangeDays(range: RangeKey): number {
return RANGE_DAYS[range]
}
const DAY_MS = 86_400_000
// Twice the longest range, so every range can be compared with the one before it.
const SERIES_DAYS = 180
type Day = { date: string; visitors: number; signups: number; sessionSeconds: number }
/** 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
const SERIES_START = LAST_DAY - (SERIES_DAYS - 1) * DAY_MS
// Two days near the end of the window when a launch post ran, so the chart has
// something to explain rather than only a trend.
const SPIKE_FROM = SERIES_DAYS - 26
/**
* Half a year of daily traffic: weekends run a little over half a weekday, the
* whole window climbs, and the launch post shows up as a two-day spike.
*/
function generateDays(): Day[] {
const rand = seeded("dashboard-01")
return Array.from({ length: SERIES_DAYS }, (_, index) => {
const at = new Date(SERIES_START + index * DAY_MS)
const weekend = at.getUTCDay() === 0 || at.getUTCDay() === 6
const growth = 1 + (index / SERIES_DAYS) * 0.7
const spike = index >= SPIKE_FROM && index < SPIKE_FROM + 2 ? 1.42 : 1
const base = (weekend ? 520 : 1_020) * growth * spike
const visitors = Math.round(base * (0.88 + rand() * 0.24))
// Signups track visitors at a rate that drifts a little day to day.
const rate = 0.032 + rand() * 0.009
const sessionSeconds = Math.round((196 + (index / SERIES_DAYS) * 62) * (0.94 + rand() * 0.12))
return {
date: at.toISOString().slice(0, 10),
visitors,
signups: Math.max(1, Math.round(visitors * rate)),
sessionSeconds,
}
})
}
const DAYS = generateDays()
/** The `days` days ending `offset` spans back: `span(30, 1)` is the previous 30 days. */
function span(days: number, offset = 0): Day[] {
const end = DAYS.length - offset * days
return DAYS.slice(Math.max(0, end - days), end)
}
const sum = (rows: readonly Day[], key: "visitors" | "signups" | "sessionSeconds"): number =>
rows.reduce((total, row) => total + row[key], 0)
// The middle session, not the average one: a handful of very long sessions drags
// a mean somewhere no reader ever sat. Even-length windows take the midpoint of
// the two middles, which is what "median" means for an even count.
const median = (rows: readonly Day[], key: "sessionSeconds"): number => {
if (rows.length === 0) return 0
const sorted = rows.map((row) => row[key]).sort((a, b) => a - b)
const middle = Math.floor(sorted.length / 2)
return sorted.length % 2 === 1 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2
}
/** The four measures the overview plots, each keyed as its stat card is. */
export type MetricKey = "visitors" | "signups" | "rate" | "session"
/**
* One day, in both periods: the four measures as they were, and the same four
* over the window immediately before — aligned by position, so the compare
* ghost lines up day for day with the series it sits behind.
*/
export type TrafficPoint = Record<MetricKey | `${MetricKey}Before`, number> & { date: string }
const measures = (day: Day) => ({
visitors: day.visitors,
signups: day.signups,
// A fraction, not a percentage: the chart's own formatter prints the sign.
rate: day.signups / day.visitors,
session: day.sessionSeconds,
})
/** The daily measures inside a range, oldest first, each beside the period before it. */
export function trafficPoints(range: RangeKey): TrafficPoint[] {
const days = RANGE_DAYS[range]
const now = span(days)
const before = span(days, 1)
return now.map((day, index) => {
const previous = before[index] ?? day
const was = measures(previous)
return {
date: day.date,
...measures(day),
visitorsBefore: was.visitors,
signupsBefore: was.signups,
rateBefore: was.rate,
sessionBefore: was.session,
}
})
}
/**
* The day the launch post ran, or undefined when it falls outside the range —
* the peak of the two-day spike the generator writes in, read back off the rows
* rather than written out a second time as a literal.
*/
export function launchDay(range: RangeKey): string | undefined {
const window = span(RANGE_DAYS[range])
const spike = DAYS.slice(SPIKE_FROM, SPIKE_FROM + 2)
const peak = spike.reduce((most, day) => (day.visitors > most.visitors ? day : most), spike[0])
return window.some((day) => day.date === peak.date) ? peak.date : undefined
}
export type OverviewStat = {
key: string
label: string
/** The number itself, carrying the kind it is read in. */
metric: Metric
/** The same measure over the range immediately before this one. */
previous: number
/** What the change is measured against, taken from the period itself. */
compareLabel: string
/** Where the number came from — the sampled rows and the formula, never the whole window. */
provenance: Provenance
spark: number[]
sparkType: "area" | "bar" | "line"
}
/**
* The four headline numbers for a range, each traced back to the days it was
* computed from. `traced` runs the sum over every day in the window and keeps
* only the first few rows, so a card can show its work without the page
* handing a quarter of daily traffic to the browser. The rows go in newest
* first, which is the end of the window a reader checks.
*/
export function overviewStats(range: RangeKey): OverviewStat[] {
const days = RANGE_DAYS[range]
const now = span(days)
const before = span(days, 1)
const recent = [...now].reverse()
// REFERENCE_DATE is this page's "now"; the metric lib keeps none of its own.
const compareLabel = `vs ${previousPeriod(trailingPeriod(days, REFERENCE_DATE)).label.toLowerCase()}`
const visitors = traced("daily traffic", recent, "sum(visitors)", (rows) => sum(rows, "visitors"), {
columns: ["date", "visitors"],
})
const signups = traced("daily traffic", recent, "sum(signups)", (rows) => sum(rows, "signups"), {
columns: ["date", "signups"],
})
const rate = traced(
"daily traffic",
recent,
"sum(signups) ÷ sum(visitors)",
(rows) => sum(rows, "signups") / sum(rows, "visitors"),
{ columns: ["date", "visitors", "signups"] }
)
const session = traced(
"daily sessions",
recent,
"median(sessionSeconds)",
(rows) => median(rows, "sessionSeconds"),
{ columns: ["date", "sessionSeconds"] }
)
const wasVisitors = sum(before, "visitors")
const wasSignups = sum(before, "signups")
return [
{
key: "visitors",
label: "Visitors",
metric: { kind: "count", value: visitors.value },
previous: wasVisitors,
compareLabel,
provenance: visitors.provenance,
spark: now.map((day) => day.visitors),
sparkType: "area",
},
{
key: "signups",
label: "Signups",
metric: { kind: "count", value: signups.value },
previous: wasSignups,
compareLabel,
provenance: signups.provenance,
spark: now.map((day) => day.signups),
sparkType: "bar",
},
{
key: "rate",
label: "Signup rate",
metric: { kind: "percent", value: rate.value, precision: 2 },
previous: wasSignups / wasVisitors,
compareLabel,
provenance: rate.provenance,
spark: now.map((day) => Math.round((day.signups / day.visitors) * 1000) / 10),
sparkType: "line",
},
{
key: "session",
label: "Median session",
metric: { kind: "duration", value: session.value, unit: "s" },
previous: median(before, "sessionSeconds"),
compareLabel,
provenance: session.provenance,
spark: now.map((day) => day.sessionSeconds),
sparkType: "line",
},
]
}
/** One reading for each range the toolbar offers. */
export type ByRange<T> = Record<RangeKey, T>
const byRange = <T,>(read: (range: RangeKey) => T): ByRange<T> => ({
"7d": read("7d"),
"30d": read("30d"),
"90d": read("90d"),
})
/**
* Every range the toolbar offers, computed here rather than in the island that
* renders them: the range is client state, the numbers are not.
*/
export function overviewStatsByRange(): ByRange<OverviewStat[]> {
return byRange(overviewStats)
}
/** What the traffic chart draws for one range: its days, and the launch post when it falls inside. */
export type TrafficView = { days: number; points: TrafficPoint[]; launch?: string }
/**
* The chart's rows for every range, handed to the island as a prop — the same
* reason as the tiles': an island that imported this module would take `db`,
* and every row behind it, into the browser.
*/
export function trafficByRange(): ByRange<TrafficView> {
return byRange((range) => ({ days: rangeDays(range), points: trafficPoints(range), launch: launchDay(range) }))
}
/** Where one range's visitors came from. */
export type SourcesView = { days: number; sources: { name: string; value: number }[] }
export function trafficSourcesByRange(): ByRange<SourcesView> {
return byRange((range) => ({ days: rangeDays(range), sources: trafficSources(range) }))
}
/** One range's most-read pages, and how many pages saw any traffic in it. */
export type PagesView = { days: number; pages: { label: string; value: number }[]; withTraffic: number }
export function topPagesByRange(): ByRange<PagesView> {
return byRange((range) => ({ days: rangeDays(range), pages: topPages(range), withTraffic: pagesWithTraffic(range) }))
}
// Where visitors arrive from, largest first. The names are the block's own
// vocabulary; the split comes off the same generator as the traffic.
const SOURCE_NAMES = ["Organic search", "Direct", "Referral", "Paid social", "Email"]
const SOURCE_WEIGHTS = shares("dashboard-01-sources", SOURCE_NAMES.length, 0.42)
/** `count` shares of 1, each one `decay` times the share before it, plus a little noise. */
function shares(name: string, count: number, decay: number): number[] {
const rand = seeded(name)
const raw = Array.from({ length: count }, (_, index) => decay ** index * (0.9 + rand() * 0.2))
const total = raw.reduce((sum, value) => sum + value, 0)
return raw.map((value) => value / total)
}
/** Where a range's visitors came from, largest share first. */
export function trafficSources(range: RangeKey): { name: string; value: number }[] {
const visitors = sum(span(RANGE_DAYS[range]), "visitors")
return SOURCE_NAMES.map((name, index) => ({
name,
value: Math.round(visitors * SOURCE_WEIGHTS[index]),
}))
}
const PAGE_PATHS = [
"/",
"/pricing",
"/docs/quickstart",
"/changelog",
"/blog/observability-budgets",
"/docs/api/events",
]
// Each page in the list takes this share of the one above it, and the tail
// below the list keeps following the same curve.
const PAGE_DECAY = 0.62
const PAGE_WEIGHTS = shares("dashboard-01-pages", PAGE_PATHS.length, PAGE_DECAY)
/**
* How many pages saw any traffic at all. `topPages` is the head of a curve that
* falls by `PAGE_DECAY` a step, so the count is the step at which it finally
* drops below one view — which is why a shorter range reaches fewer pages.
*/
export function pagesWithTraffic(range: RangeKey): number {
const [busiest] = topPages(range)
const steps = Math.floor(Math.log(busiest.value) / Math.log(1 / PAGE_DECAY)) + 1
return Math.max(PAGE_PATHS.length, steps)
}
/** The six most-read pages in a range, by views. */
export function topPages(range: RangeKey): { label: string; value: number }[] {
// A visitor reads about 2.4 pages, so views run ahead of visitors.
const views = sum(span(RANGE_DAYS[range]), "visitors") * 2.4
return PAGE_PATHS.map((label, index) => ({
label,
value: Math.round(views * PAGE_WEIGHTS[index]),
}))
}
/** What the team changed, newest first — the workspace's own audit trail. */
export function recentActivity() {
// Read per call, never held at module scope: a teammate renamed since the
// server started is the name on the next render.
const members = new Map(db.members.all().map((member) => [member.id, member]))
return db.auditEvents
.all()
.sort((a, b) => b.at.getTime() - a.at.getTime())
.slice(0, 8)
.map((event) => ({
id: event.id,
actor: {
name: members.get(event.actor)?.name ?? "A teammate",
src: members.get(event.actor)?.avatarUrl,
},
action: event.action,
target: `${event.resource.replace(/_/g, " ")} ${event.resourceId}`,
time: event.at,
meta: event.diff?.[0]
? `${event.diff[0].field}: ${event.diff[0].from} → ${event.diff[0].to}`
: undefined,
}))
}
// The plans as they are written for a reader, keyed by the value a customer row
// carries: "team" → "Team".
const PLAN_NAMES = new Map(db.plans.all().map((plan) => [plan.name.toLowerCase(), plan.name]))
/** A customer's plan, as the pricing page writes it. */
export function planName(plan: Customer["plan"]): string {
return PLAN_NAMES.get(plan) ?? plan
}
const PLANS: Customer["plan"][] = ["free", "starter", "team", "enterprise"]
/** Every plan a customer row can carry, as the pricing page writes it — for an island to look up. */
export function planNames(): Record<Customer["plan"], string> {
return Object.fromEntries(PLANS.map((plan) => [plan, planName(plan)])) as Record<Customer["plan"], string>
}
/** The newest workspaces, across every plan. */
export function recentSignups(): Customer[] {
return db.customers
.all()
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
.slice(0, 12)
}
/** 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 UPDATED_AT = new Intl.DateTimeFormat("en-US", {
dateStyle: "medium",
timeStyle: "short",
hourCycle: "h23",
timeZone: "UTC",
})
/** When the numbers on this page were last collected. */
export function lastUpdated(): string {
return `Last updated ${UPDATED_AT.format(REFERENCE_DATE)} UTC`
}"use client"
import * as React from "react"
import { ChartTimeRange } from "@/components/ui/chart-time-range"
import { ExportMenu, type ExportFormat } from "@/components/ui/export-menu"
import { type MetricKey, type RangeKey } from "../data"
/** The ranges this page offers. UI vocabulary, so it lives with the control. */
const RANGE_OPTIONS: { value: RangeKey; label: string }[] = [
{ value: "7d", label: "7d" },
{ value: "30d", label: "30d" },
{ value: "90d", label: "90d" },
]
/** The id the tiles point their `aria-controls` at, and the export reads its plot from. */
export const OVERVIEW_CHART_ID = "overview-chart"
type OverviewState = { range: RangeKey; metric: MetricKey; compare: boolean }
type OverviewActions = {
setRange: (range: RangeKey) => void
setMetric: (metric: MetricKey) => void
setCompare: (compare: boolean) => void
}
/** What "export this view" means, filled in by whatever is holding the rows. */
export type OverviewExporter = (format: ExportFormat) => void | Promise<void>
const StateContext = React.createContext<OverviewState>({
range: "30d",
metric: "visitors",
compare: false,
})
const ActionsContext = React.createContext<OverviewActions>({
setRange: () => {},
setMetric: () => {},
setCompare: () => {},
})
// A box rather than a value: the toolbar sits in the page header and the rows
// sit in the chart card, so the two are not in the same subtree. Registering
// through a ref keeps the toolbar from re-rendering when the exporter changes,
// and — the reason it matters here — keeps this module's imports type-only, so
// a client island never pulls the sample-data store into the browser with it.
const ExportContext = React.createContext<{ current: OverviewExporter | null }>({ current: null })
// Whether the tiles and the chart are drawn together. Declared by whoever
// mounts them — the page does, a widget drawn alone does not — and never read
// off the DOM, so the first render already names only what is there.
const LinkedContext = React.createContext(false)
/** The range every number on this page is measured over. */
export function useOverviewRange(): RangeKey {
return React.useContext(StateContext).range
}
/** Which of the four measures the chart is plotting, and whether it is compared. */
export function useOverviewView(): OverviewState {
return React.useContext(StateContext)
}
/**
* Whether the tiles and the chart under them are both mounted, so the tiles
* may say what they drive: on the page their group is named for the chart
* below. A card drawn without its partner speaks only for itself.
*/
export function useOverviewLinked(): boolean {
return React.useContext(LinkedContext)
}
/** The setters behind the toolbar and the metric tiles. */
export function useOverviewActions(): OverviewActions {
return React.useContext(ActionsContext)
}
/** Lets the part that holds the rows answer the toolbar's Export menu. */
export function useRegisterOverviewExport(exporter: OverviewExporter) {
const slotRef = React.useContext(ExportContext)
React.useEffect(() => {
slotRef.current = exporter
return () => {
if (slotRef.current === exporter) slotRef.current = null
}
}, [slotRef, exporter])
}
/**
* Holds what the page is scoped to: the range, the measure the chart plots, and
* whether the period before it is drawn behind. The parts that read it are
* client components; everything else stays on the server and passes through as
* children.
*
* `linked` says the tiles and the chart are both inside: the page sets it,
* because it draws both. A widget drawn alone leaves it off, and then no card
* points at an id its partner would have carried.
*/
export function OverviewRangeProvider({
children,
linked = false,
}: {
children: React.ReactNode
linked?: boolean
}) {
const [state, setState] = React.useState<OverviewState>({
range: "30d",
metric: "visitors",
compare: false,
})
const exporterRef = React.useRef<OverviewExporter | null>(null)
const actions = React.useMemo<OverviewActions>(
() => ({
setRange: (range) => setState((current) => ({ ...current, range })),
setMetric: (metric) => setState((current) => ({ ...current, metric })),
setCompare: (compare) => setState((current) => ({ ...current, compare })),
}),
[]
)
return (
<LinkedContext.Provider value={linked}>
<ExportContext.Provider value={exporterRef}>
<ActionsContext.Provider value={actions}>
<StateContext.Provider value={state}>{children}</StateContext.Provider>
</ActionsContext.Provider>
</ExportContext.Provider>
</LinkedContext.Provider>
)
}
/** The page header's controls: the range, the compare switch, and an export. */
export function OverviewToolbar() {
const { range, compare } = useOverviewView()
const { setRange, setCompare } = useOverviewActions()
const exporterRef = React.useContext(ExportContext)
return (
<>
<ChartTimeRange
size="sm"
value={range}
onValueChange={(value) => setRange(value as RangeKey)}
options={RANGE_OPTIONS}
compare={compare}
onCompareChange={setCompare}
/>
<ExportMenu
size="sm"
formats={["csv", "png", "pdf"]}
// The exporter is read when the item is chosen, not while rendering:
// the chart registers it on mount, and a menu opened before that simply
// has nothing to hand over yet.
onExport={(format) => exporterRef.current?.(format)}
/>
</>
)
}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 Analytics overview page