---
title: "Weekly blocks that keep to the wall clock"
description: "Blocked time can now repeat every week, for a person, everyone or a room. We store one rule instead of a row per day, and work out only the days a reader asks about, on the business's own clock. Here is how that holds across daylight saving and a change of time zone, and five review findings, one of which made a 14-day read work out 314 zone days instead of 14."
url: "https://bookatu.com/engineering/weekly-blocks-that-keep-to-the-wall-clock"
language: "en"
section: "Engineering"
category: "Engineering"
author: "The Bookatu engineering team"
published: "2026-10-30"
updated: "2026-10-30"
tags: ["scheduling", "timezones", "availability", "postgres", "engineering"]
---

# Weekly blocks that keep to the wall clock

Blocked time can now repeat every week, for a person, everyone or a room. We store one rule instead of a row per day, and work out only the days a reader asks about, on the business's own clock. Here is how that holds across daylight saving and a change of time zone, and five review findings, one of which made a 14-day read work out 314 zone days instead of 14.

> **The short version**
>
> - Blocked time can now repeat every week, or every few weeks, for a person, for everyone or for a room, for up to two years.
> - A weekly block is one row holding a rule. Nothing is written out per day.
> - Readers work out only the days inside the window they ask about, on the series' own clock, so daylight saving never moves a block on the wall.
> - Review found five problems before release 14 shipped. Each has a test that failed on the old code.

A team meeting every Monday. A room cleaned every Friday. Until release 14, each had to be blocked one day at a time. The Blocked times form now has a Repeats weekly box: pick the weekdays, how often, and the last day.

## One rule, not a row per day

Our post Recurring appointments and the 'admin force-book' rule shows a standing series booking each visit as a real booking. That suits appointments, which each have a client and a price. Blocked time only says these hours are not for booking. Two years of a weekday block would be over five hundred rows, and every edit would touch each one. So it is one rule:

```ts
/** A weekly rule, as stored. */
export interface WeeklyRule {
  /** 0 = Sunday to 6 = Saturday, sorted, no repeats. */
  weekdays: number[];
  /** Wall-clock minutes since midnight; endMin 1440 is midnight at the end of the day. */
  startMin: number;
  endMin: number;
  /** "YYYY-MM-DD", inclusive, on the rule's zone's calendar. */
  fromDate: string;
  untilDate: string;
  /** 1 = every week, 2 = every other week, ... counted from fromDate's week. */
  everyWeeks: number;
}
```

A time_block_series row holds the rule, every 1 to 12 weeks, and the zone it was made in. A time_block_exceptions row removes one day, for This one. This and following moves the last date back.

## Only the days you ask about

The engine asks about the days it offers. The calendar asks about a week. occurrencesBetween walks that window's days and no others:

```ts
let first = dateKey(from, tz);
let last = dateKey(new Date(to.getTime() - 1), tz);
if (first < rule.fromDate) first = rule.fromDate;
if (last > rule.untilDate) last = rule.untilDate;
const out: Occurrence[] = [];
for (let key = first, i = 0; key <= last && i < 800; key = addDaysToKey(key, 1), i++) {
  if (skip.has(key) || !ruleOccursOn(rule, key)) continue;
  const span = occurrenceSpan(key, rule.startMin, rule.endMin, tz, days);
  if (!span || span.endAt <= from || span.startAt >= to) continue;
  out.push({ date: key, ...span });
}
```

An occurrence ends by midnight, so it never leaves its day, and the work is the window's length, not the series'. A test asks about one day 500 days into a two-year daily series. It counts the zone days worked out and allows five at most, all within two days of the one asked about.

## On the business's own clock

A rule says 09:00 to 10:00, not two instants. Each day becomes instants through zoneDay, the per-day clock the engine already uses for opening hours:

```ts
export function occurrenceSpan(
  key: string,
  startMin: number,
  endMin: number,
  tz: string,
  days?: ZoneDays,
): { startAt: Date; endAt: Date } | null {
  const day = zoneDayOf(key, tz, days);
  const s = day.firstAtOrAfter(startMin);
  const e = day.firstAtOrAfter(endMin);
  if (e <= s) return null;
  return { startAt: day.instantAt(s), endAt: day.instantAt(e) };
}
```

In the test, a Monday block from 09:00 to 10:00 in Auckland holds 09:00 to 10:00 either side of 4 April 2027, when the clocks go back. The instant underneath moves from 20:00 to 21:00 UTC. A wall time the clocks skip does not exist, so a block starts at the first time after the gap. On 26 September 2027, when Auckland jumps from 02:00 to 03:00, a Sunday block from 02:00 to 03:30 holds 03:00 to 03:30. A block wholly inside the gap does not happen that day. A wall time shown twice is read the first time it shows, as opening hours are.

## The null that used to mean everyone

Rooms came in the same release, through a new resource_id column. Before it, a null staffId meant everyone, and a room's block has a null staffId too. Read the old way, a room out of service would close the business. So every reader had to learn two things at once: a room's block is nobody's, and a weekly day holds what a one-off block would. That is the booking engine, the day check, the booking editor's clash warnings, a standing series' clash check, report capacity, MCP closures and calendar feeds. Tests walk each one through both.

Our mobile API lists a room's blocks only when the client asks with include=resources, because a client built before rooms would read a null staffId as the business closed.

## What review found

### Clock work that grew with every series

Each series worked out the zone day for each of its dates on its own. A 14-day availability read with 30 weekday series asked for 314 zone days instead of 14, so every booking page and calendar feed slowed with each series a business added. A read now shares one cache, keyed by zone and date. A zone day is pure, so sharing it changes no answer:

```ts
function zoneDayOf(key: string, tz: string, days?: ZoneDays): ZoneDay {
  if (!days) return zoneDay(key, tz);
  const k = `${tz} ${key}`;
  let day = days.get(k);
  if (!day) {
    day = zoneDay(key, tz);
    days.set(k, day);
  }
  return day;
}
```

lib/dates also keeps one date-parts formatter per zone now. The test counts calls to zoneDay: at most 16 for 300 spans over 14 days, and at most 30 for the whole availability read. A characterization case checks the spans are unchanged. The calendar feed had also expanded weekly blocks for its last week out to 187 days. It now stops at seven.

### A new time zone left the blocks behind

A series keeps the zone it was made in. When an owner corrected the business's zone, opening hours moved to the new clock and weekly blocks did not. A 12:00 to 13:00 lunch made under Auckland held 10:00 to 11:00 Sydney time, while the list still said 12:00 PM to 1:00 PM. Saving a new zone now moves every series kept on the old business zone. A series on any other zone stays put, and the list names its zone.

### A series with no days left

This and following on a series' first day removes it. But the first day was worked out without the days already taken out one at a time. So This one on the first day, then This and following on the next, left a series with no days in the list. removeBlockedTime now checks seriesHasDayLeft, which walks the whole series, two years at most:

```ts
if (scope === "following") {
  const until = addDaysToKey(occ!.date, -1);
  const untilDate = until < series.untilDate ? until : series.untilDate;
  if (!seriesHasDayLeft({ ...ruleOf(series), untilDate }, skipped)) return removeWhole();
  // ...
}
if (!seriesHasDayLeft(ruleOf(series), new Set([...skipped, occ!.date]))) return removeWhole();
```

### Two holes in our mobile API

GET /time-blocks found each weekly day's series in the same list the Blocked times card uses, which stops at 60, and dropped days whose series was not in it. A client could show a person free while the engine held them. Those series are now read by id, and the test makes 61. And DELETE with a series id and scope=one or scope=following removed the whole series. A series id names no day, so the route now answers 422 and the library refuses it too:

```ts
if (!occ && isSeriesId(id) && scope && scope !== "series") return false;
```

## Trade-offs

- Every read works out its days again. That is cheap per day now, and there are no per-day rows to keep in step.
- Two years is the limit, which bounds the full walk in seriesHasDayLeft.
- A rollback has a cost. Release 13 reads a room's block as everyone and never reads a series, so our deploy notes say to delete room blocks first and to warn owners that weekly blocks will vanish.
- A one-off block is a moment, so it stays put when the zone changes.
