Skip to contentVibraUI

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

Install

npx shadcn@latest add @vibra/widget-clinic-overview-clinic-notes

Needs the @vibra registry in your components.json — set it up once.

Source

app/clinic/components/clinic-notes.tsx
"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>
  )
}
app/clinic/data.ts
/**
 * 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 }
}
app/clinic/actions.ts
"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