One schedule, two ways to see it
A live patient-booking schedule for hospital labs, built on a headless timeline component. One data layer fed by GraphQL subscriptions, one piece of time maths, drawn as many views — so every lab sees the same truth, the moment it changes.
The problem
Lab staff set up time slots in each room, then book patients into those slots. Two kinds of lab used the same bookings — but wanted to see their day in completely different ways.
The easy fix was two separate calendars. But then everything exists twice — the bookings, the rules, the live updates — and sooner or later the two copies stop matching.
So I kept one set of booking data underneath, and gave each lab its own view on top. A view only decides how the day looks.
The flow
What happens in between:
- Someone books a patientDrag on the schedule, or fill in the booking form.
- The database saves itPostgres, behind Hasura.
- Hasura pushes the changeOver an open GraphQL subscription — no polling, no refresh.
- The data layer updatesOne shared list of slots and bookings. No layout lives here.
- Respiratory viewdraws it top to bottomSleep viewdraws it left to right
- Every screen updates at onceno refresh, no two versions of the truth
How I did it
- 1
Live data, in one place
The schedule opens a GraphQL subscription to Hasura. Think of it as a phone line that stays open: when a booking changes, Hasura sends the new rows down the line. They land in one shared place that knows nothing about how they'll be drawn.
- 2
Views are settings, not components
Each view is a small config — which way time runs, how big one step is, what the rows are. The views live in a registry, so adding a new kind of lab means adding an entry, not building a new calendar.
- 3
One piece of time maths
Every view uses the same two functions: time → position (to draw a booking) and position → time (when you drag). Vertical views use them up and down, horizontal views use them sideways.
- 4
Bookings that don't fit
If a booking starts before or ends after the visible window, the timeline only sets a clip-start or clip-end flag. Each view decides what that means: here CSS turns it into a cut edge, so it's obvious the booking keeps going; a print view could show a small "+15m" instead.
A headless timeline
The first cut was one calendar component that owned both the time maths and the pixels. That's fine until a second view shows up — then you're re-deriving the same start, end and snap logic, and slowly letting the two drift apart.
So the timeline became headless: it owns where things sit in time and how they behave — position, dragging, snapping, clipping — but not what they look like. On the outside it's a set of small pieces you put together, not one big widget with a prop for every special case.
<Timeline layout="vertical" start="08:00" end="18:00" step={15}>
<Timeline.Axis />
<Timeline.Lane id="lab-1">
<Timeline.Header>Lab 1</Timeline.Header>
<Timeline.Content>
{bookings.map((b) => (
<Timeline.Item key={b.id} start={b.start} end={b.end}>
{b.patient}
</Timeline.Item>
))}
</Timeline.Content>
</Timeline.Lane>
<Timeline.Lane id="lab-2">…</Timeline.Lane>
<Timeline.NowLine />
</Timeline>Switching to the sleep lab's view is one prop:
// The sleep lab: same children, one prop changes.
<Timeline layout="horizontal" start="18:00" end="08:00" step={30}>
…
</Timeline><Timeline>owns the time window and step, shares them through context<Timeline.Axis>hour lines and labels<Timeline.Lane>one room — a column or a row<Timeline.Header>the lane's label, sticky while you scroll<Timeline.Content>the area bookings are placed in<Timeline.Item>one booking, positioned from its start and end<Timeline.NowLine>a line at the current time
Timeline.Item stays unopinionated about markup: it hands its child a position and nothing else, so a view can render a plain block, a booking card, or (through an asChild-style prop) an element it already has. Dragging uses Pointer Events with pointer capture, so a drag keeps tracking even when the cursor leaves the card mid-gesture.
Drag a block (or focus it and use ↑ / ↓) — it snaps to 15-minute increments.
A playable clean-room rebuild of the idea — same maths, none of the original code. Toggle to Roster to see the same axis run sideways across beds and staff, the shape the sleep lab needs.
Inside
1. Sharing the maths through context
The parent builds the time maths once and puts it in a React context. Every child reads it with useTimeline(), so no piece needs props passed down by hand.
const TimelineContext = createContext<TimelineValue | null>(null);
export function useTimeline() {
const ctx = useContext(TimelineContext);
if (!ctx) throw new Error("Timeline.* must be used inside <Timeline>");
return ctx;
}
export function Timeline({ layout, start, end, step, children }) {
// Build the time maths once; every child reads it from context.
const axis = useMemo(
() => createAxis({ start, end, step }),
[start, end, step],
);
return (
<TimelineContext.Provider value={{ layout, ...axis }}>
<div data-layout={layout}>{children}</div>
</TimelineContext.Provider>
);
}2. Time ↔ position, with steps
step is how fine the grid is — 15 minutes for the respiratory lab, 30 for the sleep lab. Drawing goes time → pixels. Dragging goes pixels → time, rounded to the nearest step, so a slot always snaps to a clean time.
function createAxis({ start, end, step, pxPerStep = 24 }) {
const pxPerMinute = pxPerStep / step;
return {
// time → position: where to draw a booking
toPosition: (time) => minutesBetween(start, time) * pxPerMinute,
// position → time: where a drag landed, snapped to the step
toTime: (px) => {
const minutes = px / pxPerMinute;
const snapped = Math.round(minutes / step) * step;
return addMinutes(start, snapped);
},
// hour lines / tick marks for <Timeline.Axis />
ticks: () => range(start, end, 60),
};
}3. One item, any direction
Layout only matters in one place: whether a booking setstop/height or left/width. The clip flags are set here too.
function TimelineItem({ start, end, children }) {
const { layout, toPosition, start: viewStart, end: viewEnd } = useTimeline();
const from = toPosition(max(start, viewStart));
const size = toPosition(min(end, viewEnd)) - from;
// The only place layout matters: which CSS properties to set.
const style = layout === "vertical"
? { top: from, height: size }
: { left: from, width: size };
return (
<div
style={style}
data-clip-start={start < viewStart || undefined}
data-clip-end={end > viewEnd || undefined}
>
{children}
</div>
);
}4. Repeating slots
Slots repeat — every Monday at 9, every weekday at 2. Instead of saving every single slot, a template saves a repeat rule (RFC 5545, the same format calendars use). The timeline only asks for the slots inside the window on screen, and they're worked out in the lab's own local time, so 9am stays 9am when the clocks change.
// A slot template stores a rule, not every slot:
// "FREQ=WEEKLY;BYDAY=MO,WE;BYHOUR=9" → every Mon & Wed at 9am
function slotsInWindow(template, from, to) {
return rrulestr(template.rrule)
.between(from, to, true)
.map((start) => ({
start,
end: addMinutes(start, template.durationMinutes),
}));
}5. Feeding it live data
The subscription only asks for the window being shown. When Hasura pushes a change, the data updates and every timeline reading it re-renders.
const BOOKINGS = gql`
subscription Bookings($from: timestamptz!, $to: timestamptz!) {
bookings(where: { start: { _gte: $from, _lt: $to } }) {
id start end patient room_id
}
}
`;
function LabSchedule({ view }) {
const { data } = useSubscription(BOOKINGS, {
variables: windowFor(view),
});
// Hasura pushes every change; React re-renders the timeline.
return <Timeline {...view}>{renderLanes(data, view)}</Timeline>;
}Simplified sketches for this page — names and details changed, none of the original code.
Why it was worth it
- 2 calendars
- 2 copies of the data
- 2 sets of rules
- → they drift apart
- 1 live data layer
- 1 set of rules
- as many views as needed
- → always in sync
What I'd do differently
I'd make the timeline headless from day one. The instinct, when a component starts handling drag, layout and rendering at once, is to add a prop for the next special case — a variant, anisCompact flag — and each one makes it harder to reason about. Splitting the maths out first, and leaving the markup to each view, turned out to be less work, not more, once the second lab showed up.