A coworking member's day is rarely one booking. It's a desk from nine, a meeting room for the 11 o'clock, and somewhere quiet for a call at two. Most booking widgets sell those one at a time: three flows, three checkouts, three confirmation emails.
Gable is the open-source Next.js template I built to sell the day instead. It's a coworking site for a made-up network of six buildings in Dublin, Cork and Galway, with 77 bookable spaces, and the whole design is drawn like an architect's drawing set. This post is about the parts I'd copy into any multi-resource booking site: how it reads availability, why the browser does the maths, and how it suggests what your day is missing without calling an API.
Live demo · GitHub (MIT) · the longer write-up
The cart is a day sheet
The planner at /book is a search, not a wizard. You pick a house, a day, how long, a start time and how many people, and you get the spaces that fit, each with a day bar where booked time is hatched like a cut wall in a drawing.
Whatever you add lands on the day sheet on the right, which is the cart drawn as a timeline from 08:00 to 20:00. Each booking is held for 15 minutes while you plan the rest, so the room you picked first is still yours when you go back for a desk. Checkout happens once, for everything.
The planner state lives in the URL (/book?house=dock-house&kind=room&date=…&start=10:00&length=60&people=6), so a link from the home page or a space page lands on the exact search.
One availability read per house, not one per space per day
Every space in Gable is its own resource: a room, a booth, a studio desk. The booking API answers availability per space. The naive version of this page asks for every space, for the selected day, every time you change the day, and with 77 spaces across six houses and a 21-day window, that gets expensive fast.
Gable flips it. The server asks each space once for its merged free ranges across the whole booking horizon (no date parameter), caches each space for 60 seconds, and sends the browser one response per house. This is lib/availability-server.ts, trimmed:
async function readRanges(spaceId: string, houseId: string, tz: string): Promise<Packed[]> {
for (let attempt = 0; ; attempt++) {
try {
// throwOnError is required: without it the SDK resolves a 429 as an empty result.
const { data } = await ProductService.getCurrentAvailabilitiesMerged({
path: { productId: spaceId },
query: { locationId: houseId, timezone: tz },
throwOnError: true,
});
const until = nowMin() + (WINDOW_DAYS + 1) * 1440;
return mergePacked((data ?? []).map(pack)).filter(([s, len]) => len > 0 && s < until);
} catch (err) {
const status = err instanceof OpencalsApiError ? err.status : 0;
const retriable = status === 429 || status === 503 || status === 0;
if (!retriable || attempt >= MAX_RETRIES) throw err;
await sleep(400 * 2 ** attempt + Math.floor(Math.random() * 250));
}
}
}
function cachedRanges(spaceId: string, houseId: string, tz: string) {
return unstable_cache(() => readRanges(spaceId, houseId, tz), ['space-ranges-v1', spaceId, houseId, tz], {
revalidate: 60,
tags: ['availability', `space:${spaceId}`, `house:${houseId}`],
})();
}
Three details in there cost me time:
-
That
throwOnErrorcomment is real. Without it, a rate-limited request resolves as an empty list, and an empty list looks exactly like "fully booked". You'd show a house as full because you asked too fast. -
The reads run three at a time, with exponential backoff and jitter on 429 and 503. A space that still fails isn't cached; it goes into a
failedlist in the response, and the client asks again for just that space a moment later. -
The cache tags are what keep it honest. A booking drops the
space:<id>tag for that space; a cancel, a move or a cart removal drops the house. So the 60-second cache never shows a slot as free after someone took it through this site.
The browser does the maths
Each free range is packed into a tuple, [start, length, taken, capacity], in UTC epoch minutes. For a meeting room taken is 0 and capacity is 1. For the open-plan flex floor, which is one product with 40 seats, taken is the peak number of seats already booked over that span.
export type Packed = [start: number, length: number, taken: number, capacity: number];
/** Seats still free for the whole of [start, start + minutes] (0 when it doesn't fit). */
export function seatsLeft(ranges: Packed[] | undefined, start: number, minutes: number): number {
const r = covering(ranges, start, minutes);
return r ? Math.max(0, r[3] - r[2]) : 0;
}
export function fits(ranges: Packed[] | undefined, start: number, minutes: number, seats = 1): boolean {
return seatsLeft(ranges, start, minutes) >= Math.max(1, seats);
}
Everything on the planner comes from those few functions: which spaces fit, "Free at 10:00", "Next free 14:30", "23 seats free", the start times, the day strip. Changing the day, the length, the start or the number of people never calls the API.
Two small things worth knowing if you do this yourself. The API can split one free range at a one-second seam, so mergePacked joins spans that touch. And because the merged range reports the peak seats taken, "seats left" is conservative: it may say 22 when 23 are free for part of the span, never the other way round.
"Fits in your day" runs without a single API call
Under the day sheet there's a short list of suggestions, and it's my favourite part of the template:
- a desk around your meetings, on the same floor,
- a phone booth in the half hour between two meetings,
- lunch or coffee for the number of people in a room,
- a bigger room that's free at the same time, when your guest list is close to the room's seats,
- parking at the houses that have it.
All of it is lib/upsell.ts, computed from what's already on the sheet and the ranges the browser already holds. The booth one is the whole idea in eight lines:
// 3. A booth in a gap
const booths = houseSpaces.filter((s) => s.kind === 'booth');
for (let i = 0; i < today.length - 1 && booths.length; i++) {
const gapStart = today[i]!.end;
const gapEnd = today[i + 1]!.start;
if (gapEnd - gapStart < 30) continue;
const booth = booths.find((s) => fits(ranges[s.id], gapStart, 30));
if (booth) {
out.push({ key: `booth:${gapStart}`, type: 'add', space: booth, start: gapStart, minutes: 30,
price: priceFor(booth, 30), title: 'A booth between meetings',
note: `${booth.name} is free for that half hour` });
break;
}
}
Every suggestion is checked with fits() before it's shown, so it never offers a booth that's taken.
Same key setup as the other templates
Gable calls the API from its own server, so it uses a private storefront key (spk_) and forwards each visitor's IP, which means every visitor gets their own rate limit instead of all of them sharing your server's:
// lib/opencals.ts
import { setupOpencals } from '@opencals/storefront-sdk';
import { nextBuyerIp } from '@opencals/storefront-sdk/next';
setupOpencals({
baseUrl: process.env.OPENCALS_API_URL ?? 'https://api.opencals.com',
apiKey: process.env.OPENCALS_API_KEY, // spk_…
buyerIp: nextBuyerIp,
});
The confirmation is a drawing of the building
After checkout, /thank-you draws a section through the house, with each booked space on its floor and a dashed route from the front door, next to a title block with the date, the bookings, the total and how you're paying. It prints as a one-page day sheet with the site chrome hidden.
The account opens on a Week plan rather than a list, because coworking members book in patterns: Monday and Thursday at a desk, the same room every Tuesday. Desks are hatched, rooms are solid, and each upcoming booking has Change time, Stay longer, Invite and Cancel.
Where it stops
So you don't find out after you've forked it:
- No monthly memberships or credit allowances. Gable is pay-as-you-go. If your coworking space runs on "10 room hours included" plans, you need a membership system alongside it. Memberships are in beta on the Opencals side.
- No doors, Wi-Fi or printers. It collects a meeting name "for the door screen" at checkout, and that's as far as it goes into the building.
- No webhooks yet. Booking events reach other tools through Zapier.
- The engine isn't open source. The template, the SDK and the agent skills are MIT. Availability, holds, payments and accounts run on Opencals, which is a hosted service and the thing I build. The free development store has every feature in test mode, but it can't take real payments.
Running your own copy
- Sign up at app.opencals.com, create a Dev Store in Ireland (the store's country sets the currency) and choose the Gable (Coworking) dataset. It seeds the six houses, 77 spaces, 16 extras, three discounts and the checkout questions.
- Create a private storefront key (
spk_) in the dashboard. - Deploy with the Vercel button in the README, setting
OPENCALS_API_KEYandAUTH_SECRET.
To check the screens after booking without making a booking, run it locally and open /preview, which renders them from fixtures built from your catalogue.
The previous post in this series was the Haar salon template rebuild. If you build something on Gable, or think the one-read-per-house approach breaks somewhere I haven't hit yet, I'd like to hear it in the comments.




Top comments (0)