A day's dose slots by time with taken, due, upcoming, and missed states; checking a due dose draws a check and settles the row to a muted taken look.
npx shadcn@latest add @paragon/medication-schedule"use client";
import * as React from "react";
import { Clock, Pill } from "lucide-react";
import { cn } from "@/lib/utils";
export type DoseState = "taken" | "due" | "upcoming" | "missed";
export interface DoseSlot {
id: string;
/** Display time, e.g. "8:00 AM". */
time: string;
medication: string;
/** Dosage detail, e.g. "500 mg · 1 tablet". */
detail?: string;
state: DoseState;
}
export interface MedicationScheduleProps
extends Omit<React.ComponentProps<"ul">, "onChange"> {
slots: DoseSlot[];
/** Fires with the dose id when a due dose is checked off. */
onTake?: (id: string) => void;
/** Disables the check-draw and settle motion. */
static?: boolean;
}
const stateMeta: Record<
DoseState,
{ ring: string; label: string; labelClass: string }
> = {
taken: {
ring: "border-success bg-success text-success-foreground",
label: "Taken",
labelClass: "text-success",
},
due: {
ring: "border-primary bg-transparent text-transparent hover:bg-accent",
label: "Due now",
labelClass: "text-foreground",
},
upcoming: {
ring: "border-input bg-transparent text-transparent",
label: "Upcoming",
labelClass: "text-muted-foreground",
},
missed: {
ring: "border-destructive/40 bg-destructive/10 text-destructive",
label: "Missed",
labelClass: "text-destructive",
},
};
/**
* A day's dose slots as a vertical list, each with a taken / due / upcoming /
* missed state. Checking a due dose draws the check (pathLength +
* stroke-dashoffset transition) and settles the row to a muted, taken look —
* all CSS transitions, so re-toggling retargets cleanly. Reduced motion keeps
* the color settle and drops the draw. Uncontrolled by default; pass `state`
* and `onTake` to control it.
*/
export function MedicationSchedule({
slots,
onTake,
static: isStatic = false,
className,
...props
}: MedicationScheduleProps) {
const [taken, setTaken] = React.useState<Set<string>>(() => new Set());
const effectiveState = (slot: DoseSlot): DoseState =>
slot.state === "due" && taken.has(slot.id) ? "taken" : slot.state;
return (
<ul
data-slot="medication-schedule"
className={cn("flex w-full flex-col", className)}
{...props}
>
{slots.map((slot, i) => {
const state = effectiveState(slot);
const meta = stateMeta[state];
const isTaken = state === "taken";
const interactive = slot.state === "due";
const last = i === slots.length - 1;
const dot = (
<span
aria-hidden
className={cn(
"relative z-10 flex size-6 shrink-0 items-center justify-center rounded-full border",
meta.ring,
!isStatic &&
"transition-[background-color,border-color] duration-(--duration-quick) ease-out",
)}
>
{(isTaken || state === "missed") && (
<svg viewBox="0 0 14 14" fill="none" className="size-3.5">
{isTaken ? (
<path
d="M3 7.4 5.6 10 11 4"
pathLength={1}
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
strokeDasharray={1}
style={{
strokeDashoffset: isTaken ? 0 : 1,
transition: isStatic
? undefined
: "stroke-dashoffset 220ms var(--ease-out) 60ms",
}}
/>
) : (
<path
d="M4 4 10 10 M10 4 4 10"
stroke="currentColor"
strokeWidth={1.6}
strokeLinecap="round"
/>
)}
</svg>
)}
</span>
);
const body = (
<span className="flex min-w-0 flex-1 flex-col gap-0.5 text-left">
<span className="flex items-center gap-1.5">
<Pill
aria-hidden
className={cn(
"size-3.5 shrink-0",
isTaken ? "text-muted-foreground" : "text-muted-foreground",
)}
/>
<span
className={cn(
"truncate text-sm font-medium",
isTaken
? "text-muted-foreground line-through decoration-muted-foreground/40"
: "text-foreground",
!isStatic && "transition-colors duration-(--duration-base)",
)}
>
{slot.medication}
</span>
</span>
{slot.detail && (
<span className="pl-5 text-xs text-muted-foreground">
{slot.detail}
</span>
)}
</span>
);
return (
<li key={slot.id} className="relative flex gap-3 pb-1">
{/* connective rail — 76px = time col (44) + gap (12) + row pad (8) + half dot (12) */}
{!last && (
<span
aria-hidden
className="absolute top-7 bottom-0 left-[75.5px] w-px bg-border"
/>
)}
<span className="flex w-11 shrink-0 items-center justify-end gap-1 pt-1 text-[11px] text-muted-foreground tabular-nums">
{state === "due" && !isTaken && (
<Clock aria-hidden className="size-3 text-primary" />
)}
<span className="whitespace-nowrap">{slot.time}</span>
</span>
{interactive ? (
<button
type="button"
aria-pressed={isTaken}
onClick={() => {
setTaken((prev) => {
const next = new Set(prev);
if (next.has(slot.id)) next.delete(slot.id);
else next.add(slot.id);
return next;
});
if (!taken.has(slot.id)) onTake?.(slot.id);
}}
className="flex flex-1 items-start gap-3 rounded-lg px-2 py-2 text-left transition-colors duration-(--duration-fast) hover:bg-accent/60"
>
{dot}
{body}
<span
className={cn(
"shrink-0 self-center text-[11px] font-medium",
meta.labelClass,
)}
>
{isTaken ? stateMeta.taken.label : meta.label}
</span>
</button>
) : (
<div className="flex flex-1 items-start gap-3 px-2 py-2">
{dot}
{body}
<span
className={cn(
"shrink-0 self-center text-[11px] font-medium",
meta.labelClass,
)}
>
{meta.label}
</span>
</div>
)}
</li>
);
})}
</ul>
);
}