Skip to contentVibraUI
Part of the E-commerce dashboardinstalls at /ecommerce/fulfillment

Fulfillment

A warehouse floor: parcels in transit, delivered this week, open exceptions and mean transit time; throughput by service at two bucket sizes; a stage bar for every parcel that has stood still longest; what each carrier is holding; and the queue of parcels that stopped.

Open the live page

Every number on this page is a count or a mean over db.shipments — a parcel per recent paid order, whose stage is wherever the clock has got to rather than a draw of its own, so an old order's parcel is with the customer and only recent work is open. Nothing here is modelled. Mean transit is measured from the carrier handover to the doorstep rather than from the order, because the warehouse is not answerable for the days a parcel spent being picked; it is the one card where a rise is bad news, which is what positiveIsGood says, and the exceptions card is the other. The throughput chart is bucketed on the server at both sizes the reader can ask for — twelve weeks from Monday, or six calendar months — so the segmented control is a re-render rather than a round trip, and each dataset carries its own index key so the chart's spoken summary says "by week" or "by month" instead of "by label". It plots by service (standard, express, overnight) rather than by category the way the storefront dashboard's charts do, because a shipment carries a carrier and a service but no category of its own — there is nothing else on this row to split a chart by. Every bucket boundary is UTC, as is every formatter on the page; buckets.ts holds that calendar on its own, because it is the one part of the page with no rows behind it at all. The in-flight board is sorted oldest first, not newest: the parcel a shift has to act on is the one that has not moved, and a board sorted by arrival would bury it. A parcel that is stopped is not on the board at all — the exceptions queue owns it, and a stopped parcel always outwaits a moving one, so listing both would fill the board with the queue's own rows; its stage bar still draws the stopped segment as blocked, so the two never disagree. A delivered parcel is never an open exception, because an exception is only open while the parcel is still ours. The exceptions queue itself shows only the eight newest problems — the same size the in-flight board caps its own list to — and its footer names how many more are open once the true count runs past that, rather than letting a short list imply it is all of them. The two charts each give up a default that does not survive this data: the throughput lines name themselves under the plot rather than beside their last point, because three services running at similar volumes finish within a parcel or two of each other; and the carrier chart plots one series rather than two, because nine parcels in ten have already been delivered and the open queue — the only number anyone acts on — would be a sliver beside them. The delivered totals are in the footer, where a count reads fine without a bar. Composes AppShell, PageHeader, StatCardGroup, StatCard, DashboardGrid, Widget, SegmentedControl, LineChart, HorizontalBarChart, StageProgress, DataList, DataListItem and EmptyState.

Preview

Install

npx shadcn@latest add @vibra/ecommerce-fulfillment

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

Source

app/ecommerce/fulfillment/page.tsx
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { DashboardGrid, DashboardGridItem } from "@/components/ui/dashboard-grid"
import { PageHeader } from "@/components/ui/page-header"

import { signOut } from "./actions"
import { CarrierMix } from "./components/carrier-mix"
import { ExceptionList } from "./components/exception-list"
import { FulfillmentStats } from "./components/fulfillment-stats"
import { InFlightBoard } from "./components/in-flight-board"
import { ThroughputChart } from "./components/throughput-chart"
import {
  carrierLoad,
  currentUser,
  fulfillmentStats,
  inFlightParcels,
  lastUpdated,
  openExceptions,
  openExceptionsCount,
  shellNotifications,
  throughput,
} from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/ecommerce/nav"

/**
 * The warehouse floor. A server component: every count, mean and bucket is
 * worked out here against `REFERENCE_DATE`, and only the two charts — one of
 * which lets the reader change its bucket — are client islands.
 */
export default function FulfillmentPage() {
  const { weeks, months } = throughput()

  return (
    <AppShell
      nav={NAV}
      activeHref={ROUTES.fulfillment}
      user={currentUser()}
      notifications={shellNotifications()}
      now={REFERENCE_DATE}
      onSignOut={signOut}
    >
      <PageHeader
        title="Fulfillment"
        description="What is moving, what is stuck, and who is carrying it."
        meta={lastUpdated()}
      />

      <FulfillmentStats stats={fulfillmentStats()} />

      <DashboardGrid>
        <DashboardGridItem colSpan={{ base: 12, lg: 8 }}>
          <ThroughputChart weeks={weeks} months={months} />
        </DashboardGridItem>
        <DashboardGridItem colSpan={{ base: 12, lg: 4 }}>
          <CarrierMix rows={carrierLoad()} />
        </DashboardGridItem>

        <DashboardGridItem colSpan={{ base: 12, lg: 7 }}>
          <InFlightBoard parcels={inFlightParcels()} now={REFERENCE_DATE} />
        </DashboardGridItem>
        <DashboardGridItem colSpan={{ base: 12, lg: 5 }}>
          <ExceptionList
            parcels={openExceptions()}
            total={openExceptionsCount()}
            now={REFERENCE_DATE}
          />
        </DashboardGridItem>
      </DashboardGrid>
    </AppShell>
  )
}
app/ecommerce/fulfillment/data.ts
/**
 * What this page reads. Every row is a `db.shipments` record — a parcel against
 * an order, four stages of which the ones already reached carry a timestamp, a
 * carrier, a service, and an exception where the warehouse raised one. Nothing
 * is invented: a number on this page is a count or a mean over those rows.
 * "Now" is `REFERENCE_DATE`, and every bucket boundary is UTC.
 */
import { getInitials } from "@/lib/format"
import {
  REFERENCE_DATE,
  SHIPMENT_STAGES,
  daysAgo,
  db,
  type Member,
  type Shipment,
  type ShipmentException,
  type ShipmentStageName,
} from "@/lib/sample-data"
import { type StageProgressStage } from "@/components/ui/stage-progress"

import { monthBuckets, weekBuckets, type Bucket } from "./buckets"
import { SERVICE_LABELS } from "./vocabulary"

// Read per call, never held at module scope: a parcel marked shipped on the
// order's own page is on this board the next time it is asked for.
const shipments = (): Shipment[] => db.shipments.all()

const HOUR_MS = 3_600_000

/** A parcel that has not reached the customer yet. */
const flying = (row: Shipment) => row.stage !== "delivered"

/** When a stage was stamped, if it has been. */
const stampOf = (row: Shipment, name: ShipmentStageName): Date | undefined =>
  row.stages.find((stage) => stage.name === name)?.at

const inWindow = (at: Date, from: Date, to: Date) =>
  at.getTime() >= from.getTime() && at.getTime() < to.getTime()

/** The change from `was` to `now` as a ratio; 0 when there was nothing to grow from. */
function growth(now: number, was: number): number {
  if (was === 0) return 0
  return (now - was) / was
}

export type FulfillmentStat = {
  key: string
  label: string
  value: number
  /** "count" prints as a whole number, "hours" as a duration. */
  kind: "count" | "hours"
  description: string
  /** Left out where there is nothing honest to compare against. */
  delta?: number
  /** False where a rise is the bad news. */
  positiveIsGood: boolean
}

/** Parcels delivered inside a span. */
const deliveredBetween = (from: Date, to: Date) =>
  shipments().filter((row) => {
    const at = stampOf(row, "delivered")
    return at !== undefined && inWindow(at, from, to)
  })

/** Mean hours from the carrier taking a parcel to the customer having it. */
function meanTransitHours(rows: Shipment[]): number {
  const spans = rows
    .map((row) => {
      const shipped = stampOf(row, "shipped")
      const delivered = stampOf(row, "delivered")
      return shipped && delivered ? delivered.getTime() - shipped.getTime() : null
    })
    .filter((span): span is number => span !== null)
  if (spans.length === 0) return 0
  return spans.reduce((sum, span) => sum + span, 0) / spans.length / HOUR_MS
}

/** The four headline numbers a warehouse morning starts on. */
export function fulfillmentStats(): FulfillmentStat[] {
  const thisWeek = deliveredBetween(daysAgo(7), REFERENCE_DATE)
  const lastWeek = deliveredBetween(daysAgo(14), daysAgo(7))
  const transitNow = meanTransitHours(deliveredBetween(daysAgo(30), REFERENCE_DATE))
  const transitBefore = meanTransitHours(deliveredBetween(daysAgo(60), daysAgo(30)))

  return [
    {
      key: "in-transit",
      label: "In transit",
      value: shipments().filter(flying).length,
      kind: "count",
      description: "Picking, packing, or with a carrier",
      positiveIsGood: true,
    },
    {
      key: "delivered",
      label: "Delivered this week",
      value: thisWeek.length,
      kind: "count",
      description: "vs the week before",
      delta: growth(thisWeek.length, lastWeek.length),
      positiveIsGood: true,
    },
    {
      key: "exceptions",
      label: "Open exceptions",
      value: shipments().filter((row) => flying(row) && row.exception).length,
      kind: "count",
      description: "Parcels stopped and waiting on someone",
      positiveIsGood: false,
    },
    {
      key: "transit",
      label: "Mean transit",
      value: transitNow,
      kind: "hours",
      description: "Carrier handover to doorstep, last 30 days",
      delta: growth(transitNow, transitBefore),
      // A longer journey is worse news, so the arrow points the other way.
      positiveIsGood: false,
    },
  ]
}

/**
 * One bucket of the throughput chart. The index key is the bucket's own name —
 * `week` or `month` — so the chart's spoken summary reads "by week" rather than
 * "by label"; every other key is a service's count.
 */
export type ThroughputPoint = Record<string, number | string>

/** Parcels handed to a carrier in each bucket, one count per service. */
function bucketed(buckets: Bucket[], index: string): ThroughputPoint[] {
  return buckets.map((bucket) => {
    const point: ThroughputPoint = { [index]: bucket.label }
    for (const service of Object.keys(SERVICE_LABELS)) point[service] = 0

    for (const row of shipments()) {
      const shipped = stampOf(row, "shipped")
      if (!shipped || !inWindow(shipped, bucket.start, bucket.end)) continue
      point[row.service] = (point[row.service] as number) + 1
    }
    return point
  })
}

/**
 * Throughput by service, at both bucket sizes the reader can ask for. Both are
 * computed here, on the server, so switching between them costs no round trip
 * and no second read of the store.
 */
export function throughput(): { weeks: ThroughputPoint[]; months: ThroughputPoint[] } {
  return { weeks: bucketed(weekBuckets(12), "week"), months: bucketed(monthBuckets(6), "month") }
}

export type Parcel = {
  id: string
  orderNumber: string
  carrier: string
  service: Shipment["service"]
  destination: string
  tracking?: string
  stage: ShipmentStageName
  /** Every stage in order, with the state the bar draws it in. */
  stages: StageProgressStage[]
  /** When the parcel reached the stage it is on now. */
  since: Date
  /** Set where the parcel is stopped, which is what makes its stage blocked. */
  exception?: ShipmentException
}

/** A parcel the warehouse has to do something about: its exception is not optional. */
export type StuckParcel = Parcel & { exception: ShipmentException }

/** What each stage is called on the page. */
const STAGE_LABELS: Record<ShipmentStageName, string> = {
  picking: "Picking",
  packing: "Packing",
  shipped: "Shipped",
  delivered: "Delivered",
}

/** All four stages, each in the state the parcel's own progress puts it in. */
export function stageBars(row: Shipment): StageProgressStage[] {
  const reached = SHIPMENT_STAGES.indexOf(row.stage)

  return SHIPMENT_STAGES.map((name, index) => ({
    id: name,
    label: STAGE_LABELS[name],
    state:
      index < reached
        ? ("done" as const)
        : index > reached
          ? ("pending" as const)
          : // The stage the parcel sits on: blocked when something stopped it
            // there, done when the journey is over, otherwise still running.
            row.exception
            ? ("blocked" as const)
            : row.stage === "delivered"
              ? ("done" as const)
              : ("active" as const),
  }))
}

function toParcel(row: Shipment): Parcel {
  return {
    id: row.id,
    orderNumber: row.orderNumber,
    carrier: row.carrier,
    service: row.service,
    destination: row.destination,
    ...(row.tracking ? { tracking: row.tracking } : {}),
    stage: row.stage,
    stages: stageBars(row),
    since: stampOf(row, row.stage) ?? REFERENCE_DATE,
    ...(row.exception
      ? {
          exception: {
            code: row.exception.code,
            message: row.exception.message,
            raisedAt: row.exception.raisedAt,
          },
        }
      : {}),
  }
}

/** How many parcels the stage board holds: the oldest work, not all of it. */
export const IN_FLIGHT_SHOWN = 8

/**
 * The parcels still moving that have been sitting at their current stage
 * longest — the ones a shift picks up first — oldest first. A parcel with an
 * exception is not moving at all: the exceptions queue owns it, and listing
 * it here as well would fill the board with the same rows twice.
 */
export function inFlightParcels(): Parcel[] {
  return shipments().filter((row) => flying(row) && !row.exception)
    .map(toParcel)
    .sort((a, b) => a.since.getTime() - b.since.getTime())
    .slice(0, IN_FLIGHT_SHOWN)
}

export type CarrierLoad = { carrier: string; inFlight: number; delivered: number }

/** What each carrier is holding now, heaviest first. */
export function carrierLoad(): CarrierLoad[] {
  const load = new Map<string, CarrierLoad>()

  for (const row of shipments()) {
    const entry = load.get(row.carrier) ?? { carrier: row.carrier, inFlight: 0, delivered: 0 }
    if (flying(row)) entry.inFlight += 1
    else entry.delivered += 1
    load.set(row.carrier, entry)
  }

  return [...load.values()].sort((a, b) => b.inFlight - a.inFlight)
}

function allOpenExceptions(): StuckParcel[] {
  return shipments().filter((row) => flying(row) && row.exception)
    .map(toParcel)
    .filter((parcel): parcel is StuckParcel => parcel.exception !== undefined)
    .sort((a, b) => b.exception.raisedAt.getTime() - a.exception.raisedAt.getTime())
}

/** How many exceptions the queue shows before its footer says how many more there are. */
export const EXCEPTIONS_SHOWN = 8

/** The newest problems, capped the way the in-flight board caps its own list. */
export function openExceptions(): StuckParcel[] {
  return allOpenExceptions().slice(0, EXCEPTIONS_SHOWN)
}

/** How many exceptions are open in total — what the queue's footer counts the shown rows against. */
export function openExceptionsCount(): number {
  return allOpenExceptions().length
}

/** 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",
})

/** How many parcels the book holds, and when it was last collected. */
export function lastUpdated(): string {
  return `${shipments().length} parcels · updated ${UPDATED_AT.format(REFERENCE_DATE)} UTC`
}
app/ecommerce/fulfillment/actions.ts
"use server"

import { mockAuthAdapter } from "@/lib/auth-adapter"
import { type Result } from "@/lib/sample-data"

/**
 * The one thing this page changes. A server action so the page can stay a
 * server component and still hand the shell something to call, and a `Result`
 * so the caller reads the same success-or-error shape every mutation returns.
 */
export async function signOut(): Promise<Result<{ signedOut: true }>> {
  await mockAuthAdapter.signOut()
  return { ok: true, data: { signedOut: true } }
}
app/ecommerce/fulfillment/vocabulary.ts
/**
 * The words this page is written in, and nothing else.
 *
 * `data.ts` reads `db` at module scope, so a value a client island imports from
 * it would drag the whole sample-data store into the browser. The service and
 * exception names, and the one function that says how long a parcel has been
 * standing still, have no rows behind them — and nothing here imports a value
 * from the store, which is the whole point of the file.
 */
import { type ShipmentException } from "@/lib/sample-data"

/** What each shipping service is called on the page, in the order it is ranked. */
export const SERVICE_LABELS = {
  standard: "Standard",
  express: "Express",
  overnight: "Overnight",
} as const

/** How long a parcel has been sitting somewhere, in the roundest true words. */
export function sinceLabel(from: Date, now: Date): string {
  const hours = Math.max(0, Math.round((now.getTime() - from.getTime()) / 3_600_000))
  if (hours < 1) return "under an hour"
  if (hours < 48) return `${hours}h`
  return `${Math.round(hours / 24)}d`
}

/** What each exception code is called where a reader has to act on it. */
export const EXCEPTION_LABELS: Record<ShipmentException["code"], string> = {
  address: "Address incomplete",
  customs: "Held at customs",
  damaged: "Damaged in transit",
  delayed: "Late at the hub",
  missing_label: "Label never printed",
}
app/ecommerce/fulfillment/buckets.ts
/**
 * The calendar this page's throughput chart is bucketed on: whole weeks from
 * Monday, and whole calendar months, both in UTC and both measured back from
 * `REFERENCE_DATE`. Nothing here reads `db` or the clock.
 */
import { REFERENCE_DATE } from "@/lib/sample-data"

const DAY_MS = 86_400_000

/** One bucket: the half-open span it covers, and what it is called. */
export type Bucket = { start: Date; end: Date; label: string }

// Fixed to UTC: a bucket named for the day it starts on has to be named the
// same wherever the page renders.
const WEEK_LABEL = new Intl.DateTimeFormat("en-US", {
  month: "short",
  day: "numeric",
  timeZone: "UTC",
})
const MONTH_LABEL = new Intl.DateTimeFormat("en-US", { month: "short", timeZone: "UTC" })

/** Weeks back to front: twelve buckets, Monday to Monday, in UTC. */
export function weekBuckets(count: number): Bucket[] {
  // Monday of the week REFERENCE_DATE falls in, at midnight UTC.
  const day = REFERENCE_DATE.getUTCDay()
  const mondayOffset = (day + 6) % 7
  const thisMonday = Date.UTC(
    REFERENCE_DATE.getUTCFullYear(),
    REFERENCE_DATE.getUTCMonth(),
    REFERENCE_DATE.getUTCDate() - mondayOffset
  )

  return Array.from({ length: count }, (_, index) => {
    const start = new Date(thisMonday - (count - 1 - index) * 7 * DAY_MS)
    return {
      start,
      end: new Date(start.getTime() + 7 * DAY_MS),
      label: WEEK_LABEL.format(start),
    }
  })
}

/** Calendar months back to front, in UTC. */
export function monthBuckets(count: number): Bucket[] {
  const year = REFERENCE_DATE.getUTCFullYear()
  const month = REFERENCE_DATE.getUTCMonth()

  return Array.from({ length: count }, (_, index) => {
    const start = new Date(Date.UTC(year, month - (count - 1 - index), 1))
    return {
      start,
      end: new Date(Date.UTC(year, month - (count - 2 - index), 1)),
      label: MONTH_LABEL.format(start),
    }
  })
}
app/ecommerce/fulfillment/components/carrier-mix.tsx
"use client"

import { formatNumber } from "@/lib/format"
import { HorizontalBarChart } from "@/components/ui/horizontal-bar-chart"
import { Widget } from "@/components/ui/widget"

import { type CarrierLoad } from "../data"

const SERIES = [{ key: "inFlight", label: "In flight", color: "chart-1" as const }]

/**
 * What each carrier is holding right now.
 *
 * One series, not two: nine in ten parcels in the book have already been
 * delivered, so a bar for those would be an order of magnitude longer and the
 * open queue — the only number anyone acts on — would be a sliver against it.
 * The delivered totals are in the footer instead, where a count reads fine
 * without a bar.
 */
export function CarrierMix({ rows }: { rows: CarrierLoad[] }) {
  const flying = rows.reduce((sum, row) => sum + row.inFlight, 0)
  const delivered = rows.reduce((sum, row) => sum + row.delivered, 0)

  return (
    <Widget
      data-widget="widget-ecommerce-fulfillment-carrier-mix"
      title="Carriers"
      description="Parcels each carrier is holding right now"
      className="h-full"
      footer={`${formatNumber(flying)} parcels are open across the four carriers; ${formatNumber(delivered)} have been delivered.`}
    >
      <HorizontalBarChart
        data={rows}
        index="carrier"
        series={SERIES}
        height={220}
        valueFormatter={(value) => formatNumber(value, { maximumFractionDigits: 0 })}
      />
    </Widget>
  )
}
app/ecommerce/fulfillment/components/exception-list.tsx
import { DataList, DataListItem } from "@/components/ui/data-list"
import { EmptyState } from "@/components/ui/empty-state"
import { Widget } from "@/components/ui/widget"

import { type StuckParcel } from "../data"
import { EXCEPTION_LABELS, sinceLabel } from "../vocabulary"

/**
 * Every parcel that is stopped, newest problem first — the queue to work.
 *
 * `parcels` is already capped to `EXCEPTIONS_SHOWN`; `total` is how many are
 * actually open, so the footer can say how many more there are rather than
 * letting a short list imply that is all of them.
 */
export function ExceptionList({
  parcels,
  total,
  now,
}: {
  parcels: StuckParcel[]
  total: number
  now: Date
}) {
  const hidden = total - parcels.length
  const footer =
    "A delivered parcel is never in this list: an exception is only open while the parcel is still ours." +
    (hidden > 0 ? ` ${hidden} more ${hidden === 1 ? "is" : "are"} not shown here.` : "")

  return (
    <Widget
      data-widget="widget-ecommerce-fulfillment-exception-list"
      title="Exceptions"
      description="Parcels that stopped, and what stopped them"
      className="h-full"
      contentClassName="px-0"
      footer={footer}
    >
      {parcels.length === 0 ? (
        <div className="px-4">
          <EmptyState
            title="Nothing is stuck"
            description="Every parcel in the book is moving."
            size="sm"
          />
        </div>
      ) : (
        <DataList divided>
          {parcels.map((parcel) => (
            <DataListItem
              key={parcel.id}
              title={`${parcel.orderNumber} · ${EXCEPTION_LABELS[parcel.exception.code]}`}
              description={parcel.exception.message}
              wrapTitle
              meta={
                <span className="whitespace-nowrap tabular-nums">
                  {sinceLabel(parcel.exception.raisedAt, now)}
                </span>
              }
            />
          ))}
        </DataList>
      )}
    </Widget>
  )
}
app/ecommerce/fulfillment/components/fulfillment-stats.tsx
import { formatNumber } from "@/lib/format"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"

import { type FulfillmentStat } from "../data"

/** Hours, printed the way a warehouse says them: "31h", or "1.3d" past two days. */
function hours(value: number): string {
  if (value < 48) return `${formatNumber(value, { maximumFractionDigits: 0 })}h`
  return `${formatNumber(value / 24, { maximumFractionDigits: 1 })}d`
}

/** The four numbers a shift starts on. */
export function FulfillmentStats({ stats }: { stats: FulfillmentStat[] }) {
  return (
    <StatCardGroup data-widget="widget-ecommerce-fulfillment-fulfillment-stats" columns={4}>
      {stats.map((stat) => (
        <StatCard
          key={stat.key}
          label={stat.label}
          value={stat.kind === "hours" ? hours(stat.value) : formatNumber(stat.value)}
          delta={stat.delta}
          deltaFormat="percent"
          positiveIsGood={stat.positiveIsGood}
          description={stat.description}
        />
      ))}
    </StatCardGroup>
  )
}
app/ecommerce/fulfillment/components/in-flight-board.tsx
import { StageProgress } from "@/components/ui/stage-progress"
import { Widget } from "@/components/ui/widget"

import { type Parcel } from "../data"
import { SERVICE_LABELS, sinceLabel } from "../vocabulary"

/**
 * The moving parcels that have been at their stage longest, one stage bar each.
 *
 * Sorted oldest first rather than newest, because the row a shift has to do
 * something about is the one that has not moved — a board sorted by arrival
 * would bury it. A parcel something has stopped is not on this board at all:
 * the exceptions queue beside it holds those.
 */
export function InFlightBoard({ parcels, now }: { parcels: Parcel[]; now: Date }) {
  return (
    <Widget
      data-widget="widget-ecommerce-fulfillment-in-flight-board"
      title="In flight"
      description="The moving parcels that have been at their stage longest"
      className="h-full"
      contentClassName="flex flex-col gap-4"
      footer="Waiting time is measured from the stamp on the stage the parcel is on, against the same fixed now the rest of the page uses."
    >
      {parcels.map((parcel) => (
        <div key={parcel.id} className="flex flex-col gap-2">
          <div className="flex items-baseline justify-between gap-3">
            <span className="flex min-w-0 items-baseline gap-2">
              <span className="font-mono text-xs">{parcel.orderNumber}</span>
              <span className="truncate text-xs text-muted-foreground">
                {parcel.carrier} · {SERVICE_LABELS[parcel.service]} · {parcel.destination}
              </span>
            </span>
            <span className="shrink-0 text-xs tabular-nums text-muted-foreground">
              {sinceLabel(parcel.since, now)} here
            </span>
          </div>
          <StageProgress stages={parcel.stages} size="sm" />
        </div>
      ))}
    </Widget>
  )
}
app/ecommerce/fulfillment/components/throughput-chart.tsx
"use client"

import * as React from "react"

import { formatNumber } from "@/lib/format"
import { LineChart } from "@/components/ui/line-chart"
import { SegmentedControl } from "@/components/ui/segmented-control"
import { Widget } from "@/components/ui/widget"

import { type ThroughputPoint } from "../data"
import { SERVICE_LABELS } from "../vocabulary"

const SERIES = (Object.keys(SERVICE_LABELS) as (keyof typeof SERVICE_LABELS)[]).map(
  (key, index) => ({ key, label: SERVICE_LABELS[key], color: `chart-${index + 1}` as const })
)

const BUCKETS = [
  { value: "weeks", label: "Weeks" },
  { value: "months", label: "Months" },
]

/**
 * How many parcels each service handed to a carrier, at whichever bucket size
 * the reader asks for. Both series arrive as props — the page counted them on
 * the server — so the switch is a re-render, not a round trip, and the chart
 * has a true first frame before any JavaScript runs.
 */
export function ThroughputChart({
  weeks,
  months,
}: {
  weeks: ThroughputPoint[]
  months: ThroughputPoint[]
}) {
  const [bucket, setBucket] = React.useState("weeks")
  const data = bucket === "months" ? months : weeks

  return (
    <Widget
      data-widget="widget-ecommerce-fulfillment-throughput-chart"
      title="Parcels by service"
      description={
        bucket === "months"
          ? "Handed to a carrier, per calendar month"
          : "Handed to a carrier, per week from Monday"
      }
      className="h-full"
      actions={
        <SegmentedControl
          aria-label="Bucket"
          size="sm"
          options={BUCKETS}
          value={bucket}
          onValueChange={setBucket}
        />
      }
      footer="Counted from the moment the carrier took the parcel, so a parcel still being packed is not in here yet."
    >
      <LineChart
        data={data}
        index={bucket === "months" ? "month" : "week"}
        series={SERIES}
        height={240}
        // Under the plot rather than beside the last point: the three services
        // run at similar volumes and their lines finish within a parcel or two
        // of each other, so inline labels would sit on top of one another.
        legend="bottom"
        valueFormatter={(value) => formatNumber(value, { maximumFractionDigits: 0 })}
        dots={data.length <= 12}
      />
    </Widget>
  )
}