Building & Shipping

The Month View Was Recomputing Yesterday

Brett Ridenour Brett Ridenour · Published August 2026

One of the Freebo operators texted me: the monthly calendar in the ops app takes over a minute to load in production. Can I make month the default so they can see availability at a glance?

I promised the default. First I had to explain to myself why the current month took 40 seconds and next month took under one.

The partition made sense until it didn’t

Freebo’s admin calendar hits an admin-events endpoint that returns bookable slots for a date range. Slots come out of an availability engine that is expensive per day per product — flex products fan out to 12–17 sequential DB round trips before a single slot exists. To keep this survivable, the service caches slot computations by day into a availability_day_cache table, and only live-computes the “near” window — today and the next couple of days, where a fresh reservation could invalidate the cache.

The partition looks like this:

for (const day of allDays) {
  const dayDt = DateTime.fromISO(day, { zone: timezone });
  if (dayDt <= liveWindowEnd) {
    nearDays.push(day);
  } else {
    farDays.push(day);
  }
}

for (const day of nearDays) {
  const daySlots = await this.computeDay(params, productIdEntry, day);
  allSlots.push(...daySlots);
}

Read it a second time.

liveWindowEnd is roughly today + 2 days. Every past day of the month satisfies dayDt <= liveWindowEnd too. August 23rd. August 1st. February 12th, 2023. They all take the nearDays branch. They all get live-computed, serially, per product.

A current-month request on the 23rd was 23 past days plus 3 near days, times every product, times ~12–17 DB round trips at ~470ms each. 40 seconds in production. 79 seconds in staging where the account had more products. Next month’s request had zero past days and hit the cache — one second, easy.

The whole compute was already garbage. The service that assembled the response for the calendar merged reservations from a separate query anyway, and past slots were discarded the moment they came back. We were doing 40 seconds of work whose only observable effect was heat.

The partition that treated 2023 as if it was today

The month view doesn’t want slots

The redesign was the easier part. A month view doesn’t need slots at all. It needs one row per reservation, an operating window per day, and blackouts. Everything else is layout.

So I added a mode flag:

GET /admin-events?mode=bookings&start=2026-08-01&end=2026-08-31

Default mode=slots is byte-for-byte unchanged. mode=bookings skips the availability engine entirely and answers from range reads only: reservations in the same wire shape as booked slots, plus Google Calendar external events, plus day_windows (operating hours per day, union of asset config with day-of-week overrides), plus blackouts (blocking anchors plus active blackout rules).

Everything a day cell needs to draw itself. Nothing anyone would live-compute. Sub-second for any span the operator can pan through.

The fastest query is the one your UI doesn’t need.

— The rule I keep re-learning

The second bug was worse

Writing the new service, I did what you do — fanned out the independent range reads with Promise.all, destructured, moved on. Then I stopped, because six independent queries were coming back and I was only checking the error on one of them.

The reservations query had an if (resResult.error) throw. The other five did not. Two of them wore a friendly .catch(() => []). Two more didn’t destructure error at all. The Supabase client cheerfully returns { data: null, error: SomeError } in the failure path, and if you ignore the second field you get a very confident-looking empty array or a null you fall back to a default on.

Every one of those defaults was catastrophic in a way a 500 would not have been.

Timezone lookup. The location’s timezone controls every day boundary in the response. The old code did:

const zone = location?.timezone || 'UTC';

An operator on New York time whose timezone lookup failed would silently fall back to UTC. Four to five hours off. Evening reservations file to the wrong calendar day. The operator sees the reservation in tomorrow’s cell instead of tonight’s. Nothing in the logs. Nothing in Sentry. Just a quietly wrong calendar.

Blackout rules. A .catch(() => []) on the blackout query meant a failure returned no blackouts. The month grid paints days by the blackouts it received. No blackouts received, no blackouts painted. A day the operator deliberately blocked — repairs, maintenance, a family thing — renders as free. That is not a display bug. That is an invitation to double-book, with nothing in the logs to explain why.

Asset anchor intervals (the blocking anchors) had the same shape: swallowed failure, empty array, painted free.

Assets and asset availability config were less scary — you’d get blank names and missing operating windows — but a half-populated month is not a month.

The fix was six lines. Every one of those reads now throws with a labelled error:

if (resResult.error) throw new Error(`RESERVATIONS_LOOKUP_FAILED: ${resResult.error.message}`);
if (assetsResult.error) throw new Error(`ASSETS_LOOKUP_FAILED: ${assetsResult.error.message}`);
if (configResult.error) throw new Error(`ASSET_CONFIG_LOOKUP_FAILED: ${configResult.error.message}`);
if (anchorsResult.error) throw new Error(`ANCHOR_LOOKUP_FAILED: ${anchorsResult.error.message}`);
// blackout rules and GCal already surface via their own throws

Behaviour on the happy path is unchanged. Behaviour on the failure path is now a clean 500 with a searchable label. Which is exactly what I want when the operator texts me next.

Six labelled throws instead of five silent degradations

How I want to think about this class of bug

  1. Text message
    Operator: monthly view is unusably slow
    First signal. Not Sentry, not PostHog. A person.
  2. First look
    Sentry says admin-events p95 is 10.2s
    Aggregate hides the real distribution — current-month on the 23rd was 40s.
  3. Root cause
    The `near` partition includes every past day
    One-line bug. 23 past days recomputed live, then discarded.
  4. Redesign
    `mode=bookings` skips the availability engine
    Month view never needed slots. It needed reservations + windows + blackouts.
  5. Second bug
    Six range reads, one error check
    Silent `[]` on blackouts paints blocked days as free. UTC fallback on timezone shifts an operator's whole month.
  6. Fix
    Fail loudly on every lookup
    A 500 the operator can report beats a half-populated month they cannot tell from a real one.

The 40-second bug and the silent-[] bug are the same bug in different clothes. Both are “we degraded when we shouldn’t have.” The partition degraded far cache reads into near live computes and swallowed the cost. The error handling degraded query failures into empty defaults and swallowed the correctness. In both cases, no signal reached anyone until a human on the receiving end reported a symptom.

Three rules I’m keeping:

  1. The partition is not the only place you’re partitioning. Any predicate that classifies work into “fast path” and “slow path” needs to be checked against the actual distribution of inputs, not the one you had in mind when you wrote it. Past days, empty products, deleted customers — they all land somewhere.
  2. A range read that returns [] on failure is a landmine. Especially on data that affects rendering decisions. “Show nothing” and “there is nothing” render identically and mean opposite things.
  3. A 500 the operator can report beats a half-populated month they cannot tell from a real one. This one I’m putting on a sticker.

The operator got their fast month view. I got a service where every failure has a name.