HomeServicesOur WorkArticlesAbout UsLet’s Talk

Every week, every other week,except Thursday

How we compute schedules instead of storing them

8 October 2026 · 10 min read
A week in Koredyne Schedule for a business centre, showing the boardroom and training suite: a fortnightly planning session, a board meeting every four weeks and a course that hands over to the next

“Every week, every other week, except Thursday.” Anyone who has run a timetable has said a sentence like that. A gym runs boxing every evening and a different grappling class each week. A physio clinic alternates a studio class with a pool session. A business centre hosts a standup every morning, a planning session every other Sunday and a board meeting once every four weeks. Then real life arrives: a practitioner is on leave on Tuesday, a room is being repainted, a public holiday lands on a Wednesday.

We built Koredyne Schedule to handle all of that with one engine. This article is about the decision that made it simple: we never store the calendar. We store the rules and the exceptions to them, never the sessions a rule produces, and work out every date from those.

You can try it while you read: the playground gives every visitor a private copy, so drag, cancel and delete as much as you like.

Two ways to build a timetable

The obvious way is to store every session. When someone creates “Boxing, Sundays at 6 PM”, the system writes a row for each Sunday up to some horizon, and the calendar screen reads those rows.

It works on day one. Then the questions start. How far ahead do you generate, and what keeps adding rows after that? Move the class to 7 PM and you rewrite hundreds of rows, skipping the ones someone already changed by hand. Ask “will anything clash next March?” and you can only answer for the rows you happened to create.

The other way is to store the rule once: “Boxing, Sundays 6–7 PM, in the Ring, week 1 of a 2-week cycle, from 4 October.” When the calendar opens on any week, even two years away, the app works out each day’s sessions. The calendar is real; it’s just drawn from rules instead of read from a table.

That needs three things: a data model that keeps the parts of a timetable apart, a way to compute rotations, and a way to layer exceptions on top.

Separate what, where and when

One line on a timetable mixes several facts. “Boxing with Marco, Ring, Sundays 6 PM, kids, week 1 of 2” is really four:

  • Class: what and who. An activity and the people who run it: boxing with Marco, or a board meeting co-hosted by two directors.
  • Class time: where and when. A room, a weekday and a time, with an optional label such as “Kids” or “Stage 2”.
  • Series: which classes go into which class times, and in which week of its cycle. A series has a start week, an optional end, and a cycle length.
  • Plan: how a series is sold. Eight sessions a month, unlimited, or three months for one price.
Seriesstart week, end weekcycle of N weeksEntryweek 1 … Nof the cycleClasswhat: an activitywho: one or more peoplePlan8 a month, unlimited…months and priceClass timewhere: a roomwhen: weekday and timehassold aswhere, when
The week number lives on the entry that joins a series to a class time.

Two details matter. The week number belongs to the entry, not the class, because the same class can be week 1 in one series and week 3 in another. And plans are separate from series: “one month, 12 classes” and “three months, unlimited” sell the same sessions.

Rotations are arithmetic

A series repeats on a cycle of N weeks. Week 1 is the week it starts, its anchor (always the first day of a week; the API refuses anything else), and it counts on from there. So for any date, finding the week of the cycle is one line of arithmetic:

function cyclePosition(series, date) {
  if (date < series.anchor_date) return null                  // not started yet
  if (series.end_date && date >= series.end_date) return null // already ended
  const weeks = Math.floor(daysBetween(series.anchor_date, date) / 7)
  return (weeks % series.cycle_length) + 1                    // 1-based
}
Which week of its cycle a series is in on a given date (simplified).

That is the whole rotation engine. A weekly series has a cycle of 1, so every week is week 1. A fortnightly sprint cycle has a cycle of 2: planning in week 1, a retrospective in week 2. A board meeting every four weeks is a 4-week cycle with something only in week 1. Looking at a week next year costs the same as looking at this week: there is nothing to generate, no background job, and nothing to go stale.

Week 1Week 2Week 3Week 4Week 5Week 6Week 7Week 8Daily standupevery weekW1W1W1W1W1W1W1W1Sprint cyclecycle of 2PlanRetroPlanRetroPlanRetroPlanRetroBoard meetingcycle of 4BoardBoard
Three series with different cycles, worked out week by week from their anchors.

Ending, and handing over. A series’ end date is exclusive: it is the first week the series no longer runs. That lets one series end on the same Sunday the next one starts, with no gap and no overlap. A four-week first-aid course hands its room and times to a fire-safety course; a six-week post-natal programme hands over to Stage 2. Both stay in the data, so last month still shows the first-aid course, not its successor.

Two details keep this honest. Dates and times are stored as plain strings (2026-10-04, 18:00) with no time zone: a business is in one place, so “Sunday 6 PM” stays 6 PM across a daylight-saving change; one spanning time zones would need more. And if you’re wondering about iCalendar’s RRULE: it could describe each class on its own, but not a series as one unit, with different classes in different weeks of one cycle sharing rooms and people. Exporting to it would be straightforward if anyone needs it.

Exceptions are a layer, not an edit

Rules describe the normal week; real weeks rarely are. So to work out a single date, the engine runs four steps:

  1. Series sessions. Every entry whose series is running, whose cycle week matches and whose class time falls on that weekday.
  2. That day’s changes. A session can be cancelled, moved to another time, room or date, or run by someone else, for this date only.
  3. Sessions moved in. A session moved here from another day joins the list.
  4. One-off bookings. An assessment, an interview or a private lesson: a single dated session outside any series.
STEP 1Series sessionscycle week matchesSTEP 2Day changescancel, move, swapSTEP 3Moved infrom another daySTEP 4One-offssingle bookingsThe day
How one date is worked out: rules first, then the exceptions on top.

The important choice is what a change is attached to. We key it by date, class time and class, not by series. The same Monday evening BJJ class sits in “BJJ Gi”, “BJJ Unlimited” and “All Access”, but it is one real class in one room, so cancelling it must cancel it for everyone. That key gives us this for free.

Changes also clean up after themselves: each is stored as a difference from the original, so dragging a class to 7 PM and back leaves nothing behind.

Clashes, and knowing when to stop checking

The rules themselves are simple. A room can’t hold two things at once, and the person running a session can’t be in two places. Back-to-back is fine: one session ends at 5:00, the next starts at 5:00. A room’s cleaning buffer counts; cancelled sessions don’t. Two series that include the same class in the same class time aren’t a clash either: that’s one shared session.

The hard question is how far ahead to check. A change made today might only clash in week 7, when two rotations happen to line up. Because we compute dates instead of storing them, there are no rows to scan. But rotations are periodic, and that gives an exact answer: once the last dated thing has passed (a series start or end, a one-off, a moved session), the whole pattern repeats every LCM (least common multiple) of the cycle lengths. Check that far, and you have seen every combination that will ever happen.

// After the last dated thing, everything repeats every lcm(cycles) weeks.
function conflictHorizon(state, today) {
  let last = weekStart(today)
  let lcm = 1
  for (const s of state.series) {
    last = latest(last, s.anchor_date, s.end_date)
    lcm = (lcm * s.cycle_length) / gcd(lcm, s.cycle_length)
  }
  for (const d of datedExceptions(state)) last = latest(last, d) // one-offs, moves
  const weeks = weeksBetween(weekStart(today), last) + Math.min(lcm, 104)
  return Math.min(weeks, 208) // two caps: two years of repeats, four years in all
}
How far ahead clashes need checking (simplified).
12345678910111213Cycle of 2W1W2W1W2W1W2W1W2W1W2W1W2W1Cycle of 3W1W2W3W1W2W3W1W2W3W1W2W3W16 weeks = lcm(2, 3): every combination has now happened
Cycles of 2 and 3 weeks only line up again every 6 weeks: the LCM.

With cycles of 2 and 4 weeks, that is 4 weeks past the last date; with 3 and 4, it is 12. Real timetables stay small like this, but the repeat window is capped at two years and the whole scan at four (cycles of 3, 5 and 7 weeks already need 105), so an odd mix of cycles can’t stall a save; past those caps the check is no longer exhaustive. Three more decisions make it dependable in practice:

  • The server checks inside a transaction. Every save runs, the clash check runs over the whole horizon, and if the save introduced a clash it is rolled back with a message such as “Host Mei Chen double-booked: Standup (Room A, 9 AM – 9:15 AM, Acme — Daily standup W1) & Interview (Room B, 9 AM – 12 PM, Orbit Analytics — Hiring W1), first 6 Oct”. SQLite runs synchronously in one process, so two saves can never interleave.
  • Only new clashes block. The check compares clashes before and after the change. If old data already holds a clash, unrelated edits still work, instead of the whole system freezing until someone sorts out March.
  • The browser and the server run the same rules. One module is shared by both, so the warning you see before pressing Save comes from the same code the server enforces.

When storing is the right call

Computing dates isn’t free of costs. A question like “every session Adam attends next month” means working out the dates first and filtering after; across thousands of businesses in one database, you’d want a precomputed index. And history must not move: attendance, payroll and invoices refer to sessions that actually happened, so those belong in stored records, written when they happen, not recomputed from today’s rules. Today, editing a series in place rewrites its past weeks too; “replace from a date” ends the old series and starts a new one, and that is what keeps history intact.

So the full version of the rule is: compute the future, store the past. Koredyne Schedule doesn’t track attendance yet, so today it only needs the first half.

One engine, three vocabularies

Nothing above is about gyms. A physio clinic has practitioners, treatment rooms and programmes; a business centre has hosts, meeting rooms and client bookings. The model is identical, so the engine stays the same and only the words change:

GymClinicMeeting rooms
A bookingClassSessionMeeting
Who runs itCoachPractitionerHost
What it isActivityServiceMeeting type
Who booksMemberPatientClient
The repeating setPackageProgrammeSeries

The business picks its kind in Settings, and every screen, plan label and error message follows: “Host Sara double-booked”. We turned down generic words like “resource” and “event”: the people using a timetable are front-desk staff, not engineers. Internally the code keeps one set of names; the vocabulary is a thin layer at the edges.

The same engine as a physio clinic: practitioners, treatment rooms, a pool and a rotating Back Care programme
The same engine as a clinic: patients, practitioners and programmes, with Back Care alternating studio and pool (W1/2).

Each kind ships with a demo described as plain data, and a test checks every demo for clashes twelve weeks ahead.

A playground anyone can break

We wanted a public link where anyone could try it properly, deleting everything included. With a shared demo, one visitor’s deletions would spoil everyone else’s tour.

So every visitor gets their own copy: an in-memory SQLite database, loaded with the demo and found again through a random id in a cookie. Copies idle for an hour are dropped, and beyond 200 the least recently used goes first. Each is a few hundred rows, so it costs almost nothing; a restart wipes them all, which for a demo is a feature.

handle(req, res) {
  let id = readCookie(req, 'sandbox')
  let box = id && boxes.get(id)
  if (!box) {
    id = crypto.randomUUID()
    if (boxes.size >= MAX) drop(leastRecentlyUsed())
    const db = openDb(':memory:')        // a fresh database starts with the demo
    box = { db, api: apiMiddleware(db) }
    setCookie(res, 'sandbox', id)
  }
  markUsed(id, box)
  return box.api(req, res)
}
One private database per visitor (simplified).

Small on purpose

The whole system is one Node process with the built-in node:sqlite, a React front end, and one shared rules module. Every write returns the complete state (a business’s timetable is a few hundred rows), so there is no cache to invalidate and no partial update to reconcile. On a laptop (Apple M4, Node 24), the gym demo’s 109 class times take about 1 ms for a week, about 90 ms for a whole year (some 11,800 sessions), and about 5 ms for the clash check, which runs twice on every save (before and after). Clashes are compared pairwise within each day, which is fine at a few dozen sessions a day; there is nothing to optimise yet.

Next is mostly about people rather than dates: roles, attendance, closing for a date range, and pausing a rotation over a closed week.

Try it

Open the playground, switch between the gym, clinic and meeting-room demos in Settings, and try to break it: drag a session onto someone who is already busy and watch it refuse. The code is on GitHub. And if your business runs on a timetable that repeats, rotates and gets interrupted, we’d like to hear about it.

Let’s build something that thinks

Tell us what you’re working on. We’ll turn it into a clear technical plan and a practical next step.