Every application gets to the same conversation eventually. Somebody who uses it, and who is entirely reasonable, says: "Could we just add a calendar?"
The word just is doing a lot of unpaid labour in that sentence. I've banned clients from using the term, "Could we just" and "Can't we just." It's a testament to how well we, as developers, have made things look easy. Everyone thinks that what we do just takes a few clicks.
Here's how it goes in my head. A calendar is a table. An event has a title, a start, an end and maybe a location. Draw the events in a grid, add a "next month" button, go and drink a Red Bull. That's an afternoon. I've spent the afternoon. I've also seen what's left of it two years later.
The trouble starts with the second request, which is never about the grid. "Can it repeat every Tuesday?" Fine, that's a rule. "Can I change just this Tuesday?" Now it's a rule with exceptions. "Can it remind people?" Now it needs a clock of its own. "Can it show up in my phone?" Now it needs to speak a format designed in the 1990s. "Why does it say 8:00 when I'm in Toronto?" Now I'm having a different conversation entirely.
Each of those is a small request from a sensible person. Each one lands on the same hidden assumption, which is that "when" is a single value you can put in a column. It isn't. Most of this series is me discovering that, one request at a time.
Why build one at all
Fair question. I know how this looks. Calendars are a solved problem, there are several very good ones already, and I'm writing five articles about building another in ColdFusion.
My situation is this. I build web software for groups of people who have to turn up somewhere together: clubs, teams, associations, the kind of organizations that run on volunteers and group chats. For them the calendar isn't decoration. It's the reason half of them open the thing in the first place. Where and when is the next game, and has it moved?
I did consider the shortcut: embed somebody else's calendar and go home early. That works nicely for one organization with one administrator who already lives in that tool. It works less nicely when the events have to belong to my application. They need to respect who is allowed to see what. They need to appear on public pages. They need to send reminders through my own notification pipeline, and tell the right people when something changes. And they need to turn up in the calendar app people already have on their phones, without my asking anyone to adopt a new one.
I couldn't answer "who was told about this change?" if the data lived somewhere I couldn't see. So I own the events, and I publish them outward to everyone else's calendar. That decision is the source of everything else in this series.
This series follows the calendar I built with Adobe ColdFusion 2025 and PostgreSQL, and the specific ways it tried to lie to me. There are five parts:
- How I store time and generate a repeating event (this one)
- What happens when somebody edits one occurrence
- How events move in and out of other people's calendars
- How I send reminders and notifications
- How I run the calendar, and what I decided not to build
I'm going to show you the implementation and the bugs that taught me why it works that way. I found one of those bugs while writing this article. Apparently, explaining my code is now a form of destructive testing.
I should also say why I take this so personally. I can usually get away with a clumsy screen. People tolerate an awkward registration form. They have considerably less patience for a system that disagrees with them about what 9:00 means. Everyone who uses a calendar becomes a domain expert the moment it sends them to the rink an hour early, and the group chat then becomes my incident reporting system. It is remarkably thorough.
Start with the smallest example
Let me make this concrete with the simplest event I can think of. A team practices every week, at 9:00 in the morning, in Halifax, Nova Scotia. I'll use Halifax for the examples throughout. It observes daylight saving, so it can hurt me, and it's the zone I reach for in my own tests.
Somebody types "Practice, 9:00" into a form and presses Save. What do I put in the database?
A timestamp is half an answer
Here is the sentence that can ruin my afternoon:
"Practice is at 9:00."The obvious answer is a timestamp, and it's half right. A timestamptz column tells me when something happens. It doesn't record what the person meant by 9:00. For a weekly practice in Halifax, the UTC instant changes with the season:
Summer 09:00 America/Halifax = 12:00 UTC (ADT, UTC-3)
Winter 09:00 America/Halifax = 13:00 UTC (AST, UTC-4)Look at those two lines for a second. It's the same practice and the same 9:00 on the wall clock, but two different instants. If I store the series as "every 7 days from this UTC instant", the practice drifts an hour twice a year. Nobody in Halifax has ever accepted "daylight saving" as a reason for the practice rink being locked. I need the zone to keep the practice at 09:00 while the instant moves underneath it.
So I store both. These are the time columns of calendar_event, copied from the schema:
starts_at_utc timestamp with time zone NOT NULL,
ends_at_utc timestamp with time zone NOT NULL,
timezone_iana text NOT NULL,
is_all_day boolean DEFAULT false NOT NULL,Two more columns arrived later, when the table learned what "all day" really means. I'll get to them in a moment:
ALTER TABLE public.calendar_event
ADD COLUMN IF NOT EXISTS starts_on date,
ADD COLUMN IF NOT EXISTS ends_on_exclusive date;I keep the instant because the database can sort it, compare it and index it. I keep the zone because it gives me the rules that make "9:00" meaningful. I've inherited systems that stored one and reconstructed the other with a guess, typically the server's time zone. The server was always somewhere the users weren't. Nobody had thought to invite it to practice.
I never use the server's zone as a fallback. A new event takes the zone of whoever owns the calendar, whether that's a team, a club or a whole organization, and each owner carries its own timezone_iana. I also pin the ColdFusion engines to UTC, so a stray now() can't quietly mean "Halifax" on one machine and "Las Vegas" on another.
I didn't put a foreign key on timezone_iana. I validate it in code against a curated list of zones, with an escape hatch that checks Java's own zone database. I'll get to the reason for that shortly. My importer had opinions about the list.
All-day is not a time. It's a date.
Now for the event with no time at all: the tournament weekend, the holiday, the "office closed" day. Somebody will ask for one within a week of the calendar existing.
Midnight to 23:59:59 looks like an obvious way to model an all-day event. I can make that look perfectly reasonable right up until the clocks change.
On the day the clocks spring forward, a calendar day is 23 hours long. On the day they fall back, it's 25. In Halifax on 2027-03-14, a one-day all-day event runs from 04:00 UTC to 03:00 UTC the following day. That's 23 real hours and exactly one calendar day. If I insist that "all day" means 24 hours, I give that event an extra hour it never asked for.
I store all-day events as a range of dates:
ALTER TABLE public.calendar_event
ADD CONSTRAINT ck_calevent_allday_date_model CHECK (
( is_all_day
AND starts_on IS NOT NULL
AND ends_on_exclusive IS NOT NULL
AND ends_on_exclusive > starts_on )
OR
( NOT is_all_day
AND starts_on IS NULL
AND ends_on_exclusive IS NULL )
);An all-day row must have both dates, with an exclusive end. A timed row must have neither. The database refuses anything in between. Future me is therefore prevented from inventing an event that is half of each, however persuasive he finds his own explanation at 2:00 a.m.
I still keep the instants as an encoding: local midnight of each date in the event's zone. My queries, sorts and range scans continue to work on starts_at_utc. For an all-day event, though, the dates are authoritative and the instants are derived. The validation code spells that out:
// The submitted dates ARE the model: starts_on inclusive,
// ends_on_exclusive = UI's inclusive last day + 1. The
// instants are the canonical encoding (local midnight of
// each date in the event zone). Midnight swallowed by a
// spring-forward gap resolves to the first valid instant of
// the day (atStartOfDay) - the DATE, which is what an
// all-day event means, is unaffected.
var exclusive_end_date = addDaysIso( ends_date, 1 );
normalized.starts_at_utc = localDateStartToUtc( starts_date, timezone_iana );
normalized.ends_at_utc = localDateStartToUtc( exclusive_end_date, timezone_iana );The migration had to recognize three different ways an earlier version of me had encoded "all day": next-day midnight, same-day 23:59:59, and whatever midnight a feed importer felt like. I won't name the earlier version. He knows what he did.
The 2:30 that does not exist
So far I've been storing times that exist. Let's see what happens when somebody types one that doesn't.
Here's a time I can type into a form: Sunday, March 14, 2027, 2:30 a.m. in Halifax.
That time doesn't exist. At 2:00, the clocks jump to 3:00. An administrator entering 2:30 is asking me to schedule something at a moment the clock skipped. Accepting the form doesn't make the missing hour feel obliged to return.
I can accept it and guess. I can silently move it to 3:30 and let somebody discover that later. Or I can refuse it and explain why. I make the interactive form refuse it:
var zone = createObject( "java", "java.time.ZoneId" ).of( trim( arguments.timezone_iana ) );
var rules = zone.getRules();
var valid_offsets = rules.getValidOffsets( ldt );
if ( valid_offsets.size() == 0 ) {
// Spring-forward gap: this wall-clock time never happens.
return { ok: false, utc: "", token: "calendar_error_time_nonexistent", offset_id: "", offset_choices: [] };
}
if ( valid_offsets.size() == 1 ) {
var only_offset = valid_offsets.get( 0 );
return {
ok: true,
utc: instantToCfUtcDate( ldt.toInstant( only_offset ) ),
token: "",
offset_id: only_offset.getId(),
offset_choices: []
};
}
// Ambiguous fall-back overlap: an explicit, currently-valid offset
// choice is REQUIRED (never silently earlier or later).
var requested = parseZoneOffset( arguments.explicit_offset );
var choices = [];
for ( var candidate_index = 0; candidate_index < valid_offsets.size(); candidate_index++ ) {
var candidate = valid_offsets.get( candidate_index );
var candidate_instant = ldt.toInstant( candidate );
arrayAppend( choices, {
offset: candidate.getId(),
is_dst: rules.isDaylightSavings( candidate_instant )
} );
if ( !isSimpleValue( requested ) && requested.equals( candidate ) ) {
return {
ok: true,
utc: instantToCfUtcDate( candidate_instant ),
token: "",
offset_id: candidate.getId(),
offset_choices: []
};
}
}
return { ok: false, utc: "", token: "calendar_error_time_ambiguous", offset_id: "", offset_choices: choices };That's the body of the try block in resolveLocalInstant, exactly as it runs.
ZoneRules.getValidOffsets() gives me the offsets that are valid for a local date and time. An empty list means the time doesn't exist. A list of two means it happens twice.
The twice case arrives in November. On 2027-11-07 in Halifax, 1:30 a.m. occurs once at UTC-3 and again an hour later at UTC-4. Two different instants, an hour apart, share the same label. I return both choices, with the daylight saving offset first, and make the administrator pick one. When they edit the event later, I derive the offset from the stored instant, so saving it again preserves the same 1:30. A Save button should not be a time machine.
If a stale offset arrives with a time that is no longer ambiguous, I ignore it. An old choice must never be able to move an instant.
I test all of this over real HTTP. One assertion also reads the source text of the service and fails if getValidOffsets goes missing. I'd normally call that lazy. For a rule this easy to "simplify" during a refactor, I'll take lazy. I've seen what energetic looks like.
Machines don't get to ask
The form refuses a time in the gap. My reminder scheduler needs a different policy.
Suppose an all-day event needs a reminder at 2:30 a.m. local time on the morning the clocks jump. There's no administrator waiting to choose another time. If a reminder lands in November's repeated 1:30, there's nobody to ask which one they meant either. I need the scheduler to choose the same way every time, and I need that choice documented:
/**
* Local wall time in an IANA zone -> UTC instant, with the documented DST
* policy: a gap shifts FORWARD, an overlap takes the EARLIER offset.
*
* java.time.ZonedDateTime.of( LocalDateTime, ZoneId ) implements exactly
* that, which is why it is called directly rather than re-derived from
* ZoneRules.getValidOffsets(). calendar_event_validation_service.
* resolveLocalInstant deliberately does the opposite - it REFUSES both
* cases and asks the administrator - because a person entering an event
* time can be asked which 01:30 they meant, and a scheduler cannot.
*
* Returns { ok, utc, code }.
*/Inside that function, one line does the work:
var zdt = createObject( "java", "java.time.ZonedDateTime" ).of( ldt, zone );ZonedDateTime.of(LocalDateTime, ZoneId) already implements that policy. In a gap, it moves the time forward by the length of the gap, so 2:30 becomes 3:30. In an overlap, it takes the earlier offset. I call it directly rather than re-deriving those rules.
A person gets a field error and a chance to fix the time. A machine gets a documented, deterministic shrug. I'm comfortable with that, provided I write both policies down where somebody will eventually be tempted to "make them consistent."
My mailing-list scheduler uses PostgreSQL's AT TIME ZONE, which resolves the overlap to the later 1:30. I therefore have three places that turn a wall clock into an instant and two policies between them. It's on my list. It has been on my list long enough to establish residency.
UTC is not a time zone (in this dropdown)
The curated list of zones seemed sensible until I let it become the only authority on what a zone could be.
I started with 51 entries, all in Canada or the United States, to match the people using the calendar. The list didn't contain UTC. Nobody was asking to schedule a hockey practice in UTC, so I was quite pleased with how practical I had been.
Then I added inbound calendar feeds, which I'll cover in part three. Imported events with a trailing Z were stored with the zone UTC, because that accurately described them. I now had an edit form that couldn't represent an event my own importer had saved. Three things broke:
- The form couldn't select the stored zone, so its prefill script substituted the browser's zone. Saving silently moved the event.
- Validation refused
UTCas unknown. - On a synced event, the failure surfaced as a 500.
I'd built a list that couldn't describe my own data. I fixed it with a narrow escape hatch: an update can keep the event's own stored zone, provided Java agrees it's a real zone.
/**
* True when an UPDATE keeps the event's own stored zone and that zone is a
* real one, even though the reference list does not carry it. Feed events
* imported with Z times are stored as "UTC", which the list lacks: refusing
* it made every save of such an event fail validation or - once the form's
* prefill had swapped in the browser's zone - move the event. A create, a
* CHANGE of zone, and an unknown zone id are still held to the list.
*/
private boolean function isUnchangedStoredZone( required string timezone_iana, required struct ctx ) {
var stored = trim( toString( arguments.ctx.existing_timezone_iana ?: "" ) );
if ( !len( stored ) || compare( stored, arguments.timezone_iana ) != 0 ) {
return false;
}
try {
createObject( "java", "java.time.ZoneId" ).of( stored );
return true;
} catch ( any zone_err ) {
return false;
}
}
I still require a create or a change of zone to use the curated list. An invented zone like Mars/Olympus_Mons is still refused. There's a test for it, because I'm the kind of person who writes a test for Mars while leaving an ordinary Tuesday vulnerable. I'll get to Tuesday.
A recurring event is not a pile of rows
A weekly practice for two years produces about 104 occurrences. A daily event produces about 730. Eventually, somebody will ask for "every day, forever", and I'd like my database to survive their optimism.
I store a recurring event as one row, the master, carrying its rule:
ALTER TABLE public.calendar_event
ADD COLUMN IF NOT EXISTS schedule_type varchar(20) NOT NULL DEFAULT 'one_time',
ADD COLUMN IF NOT EXISTS recurrence_frequency varchar(20),
ADD COLUMN IF NOT EXISTS recurrence_interval integer,
ADD COLUMN IF NOT EXISTS recurrence_days_of_week jsonb,
ADD COLUMN IF NOT EXISTS recurrence_monthly_mode varchar(20),
ADD COLUMN IF NOT EXISTS recurrence_day_of_month integer,
ADD COLUMN IF NOT EXISTS recurrence_nth_week integer,
ADD COLUMN IF NOT EXISTS recurrence_nth_weekday varchar(10),
ADD COLUMN IF NOT EXISTS recurrence_end_mode varchar(20),
ADD COLUMN IF NOT EXISTS recurrence_end_date timestamptz,
ADD COLUMN IF NOT EXISTS recurrence_count integer;
I generate occurrences on demand for the window somebody is viewing. The only persisted rows for individual occurrences are exceptions, which I'll cover in part two.
Two decisions in this schema earn their keep.
First, I always populate recurrence_end_date for a recurring row, even when the rule says "end after 10 occurrences." I compute the start of the tenth occurrence and store it. That adds a derivation step when I save, but gives every range query one column it can use:
WHERE schedule_type = 'recurring'
AND starts_at_utc <= :to_utc
AND recurrence_end_date >= :from_utc
Second, I let the database enforce what it can. A one-time event can't carry recurrence columns. A recurring event must carry its frequency, interval, end mode and end date. A count-based rule must carry a count. These constraints are boring. I want them boring. I have enough personality in the application code.
The two-year cap, three times
I cap every series at two years from the master's start and enforce that limit in three places. I've learned not to trust any single layer, including the one I wrote yesterday:
- Validation refuses a rule that exceeds the cap.
- The expansion loop stops at the cap, whatever the rule says.
- A CHECK constraint refuses a row that exceeds it:
ALTER TABLE public.calendar_event
ADD CONSTRAINT ck_calevent_recurrence_two_year_cap
CHECK (
recurrence_end_date IS NULL
OR recurrence_end_date <= starts_at_utc + INTERVAL '2 years'
);
I also limit expansion to 800 occurrences per event, 5,000 per response, and 2,000 loop iterations. A malformed rule should produce a bounded answer instead of a hung request. I don't expect a person to reach these limits. I'm preparing for the day somebody's importer does. Importers rarely share my sense of restraint.
Walk local time, measure duration in seconds
To keep a recurring 9:00 event at 9:00 when the clocks change, I walk local time. The wall clock stays put and the UTC instant moves.
I preserve a timed event's duration as a fixed number of seconds. For an all-day event, I preserve whole local days:
if ( rule.is_all_day ) {
// All-day recurrence operates on LOCAL DATES: the end is the
// same wall-clock (midnight) `span` local days later, via
// ZonedDateTime.plusDays - NOT start + span*24h. Fixed-hour
// addition overhung a 23-hour spring-forward day by an hour
// and rendered every such occurrence one day too long.
occ_end_zdt = occ_zdt.plusDays( javacast( "long", all_day_span_days ) );
occ_end_utc = zonedToUtcCfDate( occ_end_zdt );
} else {
// Timed recurrence preserves the FIXED duration in seconds
// (a 60-minute practice stays 60 minutes across DST).
occ_end_utc = dateAdd( "s", int( duration_millis / 1000 ), occ_start_utc );
}
A 60-minute practice stays 60 minutes. A one-day event stays one day, even when that day has 23 hours. I use half-open ranges throughout: an event ending exactly on a window boundary belongs to the earlier window and never both.
Each frequency also needs explicit rules:
- Weekly: I rebuild each occurrence from the master's local date and time, anchored to the master's week, rather than from the previous occurrence.
- Monthly on the 31st: I skip months that don't have one. I don't clamp to the 30th. "Monthly on the 31st" firing on the 28th is a different promise, and the person who scheduled it didn't make that promise.
- Yearly on February 29: I generate occurrences only in leap years. I considered the alternative for about four seconds.
I also log rejected weekday tokens when a weekly rule contains one the engine doesn't recognize. An empty calendar used to be the only explanation. That story belongs in part three.
The Tuesday that wasn't
I found one of my favourite bugs during a launch review using the production UI.
A weekly Tuesday series starts October 20, 2026, at 6:00 p.m. in Halifax. The administrator selects "Ends on November 24", also a Tuesday. They expect the last practice on November 24.
I gave them November 17.
The form posts a bare date: 2026-11-24. I parsed it as midnight and stored that midnight as a UTC instant in a timestamptz column. Every occurrence on the chosen end date fell after that instant and vanished. The save succeeded. My calendar had quietly cancelled practice on my behalf.
The month view lost it. The published feed said UNTIL=20261124T000000Z, and Google Calendar followed that instruction exactly. I can't blame another calendar for believing the lie I sent it. All-day series lost their final day the same way.
I fixed it by binding the end of the chosen local day in the event's zone:
public any function endOfLocalDayUtc( required any local_date, required string timezone_iana ) {
var d = isSimpleValue( arguments.local_date ) ? parseDateTime( arguments.local_date ) : arguments.local_date;
try {
var LocalDate = createObject( "java", "java.time.LocalDate" );
var LocalTime = createObject( "java", "java.time.LocalTime" );
var ZoneId = createObject( "java", "java.time.ZoneId" );
var zone = ZoneId.of( javacast( "string", trim( arguments.timezone_iana ) ) );
var day = LocalDate.of(
javacast( "int", year( d ) ),
javacast( "int", month( d ) ),
javacast( "int", day( d ) )
);
// LocalTime.MAX is 23:59:59.999999999; atZone resolves the offset
// actually in force on that local date.
return zonedToUtcCfDate( day.atTime( LocalTime.MAX ).atZone( zone ) );
} catch ( any zone_err ) {
return d;
}
}
I found a related bug in the human-readable summary. It formatted the "until" date in the server's zone and cheerfully announced "Nov 25" for an end date of Nov 24. One wrong assumption about dates had given me two bugs. Efficient, in its own horrible way.
The cap that wasn't
My two-year cap also had a hole in it.
The validator checked that a count-based rule didn't derive an end date beyond the cap. But the derivation itself walked the series under that same cap. If it couldn't reach the Nth occurrence, it returned null. My check then read: "no end date? nothing to compare."
The count that was too large to compute was exactly the count I skipped validating. A daily series with a count of 1,000, about 2.7 years, passed. I'd given the guard an exemption for the condition it existed to catch. It was very polite about letting the problem through.
The fix reads like a confession in code, and I left it that way:
// No derived end date. This is NOT "unknown, so allow it".
// normalizeRecurrence derives that date with
// computeNthOccurrenceUtc, which walks the series under a
// HARD two-year cap. If the Nth occurrence cannot be
// reached, it lies BEYOND the cap - exactly the condition
// this error exists to report.
I've since stopped writing "unknown, so allow it" anywhere I validate anything. It isn't a rule, exactly. It's a scar.
Plot twist: I found one while writing this
While writing this article, I took another look at the daily branch of the recurrence walker:
if ( freq == "daily" ) {
var cur = arguments.master_zoned;
var prev_millis = -1;
while ( iter < variables.MAX_ITERATIONS ) {
...
arrayAppend( locals, cur );
cur = cur.plusDays( javacast( "long", interval ) );
iter++;
...
}
return locals;
}
I advance cur from the previous result. The weekly branch rebuilds each occurrence from the master's local time. The daily branch keeps adding to whatever time the last step produced.
ZonedDateTime.plusDays() works on the local timeline. When the result lands in a gap, Java moves it forward by the length of that gap. A daily series at 2:30 a.m. in Halifax therefore produces 2:30 on Saturday, March 13, 2027. On Sunday the 14th, it tries 2:30, gets 3:30, and then adds a day to 3:30. Every subsequent occurrence stays at 3:30. The yearly branch has the same shape.
I can already hear the objection: who schedules anything at 2:30 a.m.? I apparently decided the answer was "nobody worth testing." My specs use 9:00, 18:00 and 1:30. The DST journey test described as a weekly 9:00 series is actually a daily series with an interval of seven. It sails past the gap without noticing.
My own spec calls it weekly. It's daily. I'm going to sit with that for a moment.
[Draft note: I verified the drift against the shipped calendar_recurrence_service.cfc on Adobe ColdFusion 2025.0.13 by calling expandEventOccurrences for a daily 02:30 America/Halifax series starting 2027-03-13. The local times came back as 03-13 02:30, 03-14 03:30, 03-15 03:30, 03-16 03:30, and so on. A weekly Sunday 02:30 series through the same gap returned 03-07 02:30, 03-14 03:30, 03-21 02:30. I haven't yet added a failing spec or fixed the loop. Before publishing, I still need to add the daily 02:30 America/Halifax spec and its yearly equivalent, observe the failures, fix both branches to rebuild from the master's local date and time, and update this section with the results.]
The browser gets a vote, but not the last one
I establish the event's meaning on the server. Displaying it for somebody in another zone is a separate job, with one governing rule:
A date from one zone is never paired with a time from another.I render every timed label in one zone: the calendar owner's zone for a calendar embedded in a page, and the event's own zone on the event's page. I never convert all-day events.
Then I progressively enhance the page with a small script. It reads the instant and the event's zone from data attributes and converts only when the browser's zone differs. In the embedded calendar, I also move the event into the viewer's local day. On an event page, I keep the event's own line authoritative and add a separate "your time" line:
function readInstants(el) {
if (el.getAttribute('data-calendar-all-day') === 'true') return null;
var eventTz = el.getAttribute('data-calendar-tz') || '';
if (!eventTz || eventTz === browserTz) return null;
...
}If the script fails, I leave the server's HTML untouched. The page is already correct before JavaScript arrives. I want the enhancement to be optional; I've debugged enough mandatory improvements.
One more disagreement
I have one more open item in my notes, because even the components supplying the time zone rules can disagree.
My application's Java time zone data and my database's time zone data aren't guaranteed to match. Validation, recurrence expansion and exported time zone definitions use Java's rules. The migration that backfilled all-day dates used PostgreSQL's. When those copies of the zone database come from different years, a date in a zone whose rules recently changed can produce an hour's difference depending on which component I ask.
My integration tests deliberately avoid one affected province. The comment explains that a journey must not depend on which copy of the zone database a component carries. That's a sensible requirement and an uncomfortable reason to exclude a test case.
I don't currently check tzdata at startup. This is the sort of bug that waits for a rule change somewhere I have users, then introduces itself through a support request.
[Draft note: This is based on my implementation notes dated 2026-09-14 and reaffirmed 2026-10-02. I haven't re-measured it for this article. Before publishing, I still need to confirm that it holds and decide whether to name the affected province.]
What I'd tell you to keep
These are the habits I'd carry into another calendar implementation:
- Store the instant and the zone. One alone is half an answer.
- Model an all-day event as dates, not hours.
- Ask a person which 1:30 they meant. Give a scheduler a documented policy.
- Store a recurring event as a rule. Bound its duration, its output and the work needed to generate that output.
- "Unknown, so allow it" is the most expensive sentence in validation.
- Check that a test named "weekly" actually tests a weekly series. I can recommend this one from recent personal embarrassment.
In the next part, I'm going to let someone edit one occurrence of a series. That's where I discovered how many things I'd been quietly assuming were the same thing.
