Skip to contentVibraUI

Top pages

The six most-read pages ranked by views, and how many pages saw any traffic; reads topPagesByRange().

Preview

Install

npx shadcn@latest add @vibra/widget-saas-overview-top-pages

Needs the @vibra registry in your components.json — set it up once.

Source

app/saas/components/top-pages.tsx
"use client"

import { formatCompact } from "@/lib/format"
import { RankList } from "@/components/ui/rank-list"
import { Widget } from "@/components/ui/widget"

import { type ByRange, type PagesView } from "../data"
import { useOverviewRange } from "./overview-range"

/** The most-read pages of the range the toolbar picked, out of every range the server read. */
export function TopPages({ pages: byRange }: { pages: ByRange<PagesView> }) {
  const range = useOverviewRange()
  const { days, pages, withTraffic } = byRange[range]

  return (
    <Widget
      data-widget="widget-saas-overview-top-pages"
      title="Top pages"
      description={`By views, last ${days} days`}
      className="h-full"
      footer={`${pages.length} of ${withTraffic} pages with traffic`}
    >
      <RankList
        items={pages}
        showRank
        color="chart-1"
        format={(value) => formatCompact(value)}
      />
    </Widget>
  )
}
app/saas/data.ts
/**
 * 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`
}
app/saas/components/overview-range.tsx
"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