Patient figures
Everyone in the book, the month's first visits, the patients seen twice or more, and those with a visit still to come; reads patientFigures(rows).
Preview
import { CalendarClockIcon, RepeatIcon, UserRoundPlusIcon, UsersIcon } from "lucide-react"
import { formatNumber } from "@/lib/format"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
import { type PatientFigures as Figures } from "../data"
/** The patient list in four numbers. No state, so the tiles render on the server. */
export function PatientFigures({ figures, month }: { figures: Figures; month: string }) {
return (
<StatCardGroup data-widget="widget-clinic-patients-patient-figures" columns={4} role="region" aria-label="Patients in figures">
<StatCard label="Patients" value={formatNumber(figures.patients)} description="Everyone in the book" icon={<UsersIcon />} />
<StatCard
label="New this month"
value={formatNumber(figures.newThisMonth)}
description={`First visits in ${month}`}
icon={<UserRoundPlusIcon />}
/>
<StatCard label="Returning" value={formatNumber(figures.returning)} description="Seen twice or more" icon={<RepeatIcon />} />
<StatCard
label="Booked ahead"
value={formatNumber(figures.bookedAhead)}
description="With a visit still to come"
icon={<CalendarClockIcon />}
/>
</StatCardGroup>
)
}Install
$
npx shadcn@latest add @vibra/widget-clinic-patients-patient-figuresNeeds the @vibra registry in your components.json — set it up once.
Source
import { CalendarClockIcon, RepeatIcon, UserRoundPlusIcon, UsersIcon } from "lucide-react"
import { formatNumber } from "@/lib/format"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
import { type PatientFigures as Figures } from "../data"
/** The patient list in four numbers. No state, so the tiles render on the server. */
export function PatientFigures({ figures, month }: { figures: Figures; month: string }) {
return (
<StatCardGroup data-widget="widget-clinic-patients-patient-figures" columns={4} role="region" aria-label="Patients in figures">
<StatCard label="Patients" value={formatNumber(figures.patients)} description="Everyone in the book" icon={<UsersIcon />} />
<StatCard
label="New this month"
value={formatNumber(figures.newThisMonth)}
description={`First visits in ${month}`}
icon={<UserRoundPlusIcon />}
/>
<StatCard label="Returning" value={formatNumber(figures.returning)} description="Seen twice or more" icon={<RepeatIcon />} />
<StatCard
label="Booked ahead"
value={formatNumber(figures.bookedAhead)}
description="With a visit still to come"
icon={<CalendarClockIcon />}
/>
</StatCardGroup>
)
}/**
* What this page reads. A patient is a name: every `db.appointments` row
* under the same name is the same patient, and `patientRows` works each one's
* record out of those rows — the visits they were seen at (done, or checked in
* today), the first and the last of them, the departments that saw them, and
* the next appointment still booked. A cancelled visit stays on the record
* but was not a visit. "New this month" is the book's own word for it: a
* visit this month flagged `isNewPatient`. "Now" is `REFERENCE_DATE`.
*
* The client islands import only the types below.
*/
import { formatDate, getInitials } from "@/lib/format"
import { REFERENCE_DATE, db, type Appointment, type Member } from "@/lib/sample-data"
/** One appointment on a patient's record. */
export type PatientVisit = {
id: string
at: Date
doctor: string
department: Appointment["department"]
durationMin: number
status: Appointment["status"]
procedure?: string
}
export type PatientRow = {
/** The patient is the name. */
name: string
avatarUrl?: string
/** The address on their most recent appointment. */
email: string
/** Visits they were seen at: done, or checked in. */
visits: number
firstVisit?: Date
lastVisit?: Date
/** The departments that saw them, in the order they first did. */
departments: Appointment["department"][]
nextAppointment?: { at: Date; doctor: string; department: Appointment["department"] }
newThisMonth: boolean
/** Every appointment under the name, the newest first. */
history: PatientVisit[]
}
export type PatientFigures = { patients: number; newThisMonth: number; returning: number; bookedAhead: number }
const NOW = REFERENCE_DATE.getTime()
const seen = (row: Appointment) => row.status === "done" || row.status === "checked_in"
const inMonth = (at: Date) =>
at.getUTCFullYear() === REFERENCE_DATE.getUTCFullYear() && at.getUTCMonth() === REFERENCE_DATE.getUTCMonth()
/** Every patient in the book, the one seen most recently first. */
export function patientRows(): PatientRow[] {
const byName = new Map<string, Appointment[]>()
for (const row of [...db.appointments.all()].sort((a, b) => a.at.getTime() - b.at.getTime())) {
const rows = byName.get(row.patient)
if (rows) rows.push(row)
else byName.set(row.patient, [row])
}
return [...byName.entries()]
.map(([name, rows]) => {
const visits = rows.filter(seen)
const next = rows.find((row) => row.status === "scheduled" && row.at.getTime() >= NOW)
const latest = rows.at(-1)!
return {
name,
avatarUrl: latest.avatarUrl,
email: latest.email,
visits: visits.length,
...(visits.length > 0 ? { firstVisit: visits[0].at, lastVisit: visits.at(-1)!.at } : {}),
departments: [...new Set(visits.map((row) => row.department))],
...(next ? { nextAppointment: { at: next.at, doctor: next.doctor, department: next.department } } : {}),
newThisMonth: rows.some((row) => row.isNewPatient && row.status !== "cancelled" && inMonth(row.at)),
history: [...rows].reverse().map(({ id, at, doctor, department, durationMin, status, procedure }) => ({
id,
at,
doctor,
department,
durationMin,
status,
procedure,
})),
}
})
.sort((a, b) => (b.lastVisit?.getTime() ?? -Infinity) - (a.lastVisit?.getTime() ?? -Infinity) || a.name.localeCompare(b.name))
}
/** The four numbers above the list, off the same records. */
export function patientFigures(rows: PatientRow[] = patientRows()): PatientFigures {
return {
patients: rows.length,
newThisMonth: rows.filter((row) => row.newThisMonth).length,
returning: rows.filter((row) => row.visits >= 2).length,
bookedAhead: rows.filter((row) => row.nextAppointment).length,
}
}
const MONTH = new Intl.DateTimeFormat("en-US", { month: "long", timeZone: "UTC" })
/** "September": the month "new" is counted over. */
export function monthName(): string {
return MONTH.format(REFERENCE_DATE)
}
/** 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 }
}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 Patient list page