{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "medication-schedule",
  "title": "Medication schedule",
  "description": "A daily dosing timeline with dose tracking and interaction warnings from data you provide.",
  "dependencies": [
    "@base-ui/react@^1.8.0",
    "class-variance-authority@^0.7.1",
    "cn@^0.5.0"
  ],
  "registryDependencies": [
    "alert",
    "button",
    "card",
    "empty"
  ],
  "files": [
    {
      "path": "registry/base-nova/medication-schedule/medication-schedule.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cn } from \"cn\"\n\nimport { Alert, AlertDescription, AlertTitle } from \"@/components/ui/alert\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n  Card,\n  CardAction,\n  CardContent,\n  CardDescription,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\"\nimport { Empty, EmptyDescription, EmptyHeader } from \"@/components/ui/empty\"\n\nexport type Medication = {\n  id: string\n  name: string\n  /** Shown after the name, such as \"500 mg\". */\n  dose?: string\n  /** Shown under the name, such as \"With food\". */\n  instructions?: string\n  /** Local 24-hour dosing times, \"HH:MM\". */\n  times: readonly string[]\n  /** Weekdays to dose on, 0 for Sunday to 6 for Saturday. Defaults to all. */\n  days?: readonly number[]\n  /** First dosing date, \"YYYY-MM-DD\", inclusive. */\n  start?: string\n  /** Last dosing date, \"YYYY-MM-DD\", inclusive. */\n  end?: string\n  /** A CSS color for the medication's swatch and timeline markers. */\n  color?: string\n}\nexport type InteractionSeverity = \"minor\" | \"moderate\" | \"major\"\nexport type MedicationInteraction = {\n  id: string\n  /** Two or more medication IDs. Shown when all of them dose that day. */\n  medicationIds: readonly string[]\n  severity: InteractionSeverity\n  summary: string\n  /** Doses of these medications closer than this many hours are flagged. */\n  separationHours?: number\n}\nexport type DoseStatus = \"taken\" | \"skipped\"\nexport type ScheduledDose = {\n  /** `${medication.id}@${date}T${time}`, the key used in dose status. */\n  key: string\n  medication: Medication\n  date: string\n  time: string\n  minutes: number\n}\nexport type DoseConflict = {\n  interaction: MedicationInteraction\n  doses: readonly [ScheduledDose, ScheduledDose]\n}\nexport type MedicationScheduleProps = {\n  medications: readonly Medication[]\n  /** Interactions come from your own data source; none are bundled. */\n  interactions?: readonly MedicationInteraction[]\n  /** The day shown, \"YYYY-MM-DD\". */\n  date: string\n  /** Shows previous and next day buttons when set. */\n  onDateChange?: (date: string) => void\n  /** Missing doses are still to take. */\n  doseStatus?: Readonly<Record<string, DoseStatus>>\n  /** Makes each dose's status a button that cycles to take, taken, skipped. */\n  onDoseStatusChange?: (doseStatus: Record<string, DoseStatus>) => void\n  /** The current local time, \"HH:MM\", drawn as a marker on the timeline. */\n  now?: string\n  /** Used to format the date heading. Defaults to \"en-US\". */\n  locale?: string\n  label?: string\n  /** Shown under the schedule. Pass null to hide it. */\n  disclaimer?: React.ReactNode\n  className?: string\n}\n\nconst day = 24 * 60\nconst severityOrder: Record<InteractionSeverity, number> = {\n  major: 0,\n  moderate: 1,\n  minor: 2,\n}\nconst severityLabel: Record<InteractionSeverity, string> = {\n  major: \"Major\",\n  moderate: \"Moderate\",\n  minor: \"Minor\",\n}\nconst statusLabel = { todo: \"To take\", taken: \"Taken\", skipped: \"Skipped\" }\n\nexport function parseTime(time: string): number {\n  const match = /^([01]\\d|2[0-3]):([0-5]\\d)$/.exec(time)\n  if (!match) throw new Error(`Dose times must be \"HH:MM\": found \"${time}\"`)\n  return Number(match[1]) * 60 + Number(match[2])\n}\n\nfunction parseDate(date: string): Date {\n  const match = /^(\\d{4})-(\\d{2})-(\\d{2})$/.exec(date)\n  const parsed = match\n    ? new Date(Date.UTC(+match[1]!, +match[2]! - 1, +match[3]!))\n    : null\n  if (!parsed || parsed.toISOString().slice(0, 10) !== date)\n    throw new Error(`Dates must be \"YYYY-MM-DD\": found \"${date}\"`)\n  return parsed\n}\n\nexport function addDays(date: string, days: number): string {\n  const parsed = parseDate(date)\n  parsed.setUTCDate(parsed.getUTCDate() + days)\n  return parsed.toISOString().slice(0, 10)\n}\n\nexport function medicationIndex(\n  medications: readonly Medication[]\n): Map<string, Medication> {\n  const index = new Map<string, Medication>()\n  for (const medication of medications) {\n    if (!medication.id || index.has(medication.id))\n      throw new Error(\n        `Medication IDs must be nonempty and unique: found \"${medication.id}\"`\n      )\n    medication.times.forEach(parseTime)\n    if (medication.start) parseDate(medication.start)\n    if (medication.end) parseDate(medication.end)\n    index.set(medication.id, medication)\n  }\n  return index\n}\n\n/** The doses due on a date, in time order. */\nexport function dosesOn(\n  medications: readonly Medication[],\n  date: string\n): ScheduledDose[] {\n  medicationIndex(medications)\n  const weekday = parseDate(date).getUTCDay()\n  return medications\n    .filter(\n      ({ days, start, end }) =>\n        (!days || days.includes(weekday)) &&\n        (!start || start <= date) &&\n        (!end || date <= end)\n    )\n    .flatMap((medication) =>\n      [...new Set(medication.times)].map((time) => ({\n        key: `${medication.id}@${date}T${time}`,\n        medication,\n        date,\n        time,\n        minutes: parseTime(time),\n      }))\n    )\n    .sort(\n      (a, b) =>\n        a.minutes - b.minutes ||\n        a.medication.name.localeCompare(b.medication.name)\n    )\n}\n\n/** Interactions whose medications all dose on the day, most severe first. */\nexport function activeInteractions(\n  doses: readonly ScheduledDose[],\n  interactions: readonly MedicationInteraction[]\n): MedicationInteraction[] {\n  const scheduled = new Set(doses.map(({ medication }) => medication.id))\n  return interactions\n    .filter(\n      ({ medicationIds }) =>\n        new Set(medicationIds).size >= 2 &&\n        medicationIds.every((id) => scheduled.has(id))\n    )\n    .sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity])\n}\n\n/** Pairs of same-day doses closer than an interaction's separation. */\nexport function doseConflicts(\n  doses: readonly ScheduledDose[],\n  interactions: readonly MedicationInteraction[]\n): DoseConflict[] {\n  const conflicts: DoseConflict[] = []\n  for (const interaction of activeInteractions(doses, interactions)) {\n    if (!interaction.separationHours) continue\n    const involved = doses.filter(({ medication }) =>\n      interaction.medicationIds.includes(medication.id)\n    )\n    involved.forEach((first, index) => {\n      for (const second of involved.slice(index + 1))\n        if (\n          first.medication.id !== second.medication.id &&\n          Math.abs(first.minutes - second.minutes) <\n            interaction.separationHours! * 60\n        )\n          conflicts.push({ interaction, doses: [first, second] })\n    })\n  }\n  return conflicts\n}\n\n/** Cycles a dose from to take, to taken, to skipped and back. */\nexport function nextDoseStatus(\n  doseStatus: Readonly<Record<string, DoseStatus>>,\n  key: string\n): Record<string, DoseStatus> {\n  const current = Object.hasOwn(doseStatus, key) ? doseStatus[key] : undefined\n  const rest = Object.entries(doseStatus).filter(([entry]) => entry !== key)\n  // fromEntries defines keys such as \"__proto__\" as data, unlike assignment.\n  return Object.fromEntries(\n    current === \"skipped\"\n      ? rest\n      : [...rest, [key, current === \"taken\" ? \"skipped\" : \"taken\"]]\n  )\n}\n\nfunction names(ids: readonly string[], index: Map<string, Medication>) {\n  const list = ids.map((id) => index.get(id)?.name ?? id)\n  return list.length > 1\n    ? `${list.slice(0, -1).join(\", \")} and ${list.at(-1)}`\n    : (list[0] ?? \"\")\n}\n\nfunction hours(value: number) {\n  return `${value} ${value === 1 ? \"hour\" : \"hours\"}`\n}\n\nfunction Chevron({ direction }: { direction: \"left\" | \"right\" }) {\n  return (\n    <svg\n      aria-hidden=\"true\"\n      viewBox=\"0 0 16 16\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"1.5\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n    >\n      <path d={direction === \"left\" ? \"M10 3 5 8l5 5\" : \"m6 3 5 5-5 5\"} />\n    </svg>\n  )\n}\n\nexport function MedicationSchedule({\n  medications,\n  interactions = [],\n  date,\n  onDateChange,\n  doseStatus = {},\n  onDoseStatusChange,\n  now,\n  locale = \"en-US\",\n  label = \"Medication schedule\",\n  disclaimer = \"Interaction warnings come only from the data this schedule is given. This is not medical advice.\",\n  className,\n}: MedicationScheduleProps) {\n  const index = medicationIndex(medications)\n  const doses = dosesOn(medications, date)\n  const active = activeInteractions(doses, interactions)\n  const conflicts = doseConflicts(doses, interactions)\n  const nowMinutes = now === undefined ? null : parseTime(now)\n  const heading = React.useId()\n  const title = new Intl.DateTimeFormat(locale, {\n    weekday: \"long\",\n    month: \"long\",\n    day: \"numeric\",\n    year: \"numeric\",\n    timeZone: \"UTC\",\n  }).format(parseDate(date))\n  const scheduled = medications.filter(({ id }) =>\n    doses.some(({ medication }) => medication.id === id)\n  )\n  // Every other dose each dose is too close to, for its warning note.\n  const conflictsFor = new Map<string, ScheduledDose[]>()\n  for (const { doses: pair } of conflicts)\n    for (const [dose, other] of [pair, [pair[1], pair[0]]] as const)\n      conflictsFor.set(dose.key, [...(conflictsFor.get(dose.key) ?? []), other])\n\n  return (\n    <Card\n      role=\"region\"\n      aria-labelledby={heading}\n      className={cn(\"w-full\", className)}\n    >\n      <CardHeader>\n        <CardDescription>{label}</CardDescription>\n        <CardTitle id={heading}>\n          <span className=\"sr-only\">{label}, </span>\n          {title}\n        </CardTitle>\n        {onDateChange && (\n          <CardAction className=\"flex gap-1\">\n            <Button\n              variant=\"outline\"\n              size=\"icon-sm\"\n              aria-label=\"Previous day\"\n              onClick={() => onDateChange(addDays(date, -1))}\n            >\n              <Chevron direction=\"left\" />\n            </Button>\n            <Button\n              variant=\"outline\"\n              size=\"icon-sm\"\n              aria-label=\"Next day\"\n              onClick={() => onDateChange(addDays(date, 1))}\n            >\n              <Chevron direction=\"right\" />\n            </Button>\n          </CardAction>\n        )}\n      </CardHeader>\n\n      <CardContent className=\"flex flex-col gap-5\">\n        {active.length > 0 && (\n          <div className=\"flex flex-col gap-2\">\n            {active.map((interaction) => {\n              const close = conflicts.filter(\n                (conflict) => conflict.interaction === interaction\n              )\n              return (\n                // shadcn's destructive variant tints the description below\n                // AA contrast, so major warnings mark the border and title.\n                <Alert\n                  key={interaction.id}\n                  data-severity={interaction.severity}\n                  className={cn(\n                    interaction.severity === \"major\" && \"border-destructive\"\n                  )}\n                >\n                  <AlertTitle\n                    className={cn(\n                      interaction.severity === \"major\" && \"text-destructive\"\n                    )}\n                  >\n                    {severityLabel[interaction.severity]} interaction:{\" \"}\n                    {names(interaction.medicationIds, index)}\n                  </AlertTitle>\n                  <AlertDescription>\n                    {interaction.summary}\n                    {interaction.separationHours\n                      ? ` Keep doses at least ${hours(interaction.separationHours)} apart.`\n                      : \"\"}\n                    {close.length > 0 &&\n                      ` ${close\n                        .map(\n                          ({ doses: [a, b] }) =>\n                            `${a.medication.name} at ${a.time} and ${b.medication.name} at ${b.time}`\n                        )\n                        .join(\n                          \"; \"\n                        )} ${close.length === 1 ? \"is\" : \"are\"} closer than that.`}\n                  </AlertDescription>\n                </Alert>\n              )\n            })}\n          </div>\n        )}\n\n        {doses.length === 0 ? (\n          <Empty role=\"status\">\n            <EmptyHeader>\n              <EmptyDescription>No doses on this day.</EmptyDescription>\n            </EmptyHeader>\n          </Empty>\n        ) : (\n          <>\n            {/* A visual summary; the dose list below carries the same facts. */}\n            <div aria-hidden=\"true\" className=\"flex flex-col gap-2\">\n              <div className=\"relative h-4 text-[0.7rem] text-muted-foreground sm:ml-[9.25rem]\">\n                {[0, 6, 12, 18, 24].map((hour) => (\n                  <span\n                    key={hour}\n                    className={cn(\n                      \"absolute top-0\",\n                      hour === 0\n                        ? \"left-0\"\n                        : hour === 24\n                          ? \"right-0\"\n                          : \"-translate-x-1/2\"\n                    )}\n                    style={\n                      hour % 24 ? { left: `${(hour / 24) * 100}%` } : undefined\n                    }\n                  >\n                    {String(hour).padStart(2, \"0\")}:00\n                  </span>\n                ))}\n              </div>\n              {scheduled.map((medication) => (\n                <div\n                  key={medication.id}\n                  className=\"grid gap-1 sm:grid-cols-[8.5rem_minmax(0,1fr)] sm:items-center sm:gap-3\"\n                >\n                  <span className=\"truncate text-xs font-medium\">\n                    {medication.name}\n                  </span>\n                  <div className=\"relative h-7 rounded-md bg-muted/50\">\n                    {[25, 50, 75].map((left) => (\n                      <span\n                        key={left}\n                        className=\"absolute inset-y-0 w-px bg-border\"\n                        style={{ left: `${left}%` }}\n                      />\n                    ))}\n                    {nowMinutes !== null && (\n                      <span\n                        data-now=\"\"\n                        className=\"absolute -inset-y-1 w-0.5 rounded-full bg-foreground\"\n                        style={{ left: `${(nowMinutes / day) * 100}%` }}\n                      />\n                    )}\n                    {doses\n                      .filter((dose) => dose.medication === medication)\n                      .map((dose) => {\n                        const status = Object.hasOwn(doseStatus, dose.key)\n                          ? doseStatus[dose.key]\n                          : undefined\n                        return (\n                          <span\n                            key={dose.key}\n                            data-dose={dose.key}\n                            data-status={status ?? \"todo\"}\n                            className={cn(\n                              \"absolute top-1/2 size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-background\",\n                              status === \"skipped\" && \"opacity-40\",\n                              conflictsFor.has(dose.key) &&\n                                \"ring-2 ring-destructive\"\n                            )}\n                            style={{\n                              left: `${(dose.minutes / day) * 100}%`,\n                              background:\n                                status === \"taken\"\n                                  ? \"var(--color-muted-foreground)\"\n                                  : (medication.color ?? \"currentColor\"),\n                            }}\n                          />\n                        )\n                      })}\n                  </div>\n                </div>\n              ))}\n            </div>\n\n            <ol aria-label=\"Doses\" className=\"flex flex-col divide-y\">\n              {doses.map((dose) => {\n                const status = Object.hasOwn(doseStatus, dose.key)\n                  ? doseStatus[dose.key]\n                  : undefined\n                const text = statusLabel[status ?? \"todo\"]\n                const close = conflictsFor.get(dose.key)\n                const { medication } = dose\n                return (\n                  <li\n                    key={dose.key}\n                    className=\"flex items-center gap-3 py-2 first:pt-0 last:pb-0\"\n                  >\n                    <time className=\"w-11 shrink-0 text-sm tabular-nums\">\n                      {dose.time}\n                    </time>\n                    <span\n                      aria-hidden=\"true\"\n                      className=\"size-2.5 shrink-0 rounded-full\"\n                      style={{ background: medication.color ?? \"currentColor\" }}\n                    />\n                    <div className=\"min-w-0 flex-1\">\n                      <p\n                        className={cn(\n                          \"text-sm font-medium\",\n                          status === \"skipped\" &&\n                            \"text-muted-foreground line-through\"\n                        )}\n                      >\n                        {medication.name}\n                        {medication.dose && (\n                          <span className=\"font-normal text-muted-foreground\">\n                            {\" \"}\n                            {medication.dose}\n                          </span>\n                        )}\n                      </p>\n                      {medication.instructions && (\n                        <p className=\"text-xs text-muted-foreground\">\n                          {medication.instructions}\n                        </p>\n                      )}\n                      {close && (\n                        <p className=\"text-xs text-destructive\">\n                          Within the separation window of{\" \"}\n                          {close\n                            .map(\n                              (other) =>\n                                `${other.medication.name} at ${other.time}`\n                            )\n                            .join(\", \")}\n                        </p>\n                      )}\n                    </div>\n                    {onDoseStatusChange ? (\n                      <Button\n                        variant={status ? \"secondary\" : \"outline\"}\n                        size=\"sm\"\n                        className=\"w-20\"\n                        aria-label={`${medication.name} at ${dose.time}: ${text}`}\n                        onClick={() =>\n                          onDoseStatusChange(\n                            nextDoseStatus(doseStatus, dose.key)\n                          )\n                        }\n                      >\n                        {text}\n                      </Button>\n                    ) : (\n                      <span className=\"text-xs text-muted-foreground\">\n                        {text}\n                      </span>\n                    )}\n                  </li>\n                )\n              })}\n            </ol>\n          </>\n        )}\n      </CardContent>\n\n      {disclaimer !== null && (\n        <CardFooter>\n          <p className=\"text-xs text-muted-foreground\">{disclaimer}</p>\n        </CardFooter>\n      )}\n    </Card>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "Medication schedule docs: https://organui.com/docs/components/medication-schedule",
  "categories": [
    "clinical"
  ],
  "type": "registry:ui"
}