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
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>
)
}Install
$
npx shadcn@latest add @vibra/widget-clinic-doctors-doctor-figuresNeeds the @vibra registry in your components.json — set it up once.
Source
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>
)
}/**
* 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 }
}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