Desk notes
The desk's pinboard, newest first, and a field that pins a note through the server, a blank one refused where it was typed; reads clinicNotes().
Preview
"use client"
import * as React from "react"
import { PlusIcon } from "lucide-react"
import { VISIT_DATE } from "@/lib/dashboards/clinic/vocabulary"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { DataList, DataListItem } from "@/components/ui/data-list"
import { Input } from "@/components/ui/input"
import { addNote } from "../actions"
import { type ClinicNote } from "../data"
/**
* The desk's pinboard. The notes arrive from the server page; this island
* owns them from then on, so an accepted note is on the list before the
* field has cleared. A blank one is refused where the field is.
*/
export function ClinicNotes({ notes: initial }: { notes: ClinicNote[] }) {
const [notes, setNotes] = React.useState(initial)
const [draft, setDraft] = React.useState("")
const [refusal, setRefusal] = React.useState("")
const [pending, setPending] = React.useState(false)
async function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setRefusal("")
setPending(true)
const result = await addNote(draft)
setPending(false)
if (!result.ok) return setRefusal(result.error.message)
setNotes((current) => [result.data, ...current])
setDraft("")
}
return (
<Card data-widget="widget-clinic-overview-clinic-notes" role="region" aria-label="Notes" className="h-full">
<CardHeader>
<CardTitle>Notes</CardTitle>
<CardDescription>What the desk needs to remember.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<DataList divided>
{notes.map((note) => (
<DataListItem
key={note.id}
title={note.text}
wrapTitle
meta={<span className="tabular-nums">{VISIT_DATE.format(note.at)}</span>}
/>
))}
</DataList>
<form onSubmit={submit} noValidate className="flex flex-col gap-2">
{refusal ? (
<Callout variant="danger" role="alert">
{refusal}
</Callout>
) : null}
<div className="flex items-center gap-2">
<Input
value={draft}
onChange={(event) => setDraft(event.target.value)}
placeholder="Add a new note"
aria-label="New note"
aria-invalid={refusal ? true : undefined}
className="flex-1"
/>
<Button type="submit" variant="outline" disabled={pending}>
<PlusIcon data-icon="inline-start" />
Add
</Button>
</div>
</form>
</CardContent>
</Card>
)
}Install
$
npx shadcn@latest add @vibra/widget-clinic-overview-clinic-notesNeeds the @vibra registry in your components.json — set it up once.
Source
"use client"
import * as React from "react"
import { PlusIcon } from "lucide-react"
import { VISIT_DATE } from "@/lib/dashboards/clinic/vocabulary"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { DataList, DataListItem } from "@/components/ui/data-list"
import { Input } from "@/components/ui/input"
import { addNote } from "../actions"
import { type ClinicNote } from "../data"
/**
* The desk's pinboard. The notes arrive from the server page; this island
* owns them from then on, so an accepted note is on the list before the
* field has cleared. A blank one is refused where the field is.
*/
export function ClinicNotes({ notes: initial }: { notes: ClinicNote[] }) {
const [notes, setNotes] = React.useState(initial)
const [draft, setDraft] = React.useState("")
const [refusal, setRefusal] = React.useState("")
const [pending, setPending] = React.useState(false)
async function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setRefusal("")
setPending(true)
const result = await addNote(draft)
setPending(false)
if (!result.ok) return setRefusal(result.error.message)
setNotes((current) => [result.data, ...current])
setDraft("")
}
return (
<Card data-widget="widget-clinic-overview-clinic-notes" role="region" aria-label="Notes" className="h-full">
<CardHeader>
<CardTitle>Notes</CardTitle>
<CardDescription>What the desk needs to remember.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<DataList divided>
{notes.map((note) => (
<DataListItem
key={note.id}
title={note.text}
wrapTitle
meta={<span className="tabular-nums">{VISIT_DATE.format(note.at)}</span>}
/>
))}
</DataList>
<form onSubmit={submit} noValidate className="flex flex-col gap-2">
{refusal ? (
<Callout variant="danger" role="alert">
{refusal}
</Callout>
) : null}
<div className="flex items-center gap-2">
<Input
value={draft}
onChange={(event) => setDraft(event.target.value)}
placeholder="Add a new note"
aria-label="New note"
aria-invalid={refusal ? true : undefined}
className="flex-1"
/>
<Button type="submit" variant="outline" disabled={pending}>
<PlusIcon data-icon="inline-start" />
Add
</Button>
</div>
</form>
</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 }
}"use server"
import { mockAuthAdapter } from "@/lib/auth-adapter"
import { asString, type Result } from "@/lib/sample-data"
import { pushNote, type ClinicNote } from "./data"
/**
* What this page changes on its own: the session and the desk's notes. The
* check-in is the Clinic dashboard's, shared by every page that moves a
* visit, and lives in `@/lib/dashboards/clinic/actions`.
*/
export async function signOut(): Promise<Result<{ signedOut: true }>> {
await mockAuthAdapter.signOut()
return { ok: true, data: { signedOut: true } }
}
const MAX_NOTE = 160
/** Pins a note to the desk. Blank is refused; so is an essay. */
export async function addNote(text: string): Promise<Result<ClinicNote>> {
const trimmed = asString(text).trim()
if (!trimmed) {
return { ok: false, error: { code: "required", message: "Write a note before adding it.", field: "text" } }
}
if (trimmed.length > MAX_NOTE) {
return {
ok: false,
error: { code: "too_long", message: `Keep a note under ${MAX_NOTE} characters.`, field: "text" },
}
}
return { ok: true, data: pushNote(trimmed) }
}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