Skip to contentVibraUI

Patient figures

Everyone in the book, the month's first visits, the patients seen twice or more, and those with a visit still to come; reads patientFigures(rows).

Preview

Install

npx shadcn@latest add @vibra/widget-clinic-patients-patient-figures

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

Source

app/clinic/patients/components/patient-figures.tsx
import { CalendarClockIcon, RepeatIcon, UserRoundPlusIcon, UsersIcon } from "lucide-react"

import { formatNumber } from "@/lib/format"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"

import { type PatientFigures as Figures } from "../data"

/** The patient list in four numbers. No state, so the tiles render on the server. */
export function PatientFigures({ figures, month }: { figures: Figures; month: string }) {
  return (
    <StatCardGroup data-widget="widget-clinic-patients-patient-figures" columns={4} role="region" aria-label="Patients in figures">
      <StatCard label="Patients" value={formatNumber(figures.patients)} description="Everyone in the book" icon={<UsersIcon />} />
      <StatCard
        label="New this month"
        value={formatNumber(figures.newThisMonth)}
        description={`First visits in ${month}`}
        icon={<UserRoundPlusIcon />}
      />
      <StatCard label="Returning" value={formatNumber(figures.returning)} description="Seen twice or more" icon={<RepeatIcon />} />
      <StatCard
        label="Booked ahead"
        value={formatNumber(figures.bookedAhead)}
        description="With a visit still to come"
        icon={<CalendarClockIcon />}
      />
    </StatCardGroup>
  )
}
app/clinic/patients/data.ts
/**
 * What this page reads. A patient is a name: every `db.appointments` row
 * under the same name is the same patient, and `patientRows` works each one's
 * record out of those rows — the visits they were seen at (done, or checked in
 * today), the first and the last of them, the departments that saw them, and
 * the next appointment still booked. A cancelled visit stays on the record
 * but was not a visit. "New this month" is the book's own word for it: a
 * visit this month flagged `isNewPatient`. "Now" is `REFERENCE_DATE`.
 *
 * The client islands import only the types below.
 */
import { formatDate, getInitials } from "@/lib/format"
import { REFERENCE_DATE, db, type Appointment, type Member } from "@/lib/sample-data"

/** One appointment on a patient's record. */
export type PatientVisit = {
  id: string
  at: Date
  doctor: string
  department: Appointment["department"]
  durationMin: number
  status: Appointment["status"]
  procedure?: string
}

export type PatientRow = {
  /** The patient is the name. */
  name: string
  avatarUrl?: string
  /** The address on their most recent appointment. */
  email: string
  /** Visits they were seen at: done, or checked in. */
  visits: number
  firstVisit?: Date
  lastVisit?: Date
  /** The departments that saw them, in the order they first did. */
  departments: Appointment["department"][]
  nextAppointment?: { at: Date; doctor: string; department: Appointment["department"] }
  newThisMonth: boolean
  /** Every appointment under the name, the newest first. */
  history: PatientVisit[]
}

export type PatientFigures = { patients: number; newThisMonth: number; returning: number; bookedAhead: number }

const NOW = REFERENCE_DATE.getTime()

const seen = (row: Appointment) => row.status === "done" || row.status === "checked_in"

const inMonth = (at: Date) =>
  at.getUTCFullYear() === REFERENCE_DATE.getUTCFullYear() && at.getUTCMonth() === REFERENCE_DATE.getUTCMonth()

/** Every patient in the book, the one seen most recently first. */
export function patientRows(): PatientRow[] {
  const byName = new Map<string, Appointment[]>()
  for (const row of [...db.appointments.all()].sort((a, b) => a.at.getTime() - b.at.getTime())) {
    const rows = byName.get(row.patient)
    if (rows) rows.push(row)
    else byName.set(row.patient, [row])
  }

  return [...byName.entries()]
    .map(([name, rows]) => {
      const visits = rows.filter(seen)
      const next = rows.find((row) => row.status === "scheduled" && row.at.getTime() >= NOW)
      const latest = rows.at(-1)!
      return {
        name,
        avatarUrl: latest.avatarUrl,
        email: latest.email,
        visits: visits.length,
        ...(visits.length > 0 ? { firstVisit: visits[0].at, lastVisit: visits.at(-1)!.at } : {}),
        departments: [...new Set(visits.map((row) => row.department))],
        ...(next ? { nextAppointment: { at: next.at, doctor: next.doctor, department: next.department } } : {}),
        newThisMonth: rows.some((row) => row.isNewPatient && row.status !== "cancelled" && inMonth(row.at)),
        history: [...rows].reverse().map(({ id, at, doctor, department, durationMin, status, procedure }) => ({
          id,
          at,
          doctor,
          department,
          durationMin,
          status,
          procedure,
        })),
      }
    })
    .sort((a, b) => (b.lastVisit?.getTime() ?? -Infinity) - (a.lastVisit?.getTime() ?? -Infinity) || a.name.localeCompare(b.name))
}

/** The four numbers above the list, off the same records. */
export function patientFigures(rows: PatientRow[] = patientRows()): PatientFigures {
  return {
    patients: rows.length,
    newThisMonth: rows.filter((row) => row.newThisMonth).length,
    returning: rows.filter((row) => row.visits >= 2).length,
    bookedAhead: rows.filter((row) => row.nextAppointment).length,
  }
}

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

/** "September": the month "new" is counted over. */
export function monthName(): string {
  return MONTH.format(REFERENCE_DATE)
}

/** The freshness line under the title, measured against REFERENCE_DATE. */
export function lastUpdated(): string {
  return `Synced ${formatDate(REFERENCE_DATE, "medium", { timeZone: "UTC" })}`
}

/** 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 Patient list page