← Back
Clinora
Clinical lab platform

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.

ReactTypeScriptGraphQLHasura subscriptionsHeadless componentsPointer events

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.

Same bookings, different picture. The cards don't change, only where the view puts them.

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

Booked at the front desk, on the lab's screen a moment later. No one pressed refresh.

What happens in between:

  1. Someone books a patient
    Drag on the schedule, or fill in the booking form.
  2. The database saves it
    Postgres, behind Hasura.
  3. Hasura pushes the change
    Over an open GraphQL subscription — no polling, no refresh.
  4. The data layer updates
    One shared list of slots and bookings. No layout lives here.
  5. Respiratory view
    draws it top to bottom
    Sleep view
    draws it left to right
  6. Every screen updates at once
    no refresh, no two versions of the truth

How I did it

  1. 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. 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. 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. 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.

Drag turns position back into time, snapped to the step. The now-line moves on its own, and a booking that runs past the window gets a cut edge.

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.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.

12:00am
1:00am
2:00am
3:00am
4:00am
5:00am
6:00am
7:00am
8:00am
9:00am
10:00am
11:00am
12:00pm
1:00pm
2:00pm
3:00pm
4:00pm
5:00pm
6:00pm
7:00pm
8:00pm
9:00pm
10:00pm
11:00pm
12:00am
Design review9:30am Drag or use arrow keys to reschedule.
Focus block10:30am Drag or use arrow keys to reschedule.
1:11:00pm Drag or use arrow keys to reschedule.
Wrap-up3:30pm Drag or use arrow keys to reschedule.

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.

One saved rule, expanded only for the dates on screen.
// 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

Without
  • 2 calendars
  • 2 copies of the data
  • 2 sets of rules
  • → they drift apart
With
  • 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.