Frontend
Packing a day: laying out overlapping bookings without a calendar library
Our day-view preview drew every booking at full width, so overlapping appointments rendered as text over text. The fix was the classic interval packing pass: assign each booking the lowest column whose previous booking has ended, then size each overlap cluster by its own peak concurrency. Extracting it as a pure function is what finally made it testable.
The admin calendar has a small companion view. When you are about to add or move a booking, a slim preview shows the staff member's day so you can see where the new appointment lands. The first version drew every booking at full width. It looked right for weeks, because most days in a salon are a tidy vertical stack of back-to-back appointments. Then a real day came along where two appointments overlapped, and the preview painted both cards in the same rectangle. Text drew over text, and the one screen meant to prevent a conflict was the one screen that hid it.
The short version
- Sort bookings by start time, walk them once, and give each one the lowest column whose previous booking has already ended.
- A booking that starts exactly when another ends shares that column. Touching is not overlapping.
- Group bookings into clusters of transitive overlap, and divide each cluster's width by the number of columns that cluster alone needed.
- A proposed booking, the ghost the user is about to confirm, must join the packing like a real one, or the preview lies about conflicts.
- Keep the whole thing a pure function from intervals to placements, and the layout becomes unit-testable without a browser.
Why full-width cards lie
The naive renderer maps minutes to pixels. Top is the start time, height is the duration, width is 100 percent. That is a one-dimensional model of a two-dimensional problem. Time gives you the vertical axis for free, but the horizontal axis only exists when bookings overlap, and the renderer had no concept of it. We did not want to pull in a calendar library for one preview pane, and it turns out you do not need one. The layout that the big calendar apps use for overlapping events is a small, well-understood algorithm you can write in about forty lines.
Greedy column assignment
Sort the bookings by start time, breaking ties so the longer one comes first, then walk the list once. Keep an array of column end times, where entry i holds the end of the most recent booking placed in column i. For each booking, find the lowest-numbered column whose recorded end is at or before the booking's start. If one exists, place the booking there and update that column's end. If none exists, every column is still busy at that moment, so open a new one.
The comparison direction carries a subtlety that matters more in a booking product than almost anywhere else. A booking that starts exactly when another ends must share its column, because a 10:00 blowout after a 9:00 cut is the normal shape of a salon day. So the test for a free column is end at-or-before start rather than strictly before. Get that one character wrong and every busy day sprawls sideways into phantom columns.
Clusters set the width
Column assignment is only half the layout. The other half is deciding how wide each card is, and the tempting wrong answer is to divide by the busiest moment of the whole day. Do that and a lone 8:00 appointment renders at half width because 14:00 happened to be hectic. The right divisor is local. Group the bookings into clusters, where a new cluster starts whenever a booking begins at or after everything before it has ended. Within a cluster, every card's width divisor is the cluster's own column count, which is exactly the peak concurrency of that stretch of the day. Quiet parts of the morning stay full width no matter what the afternoon looks like.
export interface Interval {
id: string;
startMin: number; // minutes from midnight
endMin: number; // exclusive
ghost?: boolean; // a proposed booking joins the packing like a real one
}
export interface Placed extends Interval {
column: number; // 0-based lane within its cluster
columns: number; // how many lanes the cluster needed
}
export function packDay(items: Interval[]): Placed[] {
const sorted = [...items].sort(
(a, b) => a.startMin - b.startMin || b.endMin - a.endMin,
);
const out: Placed[] = [];
let cluster: Placed[] = [];
let colEnds: number[] = []; // end time of the last item in each column
let clusterEnd = 0;
const close = () => {
for (const p of cluster) p.columns = colEnds.length;
cluster = [];
colEnds = [];
};
for (const it of sorted) {
// Everything so far has ended (or ends exactly now): new cluster.
if (cluster.length > 0 && it.startMin >= clusterEnd) close();
// Lowest column whose last item has ended. `<=` is the touching
// rule: a booking starting exactly at another's end shares its lane.
let col = colEnds.findIndex((end) => end <= it.startMin);
if (col === -1) {
col = colEnds.length;
colEnds.push(it.endMin);
} else {
colEnds[col] = it.endMin;
}
const placed: Placed = { ...it, column: col, columns: 1 };
cluster.push(placed);
out.push(placed);
clusterEnd = Math.max(clusterEnd, it.endMin);
}
close();
return out;
}Rendering is then one multiplication per card. Nothing downstream knows the algorithm exists.
const laneW = 100 / p.columns; // percent of the day column
const style = {
top: p.startMin * PX_PER_MIN,
height: (p.endMin - p.startMin) * PX_PER_MIN,
left: `${p.column * laneW}%`,
width: `calc(${laneW}% - 2px)`, // small gutter between lanes
};On a concrete morning the output looks like this:
col 0 col 1
9:00 +------------+
| Cut 60m |
9:30 | | +-------------+
| | | Colour 90m |
10:00 +------------+ | |
+------------+ | |
| Blowout | | |
10:30 +------------+ | |
11:00 +-------------+
11:00 +---------------------------------+
| Trim 45m (new cluster, |
| full width again) |
11:45 +---------------------------------+The cut and the colour overlap, so they form one two-column cluster and each takes half the width. The blowout starts exactly as the cut ends, so the touching rule lets it reuse column 0 inside the same cluster. The trim at 11:00 starts exactly as the colour ends, which closes the cluster, and the trim gets the full width back.
The ghost must join the packing
The preview exists to answer one question. If I put the booking here, what happens? So it draws a translucent ghost card at the proposed slot. Our first instinct was to paint the ghost as an overlay after the real bookings were laid out, and that quietly reintroduces the original bug in its most damaging spot. The real bookings never shift over to make room, the ghost sits on top of whatever is already there, and the user confirms a time while looking at a smear. The fix is to push the ghost into the same input array, flagged only so it renders translucent, and pack everything together. When the ghost lands on a busy stretch, the cluster visibly squeezes to fit one more column, and that squeeze is the conflict warning. The layout stops decorating the data and starts telling the truth about it.
Repacking is cheap enough to run on every hover. The sort dominates at O(n log n), and a single day holds tens of bookings at most, so moving the ghost around the grid re-runs the whole layout with no caching and no cleverness.
A pure function earns its tests
The quiet payoff came from where the code ended up living. While the layout logic was tangled into the component, the only test was looking at the screen, and the overlap bug survived weeks of exactly that. Extracting packDay as a pure function from intervals to placements turned the geometry into table-driven unit tests that run in milliseconds, with no browser and no rendering. The cases we pinned down first were the ones that had already bitten us or nearly did:
- Two identical intervals split the width in half.
- Back-to-back bookings, where one ends exactly as the next starts, both stay full width.
- A chain where each booking overlaps only the next packs into two columns rather than three.
- A quiet morning keeps full-width cards even when the afternoon needs four columns.
- A ghost dropped into a genuinely free gap changes nothing about the cards around it.
The bug itself took a morning to fix once we named the real problem. Overlapping event layout is interval packing, and interval packing is a solved problem with decades of prior art behind it. The lesson we keep relearning is to look for the pure function hiding inside a misbehaving component and pull it out. What remains in the component is a multiplication by pixels, and that part has never had a bug.
Building on Bookatu?
Bookatu has a public REST API and webhooks. Have a look at the developer docs.
Developer docs