Dev.to WebDev 🛠 Dev 👁 0 📖 5 min read

Stop storing event dates: store the rule and compute the next edition

Most event listings go stale in a predictable way. Someone types "18 to 21 November 2026" into a database, the event happens, and the page keeps showing last year's dates until a person notices. For one-off events that i

Most event listings go stale in a predictable way. Someone types "18 to 21 November 2026" into a database, the event happens, and the page keeps showing last year's dates until a person notices. For one-off events that is fine. For annual events it is a bug you ship every year.

At TableJourney we keep the next dates of about 1,400 food festivals. Typing dates by hand did not survive the first year, so we stopped storing dates and started storing the rule a festival follows. The site computes the next edition from the rule every day. This post shows the shapes of rule we needed, with working Python.

Three kinds of recurrence

After going through the whole dataset, almost every annual event fell into one of three groups:

  1. Fixed day of the year. "Every 24 to 26 December." The easy case: store month and day, roll the year forward.
  2. Nth weekday of a month. "The third Thursday of November, for four days." The weekday holds, the day number moves.
  3. Anchored to a moveable feast. Easter, Lunar New Year, Mid-Autumn, Ramadan, Diwali. These move by weeks between years, so neither of the first two works.

A fourth shape, "the weekend after Lunar New Year", turned out to be common enough to need its own rule, and it is the one that catches people out.

The rule as data

Each event carries a small date_rule object instead of a date. These are real rules from our data:

{"nth_weekday": [3, "Thursday"], "length": 4}
{"nth_weekday": [-1, "Monday"], "offset": -1, "length": 2}
{"anchor": "easter", "end_offset": -47}
{"anchor": "chinese-new-year", "start_offset": 0, "end_offset": 14}
{"anchor": "chinese-new-year", "weekday_after": "Saturday", "length": 2}

The second one reads "the Sunday before the last Monday of the month, for two days", which is how a lot of bank-holiday weekend events are actually defined. The month comes from the event's stored start_month; the rule never contains a year, so it keeps working.

Nth weekday

The helper is short. n is 1 to 5, or -1 for the last one:

import calendar
import datetime as dt

def nth_weekday(year, month, weekday, n):
    """n-th (1..5, or -1 for last) weekday (0=Monday) of the month."""
    days = [
        dt.date(year, month, d)
        for d in range(1, calendar.monthrange(year, month)[1] + 1)
        if dt.date(year, month, d).weekday() == weekday
    ]
    if n == -1:
        return days[-1]
    return days[n - 1] if 1 <= n <= len(days) else None

print(nth_weekday(2026, 11, 3, 3))   # third Thursday of November 2026
print(nth_weekday(2027, 5, 0, -1))   # last Monday of May 2027
2026-11-19
2027-05-31

Returning None for "the fifth Friday" in a month that has four is deliberate: the caller can then look at the next year instead of inventing a date.

Easter: compute it, do not table it

Easter is the one moveable feast you can compute exactly. This is the Anonymous Gregorian algorithm, as it sits in our code:

def easter(year):
    """Gregorian Easter Sunday (Anonymous Gregorian algorithm)."""
    a = year % 19
    b, c = divmod(year, 100)
    d, e = divmod(b, 4)
    f = (b + 8) // 25
    g = (b - f + 1) // 3
    h = (19 * a + b - d - g + 15) % 30
    i, k = divmod(c, 4)
    l = (32 + 2 * e + 2 * i - h - k) % 7
    m = (a + 11 * h + 22 * l) // 451
    month, day = divmod(h + l - 7 * m + 114, 31)
    return dt.date(year, month, day + 1)

print([str(easter(y)) for y in (2026, 2027, 2028)])
['2026-04-05', '2027-03-28', '2028-04-16']

Carnival, Lent markets and anything defined as "N days before Easter" become an offset from this date. The {"anchor": "easter", "end_offset": -47} rule above ends a season on Shrove Tuesday, 47 days before Easter Sunday.

Lunar and religious calendars: a small table, sourced

Lunar New Year, Mid-Autumn, Ramadan, Eid and Diwali cannot be computed reliably from a formula you want to maintain. We keep a small JSON table of anchor dates per year, each from a published source (an observatory's lunar tables, published religious calendars), and the code reads it:

{"chinese-new-year": {"2026": "2026-02-17", "2027": "2027-02-06", "2028": "2028-01-26"}}

The table has an end. When an anchor runs out of years, the resolver returns None and the event keeps its stored month and day, and the data validator flags every rule whose anchor table ends too soon, so the table gets extended before it matters. A table that silently ends is worse than no table.

The weekend-after rule

Here is the trap. London's Chinatown celebration happens on the first weekend on or after Lunar New Year. In 2026 the holiday is a Tuesday, so the event is the following Saturday. In 2027 the holiday is itself a Saturday. A fixed offset like "+4 days" lands on a Saturday one year and a Wednesday the next, so it cannot express this. Snap forward to a weekday instead:

WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]

def snap_forward(day, weekday_name):
    target = WEEKDAYS.index(weekday_name)
    return day + dt.timedelta(days=(target - day.weekday()) % 7)

lny_2027 = dt.date(2027, 2, 6)
start = snap_forward(lny_2027, "Saturday")
print(start, start + dt.timedelta(days=1))
2027-02-06 2027-02-07

We also support weekday_nearest (the closest named weekday in either direction) for events that celebrate a holiday on the weekend nearest to it.

Picking the next edition

With an occurrence function per rule, the next edition is the earliest occurrence that has not ended yet. Check a small window of years, because an event in early January belongs to next year when you ask in December, and a season that started last year may still be running:

def next_occurrence(rule_occurrence, today):
    best = None
    for year in (today.year - 1, today.year, today.year + 1, today.year + 2):
        occ = rule_occurrence(year)          # (start, end) or None
        if occ and occ[1] >= today:
            if best is None or occ[0] < best[0]:
                best = occ
            break
    return best

Comparing against the end date rather than the start keeps a running event on the page as "on now" until its last day. Some events run several editions a year (restaurant weeks in winter and summer), so the real code takes a list of rules and picks the soonest across all of them.

What this bought us

  • Titles, snippets and structured data always show the next real dates, with no yearly data entry.
  • One Hong Kong page had been advertising 17 February 2027 for Lunar New Year, when the holiday is on the 6th. A rule cannot make that mistake.
  • The same resolved dates feed everything downstream: the pages, the calendar feeds, the API and an open dataset. One computation, many outputs, no disagreements between them.

The rule format is deliberately boring JSON so that research agents and people can both write it. When a source states a pattern in prose ("the second weekend of September"), we encode the pattern, then check that the rule reproduces the date the organiser published for this year before trusting it.

If you want to see the result, the dates of all of these events are in an open dataset (CC BY 4.0) you can query or download: https://tablejourney.com/festivals/statistics/ has the summary and the links. How do you handle recurring dates in your own projects? I would like to hear about rule shapes we have not needed yet.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.