/clinic/doctorsDoctors
Every doctor on the schedule as a card: the department, the hours booked in the last twenty-eight days and their share of the clinic's, the month's visits seen and cancelled, the next opening on their next clinic day and who is next through the door — narrowed by department.
Installs at /clinic/doctors. A doctor is the name the schedule prints: every db.appointments row under it is theirs, their department the one those rows carry, and their face avatarFor of the name. The page is a server component; doctorCards works each doctor's load out of their own rows as rules, because the store keeps no roster to measure capacity against. The headline is booked hours over the last twenty-eight days — the minutes of every visit not cancelled with at in (REFERENCE_DATE − 28 days, REFERENCE_DATE] — and under it a bar of the doctor's share of the whole clinic's booked hours in the same window, with its percentage; the shares add up to the clinic. 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 them, from now on, between 08:00 and 17:00 UTC, that overlaps none of their visits. The figures add the clinic up — its booked hours in the window and the busiest department — with the month's visits and cancellations; the cards sit two to a row so a row reads as a department, and the pills narrow them to one. Composes AppShell, PageHeader, StatCardGroup, StatCard, QuickFilters, Card, CardHeader, CardAction, CardContent, CardFooter, UserCell, StatusBadge, Progress, ProgressLabel, ProgressValue and StatList.
Preview
import { NAV, ROUTES } from "@/lib/dashboards/clinic/nav"
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"
import { signOut } from "./actions"
import { DoctorFigures } from "./components/doctor-figures"
import { DoctorsGrid } from "./components/doctors-grid"
import { clinicFigures, currentUser, doctorCards, lastUpdated, shellNotifications } from "./data"
/**
* The doctors. The page is a server component: it works every doctor's load
* out of `db.appointments` — the hours booked in the last twenty-eight days
* and their share of the clinic's, the month's visits, the next opening —
* adds them up for the figures, and hands the cards to the island that
* narrows them by department.
*/
export default function DoctorsPage() {
const doctors = doctorCards()
return (
<AppShell
nav={NAV}
activeHref={ROUTES.doctors}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
title="Doctors"
description="Who is on the schedule, how much of the clinic's time they carry, and when they can next see someone."
meta={lastUpdated()}
/>
<div className="flex flex-col gap-4">
<DoctorFigures figures={clinicFigures(doctors)} />
<DoctorsGrid doctors={doctors} />
</div>
</AppShell>
)
}Install
npx shadcn@latest add @vibra/clinic-doctorsNeeds the @vibra registry in your components.json — set it up once.
Source
import { NAV, ROUTES } from "@/lib/dashboards/clinic/nav"
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"
import { signOut } from "./actions"
import { DoctorFigures } from "./components/doctor-figures"
import { DoctorsGrid } from "./components/doctors-grid"
import { clinicFigures, currentUser, doctorCards, lastUpdated, shellNotifications } from "./data"
/**
* The doctors. The page is a server component: it works every doctor's load
* out of `db.appointments` — the hours booked in the last twenty-eight days
* and their share of the clinic's, the month's visits, the next opening —
* adds them up for the figures, and hands the cards to the island that
* narrows them by department.
*/
export default function DoctorsPage() {
const doctors = doctorCards()
return (
<AppShell
nav={NAV}
activeHref={ROUTES.doctors}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
title="Doctors"
description="Who is on the schedule, how much of the clinic's time they carry, and when they can next see someone."
meta={lastUpdated()}
/>
<div className="flex flex-col gap-4">
<DoctorFigures figures={clinicFigures(doctors)} />
<DoctorsGrid doctors={doctors} />
</div>
</AppShell>
)
}/**
* 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 }
}"use server"
import { mockAuthAdapter } from "@/lib/auth-adapter"
import { type Result } from "@/lib/sample-data"
/**
* What this page changes: only the session. The doctors' page reads; the
* desk's verbs live on the appointments page and the overview.
*/
export async function signOut(): Promise<Result<{ signedOut: true }>> {
await mockAuthAdapter.signOut()
return { ok: true, data: { signedOut: true } }
}import { Card, CardAction, CardContent, CardFooter, CardHeader } from "@/components/ui/card"
import { Progress, ProgressLabel, ProgressValue } from "@/components/ui/progress"
import { StatList } from "@/components/ui/stat-list"
import { StatusBadge } from "@/components/ui/status-badge"
import { UserCell } from "@/components/ui/user-cell"
import { type DoctorCard as Doctor } from "../data"
import { hours, slot } from "./doctor-vocabulary"
/**
* One doctor's load: the face and the department on the strip; the hours
* booked in the last twenty-eight days as the headline, and under them the
* share of the whole clinic's hours those are; the month's visits seen and
* cancelled; the next half hour a new patient could have on the doctor's next
* clinic day; and who is next through the door.
*/
export function DoctorCard({ doctor }: { doctor: Doctor }) {
const share = doctor.clinicMinutes === 0 ? 0 : Math.round((doctor.bookedMinutes / doctor.clinicMinutes) * 100)
return (
<Card role="region" aria-label={doctor.name} data-slot="doctor-card" className="h-full">
<CardHeader>
<UserCell name={doctor.name} src={doctor.avatarUrl} description={doctor.department} />
{doctor.inToday ? (
<CardAction>
<StatusBadge status="in_today" label="In today" map={{ in_today: "success" }} />
</CardAction>
) : null}
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-0.5">
<span className="text-sm text-muted-foreground">Booked hours · 28 days</span>
<span data-slot="booked-hours" className="type-numeral text-2xl">
{hours(doctor.bookedMinutes)}
</span>
</div>
<Progress value={share} className="gap-1.5">
<ProgressLabel className="text-xs font-normal text-muted-foreground">Share of clinic</ProgressLabel>
<ProgressValue className="text-xs" />
</Progress>
</div>
<StatList
size="sm"
divided
items={[
{ label: "Appointments this month", value: doctor.month.total },
{ label: "Seen", value: doctor.month.done },
{ label: "Cancelled", value: doctor.month.cancelled },
{
label: "Next opening",
value: doctor.nextOpening ? (
<span className="flex flex-col items-end">
<span>{slot(doctor.nextOpening)}</span>
<span className="font-normal text-muted-foreground">on their next clinic day</span>
</span>
) : (
"None in the book"
),
},
]}
/>
</CardContent>
<CardFooter>
{doctor.nextVisit ? (
<UserCell
size="sm"
name={doctor.nextVisit.patient}
src={doctor.nextVisit.avatarUrl}
description={`Next in · ${slot(doctor.nextVisit.at)}`}
/>
) : (
<p className="text-xs text-muted-foreground">Nobody booked in yet.</p>
)}
</CardFooter>
</Card>
)
}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>
)
}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)}`"use client"
import * as React from "react"
import { DEPARTMENTS } from "@/lib/dashboards/clinic/vocabulary"
import { QuickFilters } from "@/components/ui/quick-filters"
import { type DoctorCard as Doctor } from "../data"
import { DoctorCard } from "./doctor-card"
/**
* The doctors, a card each, two to a row from md — the departments hold two
* doctors apiece, so a row reads as a department. The pills narrow the cards
* to one department; the cards themselves are plain server-shaped data.
*/
export function DoctorsGrid({ doctors }: { doctors: Doctor[] }) {
const [department, setDepartment] = React.useState("all")
const shown = department === "all" ? doctors : doctors.filter((doctor) => doctor.department === department)
return (
<div data-slot="doctors-grid" className="flex flex-col gap-4">
<QuickFilters
aria-label="Doctors by department"
options={[
{ value: "all", label: "All", count: doctors.length },
...DEPARTMENTS.map((value) => ({
value,
label: value,
count: doctors.filter((doctor) => doctor.department === value).length,
})),
]}
value={department}
onValueChange={setDepartment}
/>
<div className="grid gap-4 md:grid-cols-2">
{shown.map((doctor) => (
<DoctorCard key={doctor.name} doctor={doctor} />
))}
</div>
</div>
)
}