Skip to contentVibraUI

Clinic calendar

The month with a count on every day that has visits, and the picked day's patients, doctors and times under it; reads appointmentsByDay().

Preview

Install

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

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

Source

app/clinic/components/clinic-calendar.tsx
"use client"

import * as React from "react"

import { VISIT_STATUS_LABELS, VISIT_STATUS_MAP, VISIT_TIME } from "@/lib/dashboards/clinic/vocabulary"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { DataList, DataListItem } from "@/components/ui/data-list"
import { MiniCalendar, type MiniCalendarMark } from "@/components/ui/mini-calendar"
import { StatusBadge } from "@/components/ui/status-badge"

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

export type ClinicCalendarProps = {
  /** Every visit, keyed by its UTC day. */
  byDay: Record<string, AppointmentRow[]>
  /** Midnight UTC on the reference day — the day the calendar opens on. */
  today: Date
}

const FULL_DAY = new Intl.DateTimeFormat("en-US", { dateStyle: "full", timeZone: "UTC" })

/**
 * The month, marked with how many visits each day carries, and the picked
 * day's visits under it. The marks are counts rather than colours: three
 * dots are three visits, and the day's own name reads the number out.
 */
export function ClinicCalendar({ byDay, today }: ClinicCalendarProps) {
  const [picked, setPicked] = React.useState<Date | undefined>(today)

  const marks = React.useMemo(() => {
    const out: Record<string, MiniCalendarMark> = {}
    for (const [key, rows] of Object.entries(byDay)) out[key] = { count: rows.length, tone: "brand" }
    return out
  }, [byDay])

  const key = picked?.toISOString().slice(0, 10)
  const rows = key ? (byDay[key] ?? []) : []

  return (
    <Card data-widget="widget-clinic-overview-clinic-calendar" role="region" aria-label="Calendar" className="h-full">
      <CardHeader>
        <CardTitle>Calendar</CardTitle>
        <CardDescription>Visits by day; pick one to see who is booked.</CardDescription>
      </CardHeader>
      <CardContent className="flex flex-col gap-3">
        <MiniCalendar
          month={today}
          value={picked}
          onValueChange={setPicked}
          marks={marks}
          today={today}
          weekStartsOn={1}
          className="w-full"
        />
        <div
          role="region"
          aria-label="Appointments on the picked day"
          className="flex flex-col gap-1"
        >
          <p className="type-eyebrow">{picked ? FULL_DAY.format(picked) : "Pick a day"}</p>
          {rows.length === 0 ? (
            <p className="py-4 text-center text-sm text-muted-foreground">No appointments that day</p>
          ) : (
            <DataList divided>
              {rows.map((row) => (
                <DataListItem
                  key={row.id}
                  leading={
                    <Avatar size="sm">
                      {row.avatarUrl ? <AvatarImage src={row.avatarUrl} alt="" /> : null}
                      <AvatarFallback>{row.initials}</AvatarFallback>
                    </Avatar>
                  }
                  title={row.patient}
                  description={`${row.doctor} · ${row.department}`}
                  meta={<span className="tabular-nums">{VISIT_TIME.format(row.at)}</span>}
                  actions={
                    <StatusBadge
                      status={row.status}
                      label={VISIT_STATUS_LABELS[row.status]}
                      map={VISIT_STATUS_MAP}
                      size="sm"
                    />
                  }
                />
              ))}
            </DataList>
          )}
        </div>
      </CardContent>
    </Card>
  )
}
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