Skip to contentVibraUI

Appointment figures

Today's visits and how many are in or done, what is booked from now on and the clinic time it takes, the month's visits and new patients, and the share cancelled; reads visitRows().

Preview

Install

npx shadcn@latest add @vibra/widget-clinic-appointments-visit-figures

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

Source

app/clinic/appointments/components/visit-figures.tsx
import { CalendarCheckIcon, CalendarClockIcon, CalendarDaysIcon, CalendarXIcon } 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 VisitRow } from "../data"

import { utcDay } from "./visit-vocabulary"

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

const hours = (minutes: number) => `${formatNumber(minutes / 60, { maximumFractionDigits: 1 })} h`

/**
 * The book in four numbers, counted off the rows the table holds: today's
 * visits, what is booked from now on, the month's visits and the month's
 * cancellations. The rows are the island's own, so a check-in or a
 * cancellation moves these in the same render as the row.
 */
export function VisitFigures({ rows, now }: { rows: VisitRow[]; now: Date }) {
  const today = rows.filter((row) => utcDay(row.at) === utcDay(now) && row.status !== "cancelled")
  const ahead = rows.filter((row) => row.status === "scheduled" && row.at.getTime() >= now.getTime())
  const month = rows.filter((row) => inMonth(row.at, now))
  const held = month.filter((row) => row.status !== "cancelled")
  const cancelled = month.length - held.length

  return (
    <StatCardGroup data-widget="widget-clinic-appointments-visit-figures" columns={4} role="region" aria-label="The book in figures">
      <StatCard
        label="Today"
        value={formatNumber(today.length)}
        description={`${today.filter((row) => row.status === "checked_in").length} checked in · ${today.filter((row) => row.status === "done").length} done`}
        icon={<CalendarCheckIcon />}
      />
      <StatCard
        label="Booked ahead"
        value={formatNumber(ahead.length)}
        description={`${hours(ahead.reduce((sum, row) => sum + row.durationMin, 0))} of clinic time`}
        icon={<CalendarClockIcon />}
      />
      <StatCard
        label="This month"
        value={formatNumber(held.length)}
        description={`${held.filter((row) => row.isNewPatient).length} new patients`}
        icon={<CalendarDaysIcon />}
      />
      <StatCard
        label="Cancelled"
        value={formatPercent(month.length === 0 ? 0 : cancelled / month.length, { maximumFractionDigits: 1 })}
        description={`${cancelled} of ${month.length} this month`}
        icon={<CalendarXIcon />}
      />
    </StatCardGroup>
  )
}
app/clinic/appointments/data.ts
/**
 * What this page reads: every visit in `db.appointments`, as the rows the
 * book prints. The book is small enough to hand to the table whole, so the
 * table searches, filters, sorts and pages it in the browser, and the figures
 * above are counted off the same rows there — a check-in or a cancellation
 * moves them in the one render. "Now" is `REFERENCE_DATE`, handed to the
 * island rather than read off the browser's clock.
 *
 * The client islands import only the types below; `db` never crosses into
 * the browser.
 */
import { formatDate, getInitials } from "@/lib/format"
import { REFERENCE_DATE, db, type Appointment, type Member } from "@/lib/sample-data"

/** One visit as the book prints it. */
export type VisitRow = {
  id: string
  patient: string
  avatarUrl?: string
  email: string
  doctor: string
  department: Appointment["department"]
  at: Date
  durationMin: number
  status: Appointment["status"]
  procedure?: string
  isNewPatient: boolean
}

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

/** Every visit in the book, oldest first. */
export function visitRows(): VisitRow[] {
  return db.appointments
    .all()
    .sort((a, b) => a.at.getTime() - b.at.getTime())
    .map(toRow)
}

/** 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/appointments/components/visit-vocabulary.ts
import { type VisitRow } from "../data"

/**
 * The windows this page cuts the book into, and the rule each one reads.
 * The words for a visit — its status, its day and time, the departments —
 * are the Clinic dashboard's, in `@/lib/dashboards/clinic/vocabulary`.
 */
export const WINDOWS = [
  { value: "ahead", label: "From today" },
  { value: "past", label: "Past" },
  { value: "all", label: "All" },
] as const

export type VisitWindow = (typeof WINDOWS)[number]["value"]

/** Midnight UTC on the day `date` falls on. */
export const utcDay = (date: Date) => Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())

/** Which visits a window holds: from today on, before today, or every one. */
export function inWindow(row: VisitRow, window: VisitWindow, now: Date): boolean {
  if (window === "all") return true
  return window === "ahead" ? utcDay(row.at) >= utcDay(now) : utcDay(row.at) < utcDay(now)
}

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 Appointment book page