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