Skip to contentVibraUI

Clinic headline

Appointments, new patients, procedures done and the revenue they earned over the last thirty days, each against the thirty before; reads clinicStats().

Preview

Install

npx shadcn@latest add @vibra/widget-clinic-overview-clinic-stats

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

Source

app/clinic/components/clinic-stats.tsx
import * as React from "react"
import { CalendarCheckIcon, ReceiptIcon, StethoscopeIcon, UserRoundPlusIcon } from "lucide-react"

import { StatCard, type StatCardProps } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"

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

// Each tile in its own tint, with the icon that names it, so the desk finds
// this month's revenue by the amber frame before reading a word.
const TILE: Record<ClinicStat["id"], { tone: NonNullable<StatCardProps["tone"]>; icon: React.ReactNode }> = {
  appointments: { tone: "info", icon: <CalendarCheckIcon /> },
  patients: { tone: "success", icon: <UserRoundPlusIcon /> },
  procedures: { tone: "warning", icon: <StethoscopeIcon /> },
  revenue: { tone: "danger", icon: <ReceiptIcon /> },
}

/** The last thirty days in four numbers. No state, so the tiles render on the server. */
export function ClinicStats({ stats }: { stats: ClinicStat[] }) {
  return (
    <StatCardGroup data-widget="widget-clinic-overview-clinic-stats" columns={4}>
      {stats.map((stat) => (
        <StatCard
          key={stat.id}
          id={stat.id}
          label={stat.label}
          value={stat.value}
          delta={stat.delta}
          description={stat.description}
          tone={TILE[stat.id].tone}
          icon={TILE[stat.id].icon}
        />
      ))}
    </StatCardGroup>
  )
}
app/clinic/data.ts
/**
 * What this page reads. Every visit comes from `db.appointments`; the four
 * headline numbers, the two charts, the calendar's marks and the procedure
 * ranking are rules over those rows. The one thing with no rows behind it —
 * last year's visits, for the year select — is generated once from
 * `seeded("dashboard-clinic")`. "Now" is `REFERENCE_DATE`.
 *
 * The desk's notes are the page's own: a module-level list seeded once here
 * and appended through `pushNote`, which only the server action calls.
 *
 * The client islands import only the types below. The words the page puts on
 * a status are the Clinic dashboard's, in
 * `@/lib/dashboards/clinic/vocabulary`, so nothing that reaches
 * `db` is ever pulled into the browser.
 */
import { getInitials } from "@/lib/format"
import { REFERENCE_DATE, db, seeded, type Appointment, type Member } from "@/lib/sample-data"

export type Department = Appointment["department"]
export type AppointmentStatus = Appointment["status"]

/** One visit as the page prints it. */
export type AppointmentRow = {
  id: string
  patient: string
  email: string
  avatarUrl?: string
  initials: string
  doctor: string
  department: Department
  at: Date
  durationMin: number
  status: AppointmentStatus
  procedure?: string
  isNewPatient: boolean
}

function toRow(row: Appointment): AppointmentRow {
  return {
    id: row.id,
    patient: row.patient,
    email: row.email,
    avatarUrl: row.avatarUrl,
    initials: getInitials(row.patient),
    doctor: row.doctor,
    department: row.department,
    at: row.at,
    durationMin: row.durationMin,
    status: row.status,
    procedure: row.procedure,
    isNewPatient: row.isNewPatient,
  }
}

const DAY_MS = 86_400_000
const NOW = REFERENCE_DATE.getTime()

/** What a minute in a consulting room bills at, in cents. */
export const RATE_CENTS_PER_MINUTE = 450

const between = (row: Appointment, from: number, to: number) => row.at.getTime() > from && row.at.getTime() <= to

export type ClinicStat = {
  id: "appointments" | "patients" | "procedures" | "revenue"
  label: string
  value: string
  /** The change against the thirty days before, as a ratio. */
  delta: number
  description: string
}

const DOLLARS = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 })

const ratio = (now: number, before: number) => (before === 0 ? 0 : (now - before) / before)

/**
 * The four headline numbers over the last thirty days, each against the
 * thirty before. A calendar month would show three days of work on the
 * fourth; a trailing window is what a desk compares against.
 */
export function clinicStats(): ClinicStat[] {
  const rows = db.appointments.all()
  const window = (offset: number) => rows.filter((row) => between(row, NOW - (offset + 30) * DAY_MS, NOW - offset * DAY_MS))
  const now = window(0)
  const before = window(30)

  const patients = (set: Appointment[]) => set.filter((row) => row.isNewPatient).length
  const procedures = (set: Appointment[]) => set.filter((row) => row.status === "done" && row.procedure).length
  const revenue = (set: Appointment[]) =>
    set.filter((row) => row.status === "done").reduce((sum, row) => sum + row.durationMin * RATE_CENTS_PER_MINUTE, 0)

  const description = "vs the 30 days before"
  return [
    { id: "appointments", label: "Appointments", value: String(now.length), delta: ratio(now.length, before.length), description },
    { id: "patients", label: "New patients", value: String(patients(now)), delta: ratio(patients(now), patients(before)), description },
    { id: "procedures", label: "Procedures", value: String(procedures(now)), delta: ratio(procedures(now), procedures(before)), description },
    { id: "revenue", label: "Revenue", value: DOLLARS.format(revenue(now) / 100), delta: ratio(revenue(now), revenue(before)), description },
  ]
}

const MONTH = new Intl.DateTimeFormat("en-US", { month: "short", timeZone: "UTC" })
const MONTH_LONG = new Intl.DateTimeFormat("en-US", { month: "long", timeZone: "UTC" })

/** The first of the month `back` months before the reference month. */
function monthStart(back: number, year = REFERENCE_DATE.getUTCFullYear()): Date {
  return new Date(Date.UTC(year, REFERENCE_DATE.getUTCMonth() - back, 1))
}

export type VisitPoint = { month: string } & Record<string, string | number>

/** The three departments that see the most patients, busiest first — the chart's series. */
export function chartDepartments(): Department[] {
  const counts = new Map<Department, number>()
  for (const row of db.appointments.all()) counts.set(row.department, (counts.get(row.department) ?? 0) + 1)
  return [...counts.entries()]
    .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
    .slice(0, 3)
    .map(([department]) => department)
}

const rand = seeded("dashboard-clinic")
// Last year's six months, drawn once: the rows only go back six months, so
// the year before is the one series here without rows behind it.
const LAST_YEAR = Array.from({ length: 6 }, () => [0, 1, 2].map(() => 9 + Math.floor(rand() * 14)))

/**
 * Visits by department over the six months ending in the reference month,
 * keyed by year. The current year is counted off the rows; the year before is
 * the seeded series.
 */
export function visitsByDepartment(): Record<string, VisitPoint[]> {
  const departments = chartDepartments()
  const rows = db.appointments.all()
  const year = REFERENCE_DATE.getUTCFullYear()
  const months = Array.from({ length: 6 }, (_, index) => 5 - index)

  const current = months.map((back) => {
    const start = monthStart(back)
    const end = monthStart(back - 1)
    const point: VisitPoint = { month: MONTH.format(start) }
    for (const department of departments) {
      point[department] = rows.filter(
        (row) => row.department === department && row.status !== "cancelled" && row.at >= start && row.at < end
      ).length
    }
    return point
  })

  const previous = months.map((back, index) => {
    const point: VisitPoint = { month: MONTH.format(monthStart(back)) }
    departments.forEach((department, series) => {
      point[department] = LAST_YEAR[index][series]
    })
    return point
  })

  return { [String(year)]: current, [String(year - 1)]: previous }
}

/** "Six months to September 2026", for whichever year the select is on. */
export function visitsWindow(year: number): string {
  return `Six months to ${MONTH_LONG.format(monthStart(0))} ${year}`
}

/** Distinct patients seen per department, largest first. */
export function patientsByDepartment(): { name: string; value: number }[] {
  const seen = new Map<Department, Set<string>>()
  for (const row of db.appointments.all()) {
    if (!seen.has(row.department)) seen.set(row.department, new Set())
    seen.get(row.department)!.add(row.email)
  }
  return [...seen.entries()]
    .map(([name, patients]) => ({ name, value: patients.size }))
    .sort((a, b) => b.value - a.value || a.name.localeCompare(b.name))
}

/** Every visit that is not cancelled, keyed by its UTC day, for the calendar. */
export function appointmentsByDay(): Record<string, AppointmentRow[]> {
  const out: Record<string, AppointmentRow[]> = {}
  for (const row of db.appointments.all()) {
    if (row.status === "cancelled") continue
    const key = row.at.toISOString().slice(0, 10)
    ;(out[key] ??= []).push(toRow(row))
  }
  for (const key of Object.keys(out)) out[key].sort((a, b) => a.at.getTime() - b.at.getTime())
  return out
}

/** Midnight UTC on the reference day: the day the calendar opens on. */
export const TODAY = new Date(Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth(), REFERENCE_DATE.getUTCDate()))

export type ProcedureShare = { name: string; patients: number; share: number }

/** The five procedures booked most often — done or still to come — with each one's share of all of them. */
export function topProcedures(): ProcedureShare[] {
  const counts = new Map<string, number>()
  for (const row of db.appointments.all()) {
    if (row.status === "cancelled" || !row.procedure) continue
    counts.set(row.procedure, (counts.get(row.procedure) ?? 0) + 1)
  }
  const total = [...counts.values()].reduce((sum, count) => sum + count, 0)
  return [...counts.entries()]
    .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
    .slice(0, 5)
    .map(([name, patients]) => ({ name, patients, share: total === 0 ? 0 : patients / total }))
}

/** Everything still booked from now on, soonest first. */
export function upcomingAppointments(): AppointmentRow[] {
  return db.appointments
    .all()
    .filter((row) => row.status === "scheduled" && row.at.getTime() >= NOW)
    .sort((a, b) => a.at.getTime() - b.at.getTime())
    .map(toRow)
}

/** The six procedures finished most recently. */
export function recentProcedures(): AppointmentRow[] {
  return db.appointments
    .all()
    .filter((row) => row.status === "done" && row.procedure)
    .sort((a, b) => b.at.getTime() - a.at.getTime())
    .slice(0, 6)
    .map(toRow)
}

export type ClinicNote = { id: string; text: string; at: Date }

// The desk's notes, seeded once. Not an entity: nothing else reads them, and
// a list this page alone appends to has no repository to swap.
const NOTE_TEXTS = [
  "Dr. Osei's surgery moved to 10 AM",
  "Staff meeting at 2 PM in the boardroom",
  "New patient orientation packs restocked",
  "Inventory check on the second floor",
  "Annual flu clinic opens next Monday",
  "Pediatrics waiting room repainted",
]
const NOTES: ClinicNote[] = NOTE_TEXTS.map((text, index) => ({
  id: `note_${String(index + 1).padStart(3, "0")}`,
  text,
  at: new Date(NOW - (index * 4 + 1 + Math.floor(rand() * 3)) * DAY_MS),
}))

/** The notes, newest first. */
export function clinicNotes(): ClinicNote[] {
  return [...NOTES].sort((a, b) => b.at.getTime() - a.at.getTime())
}

/** Appends a note dated now. Only the server action calls this; the page reads `clinicNotes`. */
export function pushNote(text: string): ClinicNote {
  const note = { id: `note_${String(NOTES.length + 1).padStart(3, "0")}`, text, at: REFERENCE_DATE }
  NOTES.push(note)
  return note
}

/** 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 }
}

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 Clinic dashboard page