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:
/** 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:
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:
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:
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:
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:
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.
Building on Bookatu?
Bookatu has a public REST API and webhooks. Have a look at the developer docs.
Developer docs