Appointment figures
Today's visits and how many are in or done, what is booked from now on and the clinic time it takes, the month's visits and new patients, and the share cancelled; reads visitRows().
Preview
import { CalendarCheckIcon, CalendarClockIcon, CalendarDaysIcon, CalendarXIcon } 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 VisitRow } from "../data"
import { utcDay } from "./visit-vocabulary"
const inMonth = (at: Date, now: Date) => at.getUTCFullYear() === now.getUTCFullYear() && at.getUTCMonth() === now.getUTCMonth()
const hours = (minutes: number) => `${formatNumber(minutes / 60, { maximumFractionDigits: 1 })} h`
/**
* The book in four numbers, counted off the rows the table holds: today's
* visits, what is booked from now on, the month's visits and the month's
* cancellations. The rows are the island's own, so a check-in or a
* cancellation moves these in the same render as the row.
*/
export function VisitFigures({ rows, now }: { rows: VisitRow[]; now: Date }) {
const today = rows.filter((row) => utcDay(row.at) === utcDay(now) && row.status !== "cancelled")
const ahead = rows.filter((row) => row.status === "scheduled" && row.at.getTime() >= now.getTime())
const month = rows.filter((row) => inMonth(row.at, now))
const held = month.filter((row) => row.status !== "cancelled")
const cancelled = month.length - held.length
return (
<StatCardGroup data-widget="widget-clinic-appointments-visit-figures" columns={4} role="region" aria-label="The book in figures">
<StatCard
label="Today"
value={formatNumber(today.length)}
description={`${today.filter((row) => row.status === "checked_in").length} checked in · ${today.filter((row) => row.status === "done").length} done`}
icon={<CalendarCheckIcon />}
/>
<StatCard
label="Booked ahead"
value={formatNumber(ahead.length)}
description={`${hours(ahead.reduce((sum, row) => sum + row.durationMin, 0))} of clinic time`}
icon={<CalendarClockIcon />}
/>
<StatCard
label="This month"
value={formatNumber(held.length)}
description={`${held.filter((row) => row.isNewPatient).length} new patients`}
icon={<CalendarDaysIcon />}
/>
<StatCard
label="Cancelled"
value={formatPercent(month.length === 0 ? 0 : cancelled / month.length, { maximumFractionDigits: 1 })}
description={`${cancelled} of ${month.length} this month`}
icon={<CalendarXIcon />}
/>
</StatCardGroup>
)
}Install
$
npx shadcn@latest add @vibra/widget-clinic-appointments-visit-figuresNeeds the @vibra registry in your components.json — set it up once.
Source
import { CalendarCheckIcon, CalendarClockIcon, CalendarDaysIcon, CalendarXIcon } 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 VisitRow } from "../data"
import { utcDay } from "./visit-vocabulary"
const inMonth = (at: Date, now: Date) => at.getUTCFullYear() === now.getUTCFullYear() && at.getUTCMonth() === now.getUTCMonth()
const hours = (minutes: number) => `${formatNumber(minutes / 60, { maximumFractionDigits: 1 })} h`
/**
* The book in four numbers, counted off the rows the table holds: today's
* visits, what is booked from now on, the month's visits and the month's
* cancellations. The rows are the island's own, so a check-in or a
* cancellation moves these in the same render as the row.
*/
export function VisitFigures({ rows, now }: { rows: VisitRow[]; now: Date }) {
const today = rows.filter((row) => utcDay(row.at) === utcDay(now) && row.status !== "cancelled")
const ahead = rows.filter((row) => row.status === "scheduled" && row.at.getTime() >= now.getTime())
const month = rows.filter((row) => inMonth(row.at, now))
const held = month.filter((row) => row.status !== "cancelled")
const cancelled = month.length - held.length
return (
<StatCardGroup data-widget="widget-clinic-appointments-visit-figures" columns={4} role="region" aria-label="The book in figures">
<StatCard
label="Today"
value={formatNumber(today.length)}
description={`${today.filter((row) => row.status === "checked_in").length} checked in · ${today.filter((row) => row.status === "done").length} done`}
icon={<CalendarCheckIcon />}
/>
<StatCard
label="Booked ahead"
value={formatNumber(ahead.length)}
description={`${hours(ahead.reduce((sum, row) => sum + row.durationMin, 0))} of clinic time`}
icon={<CalendarClockIcon />}
/>
<StatCard
label="This month"
value={formatNumber(held.length)}
description={`${held.filter((row) => row.isNewPatient).length} new patients`}
icon={<CalendarDaysIcon />}
/>
<StatCard
label="Cancelled"
value={formatPercent(month.length === 0 ? 0 : cancelled / month.length, { maximumFractionDigits: 1 })}
description={`${cancelled} of ${month.length} this month`}
icon={<CalendarXIcon />}
/>
</StatCardGroup>
)
}/**
* What this page reads: every visit in `db.appointments`, as the rows the
* book prints. The book is small enough to hand to the table whole, so the
* table searches, filters, sorts and pages it in the browser, and the figures
* above are counted off the same rows there — a check-in or a cancellation
* moves them in the one render. "Now" is `REFERENCE_DATE`, handed to the
* island rather than read off the browser's clock.
*
* The client islands import only the types below; `db` never crosses into
* the browser.
*/
import { formatDate, getInitials } from "@/lib/format"
import { REFERENCE_DATE, db, type Appointment, type Member } from "@/lib/sample-data"
/** One visit as the book prints it. */
export type VisitRow = {
id: string
patient: string
avatarUrl?: string
email: string
doctor: string
department: Appointment["department"]
at: Date
durationMin: number
status: Appointment["status"]
procedure?: string
isNewPatient: boolean
}
function toRow(row: Appointment): VisitRow {
return {
id: row.id,
patient: row.patient,
avatarUrl: row.avatarUrl,
email: row.email,
doctor: row.doctor,
department: row.department,
at: row.at,
durationMin: row.durationMin,
status: row.status,
procedure: row.procedure,
isNewPatient: row.isNewPatient,
}
}
/** Every visit in the book, oldest first. */
export function visitRows(): VisitRow[] {
return db.appointments
.all()
.sort((a, b) => a.at.getTime() - b.at.getTime())
.map(toRow)
}
/** 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 { type VisitRow } from "../data"
/**
* The windows this page cuts the book into, and the rule each one reads.
* The words for a visit — its status, its day and time, the departments —
* are the Clinic dashboard's, in `@/lib/dashboards/clinic/vocabulary`.
*/
export const WINDOWS = [
{ value: "ahead", label: "From today" },
{ value: "past", label: "Past" },
{ value: "all", label: "All" },
] as const
export type VisitWindow = (typeof WINDOWS)[number]["value"]
/** Midnight UTC on the day `date` falls on. */
export const utcDay = (date: Date) => Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
/** Which visits a window holds: from today on, before today, or every one. */
export function inWindow(row: VisitRow, window: VisitWindow, now: Date): boolean {
if (window === "all") return true
return window === "ahead" ? utcDay(row.at) >= utcDay(now) : utcDay(row.at) < utcDay(now)
}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 Appointment book page