Skip to contentVibraUI

Doctor figures

The doctors on the schedule and their departments, the hours booked over twenty-eight days and the busiest department, the month's visits and how many were seen, and the visits cancelled; reads clinicFigures(doctors).

Preview

Install

npx shadcn@latest add @vibra/widget-clinic-doctors-doctor-figures

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

Source

app/clinic/doctors/components/doctor-figures.tsx
import { CalendarCheckIcon, CalendarXIcon, ClockIcon, StethoscopeIcon } from "lucide-react"

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

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

import { hours } from "./doctor-vocabulary"

/** The clinic in four numbers, added up from every doctor's. No state, so it renders on the server. */
export function DoctorFigures({ figures }: { figures: ClinicFigures }) {
  return (
    <StatCardGroup data-widget="widget-clinic-doctors-doctor-figures" columns={4} role="region" aria-label="The clinic in figures">
      <StatCard
        label="Doctors"
        value={formatNumber(figures.doctors)}
        description={`Across ${figures.departments} departments`}
        icon={<StethoscopeIcon />}
      />
      <StatCard
        label="Booked hours · 28 days"
        value={hours(figures.bookedMinutes)}
        description={figures.busiest ? `Busiest: ${figures.busiest.department}, ${hours(figures.busiest.minutes)}` : "Nothing booked"}
        icon={<ClockIcon />}
      />
      <StatCard
        label="This month"
        value={formatNumber(figures.appointments)}
        description={`${figures.done} seen so far`}
        icon={<CalendarCheckIcon />}
      />
      <StatCard
        label="Cancelled"
        value={formatNumber(figures.cancelled)}
        description={`${formatPercent(figures.appointments === 0 ? 0 : figures.cancelled / figures.appointments, { maximumFractionDigits: 0 })} of this month's visits`}
        icon={<CalendarXIcon />}
      />
    </StatCardGroup>
  )
}
app/clinic/doctors/data.ts
/**
 * What this page reads. A doctor is the name the schedule prints: every
 * `db.appointments` row under it is theirs, and their department is the one
 * those rows carry. `doctorCards` works each doctor's load out of their own
 * rows, as rules rather than a roster the store does not keep:
 *
 * - booked hours are the minutes of every visit that was not cancelled with
 *   `at` in the twenty-eight days to `REFERENCE_DATE` — (now − 28 days, now] —
 *   and a doctor's share is those minutes over the whole clinic's in the same
 *   window. A capacity model would need a roster the book does not have;
 *   hours actually booked, set against the clinic's own, need none;
 * - a doctor's clinic days are the days they have a visit on, and the next
 *   opening is the first half hour on one of those days, from now on, between
 *   08:00 and 17:00 UTC, that overlaps none of their visits.
 *
 * The client islands import only the types below.
 */
import { DEPARTMENTS } from "@/lib/dashboards/clinic/vocabulary"
import { formatDate, getInitials } from "@/lib/format"
import { REFERENCE_DATE, avatarFor, db, type Appointment, type Member } from "@/lib/sample-data"

const MINUTE = 60_000
const DAY = 86_400_000
const NOW = REFERENCE_DATE.getTime()

/** The window booked hours are counted over: the twenty-eight days to now. */
const WINDOW_DAYS = 28
const SINCE = NOW - WINDOW_DAYS * DAY

/** The hours a clinic day runs, UTC, and the length of the slot a new patient needs. */
const OPENS = 8
const CLOSES = 17
const SLOT_MIN = 30
const STEP_MIN = 15

/** Midnight UTC on the day `time` falls on. */
const utcDay = (time: number) => {
  const date = new Date(time)
  return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
}

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

/** A visit that took up clinic time in the window: not cancelled, and at in (now − 28 days, now]. */
const bookedInWindow = (row: Appointment) => row.status !== "cancelled" && row.at.getTime() > SINCE && row.at.getTime() <= NOW

const minutesOf = (rows: Appointment[]) => rows.reduce((sum, row) => sum + row.durationMin, 0)

export type DoctorCard = {
  name: string
  avatarUrl: string
  department: Appointment["department"]
  /** This month's appointments, and how many were seen and how many cancelled. */
  month: { total: number; done: number; cancelled: number }
  /** Minutes booked in the twenty-eight days to now. */
  bookedMinutes: number
  /** The whole clinic's booked minutes in the same window — what the doctor's share is of. */
  clinicMinutes: number
  /** A visit today that is not cancelled: the doctor holds clinic today. */
  inToday: boolean
  nextOpening?: Date
  nextVisit?: { patient: string; avatarUrl?: string; at: Date }
}

/** The first half hour from now, on a day the doctor holds clinic, that none of their visits touches. */
function nextOpening(visits: Appointment[]): Date | undefined {
  const busy = visits.map((row) => [row.at.getTime(), row.at.getTime() + row.durationMin * MINUTE] as const)
  const days = [...new Set(visits.map((row) => utcDay(row.at.getTime())))].filter((day) => day >= utcDay(NOW)).sort((a, b) => a - b)
  const step = STEP_MIN * MINUTE
  const from = Math.ceil(NOW / step) * step

  for (const day of days) {
    const close = day + CLOSES * 60 * MINUTE
    for (let start = Math.max(day + OPENS * 60 * MINUTE, from); start + SLOT_MIN * MINUTE <= close; start += step) {
      const end = start + SLOT_MIN * MINUTE
      if (!busy.some(([taken, until]) => start < until && taken < end)) return new Date(start)
    }
  }
  return undefined
}

/** Every doctor on the schedule, in department order, with their load worked out of their visits. */
export function doctorCards(): DoctorCard[] {
  const rows = db.appointments.all()
  const clinicMinutes = minutesOf(rows.filter(bookedInWindow))
  const byDoctor = new Map<string, Appointment[]>()
  for (const row of rows) {
    const own = byDoctor.get(row.doctor)
    if (own) own.push(row)
    else byDoctor.set(row.doctor, [row])
  }

  return [...byDoctor.entries()]
    .map(([name, own]) => {
      const held = own.filter((row) => row.status !== "cancelled")
      const month = own.filter((row) => inMonth(row.at))
      const next = own
        .filter((row) => row.status === "scheduled" && row.at.getTime() >= NOW)
        .sort((a, b) => a.at.getTime() - b.at.getTime())[0]
      const opening = nextOpening(held)
      return {
        name,
        avatarUrl: avatarFor(name),
        department: own[0].department,
        month: {
          total: month.length,
          done: month.filter((row) => row.status === "done").length,
          cancelled: month.filter((row) => row.status === "cancelled").length,
        },
        bookedMinutes: minutesOf(own.filter(bookedInWindow)),
        clinicMinutes,
        inToday: held.some((row) => utcDay(row.at.getTime()) === utcDay(NOW)),
        ...(opening ? { nextOpening: opening } : {}),
        ...(next ? { nextVisit: { patient: next.patient, avatarUrl: next.avatarUrl, at: next.at } } : {}),
      }
    })
    .sort((a, b) => DEPARTMENTS.indexOf(a.department) - DEPARTMENTS.indexOf(b.department) || a.name.localeCompare(b.name))
}

export type ClinicFigures = {
  doctors: number
  departments: number
  /** The clinic's booked minutes in the twenty-eight days to now. */
  bookedMinutes: number
  /** The department that booked the most of them, the first in DEPARTMENTS on a tie. */
  busiest?: { department: Appointment["department"]; minutes: number }
  appointments: number
  done: number
  cancelled: number
}

/** The four numbers above the cards: the same loads and months, added up across the clinic. */
export function clinicFigures(cards: DoctorCard[]): ClinicFigures {
  const sum = (pick: (card: DoctorCard) => number) => cards.reduce((total, card) => total + pick(card), 0)
  const byDepartment = DEPARTMENTS.map((department) => ({
    department,
    minutes: sum((card) => (card.department === department ? card.bookedMinutes : 0)),
  }))
  const busiest = byDepartment.reduce<ClinicFigures["busiest"]>(
    (best, entry) => (entry.minutes > (best?.minutes ?? 0) ? entry : best),
    undefined
  )
  return {
    doctors: cards.length,
    departments: new Set(cards.map((card) => card.department)).size,
    bookedMinutes: sum((card) => card.bookedMinutes),
    ...(busiest ? { busiest } : {}),
    appointments: sum((card) => card.month.total),
    done: sum((card) => card.month.done),
    cancelled: sum((card) => card.month.cancelled),
  }
}

/** 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 }
}
app/clinic/doctors/components/doctor-vocabulary.ts
import { VISIT_DAY, VISIT_TIME } from "@/lib/dashboards/clinic/vocabulary"
import { formatNumber } from "@/lib/format"

/**
 * The two formats this page puts on a doctor's month. The words for a visit
 * and the departments are the Clinic dashboard's, in
 * `@/lib/dashboards/clinic/vocabulary`.
 */

/** Minutes as hours, one decimal: "20.9 h". */
export const hours = (minutes: number) => `${formatNumber(minutes / 60, { maximumFractionDigits: 1, minimumFractionDigits: 1 })} h`

/** A slot in the clinic's own zone: "Mon, Sep 7 · 8:00 AM". */
export const slot = (at: Date) => `${VISIT_DAY.format(at)} · ${VISIT_TIME.format(at)}`

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 Doctors page