Add seven days to a timestamp and you get the same day next week. That holds right up until the last Sunday in October, when it quietly stops holding and nothing tells you.
I have been building Nestido, a planner for separated parents: who has the children, when, and where. The family declares its custody rhythm once (alternating weeks, 2-2-3, 5-2-2-5), the app predicts the calendar from it, and the parents only record the deviations. Symfony 8, PostgreSQL, one server.
From the outside it is a calendar with colours. From the inside it is date arithmetic with a domain that punishes you twice a year, in a way no user will ever report. Nobody files a bug that says "your daylight saving handling is off". They see a number that looks wrong, they say nothing, and they stop trusting the app.
Here are the five rules I ended up with. Every one of them exists because the obvious version was wrong. The unit is the night, and a night is not a duration
The first instinct is to store intervals and divide by 24. It is wrong in both directions, and you can prove it without leaving your own timezone.
In Paris, 1 June 23:30 to 2 June 22:30 is twenty-three hours and it is one night. 1 June 00:30 to 1 June 23:30 is also twenty-three hours and it is zero nights: nobody slept anywhere new. Identical durations, different answers.
So the count is not a duration at all, it is a number of local midnights crossed:
Truncate both ends to local midnight, then diff. days already accounts for the transitions, so the two cases that break the hour-based version come out right: 28 March 20:00 to 30 March 08:00 in Paris is 35 hours (the clocks jump forward). Two nights. 24 October 20:00 to 26 October 08:00 is 37 hours (the clocks fall back). Two nights.
Divide by 24 and you get 1 and 1. In spring you have just deleted a night from every family in the country, and the statistics screen, which is the whole reason the app exists, is quietly off by one for the rest of the year. Do the arithmetic in the family's timezone, store the result in UTC
Everything in my database is UTC. That is the easy half, and it lulls you into doing the arithmetic in UTC too. That part is wrong.
A handover happens at 18:00 local. It happens at 18:00 in July and at 18:00 in November. If you add seven days to a UTC instant, you get the same UTC instant one week later, which is a different wall clock time on the other side of a transition:
The rule that survived contact with the domain: cycle boundaries are computed on local dates at the local switch time, and converted to UTC only at the very end.
The test that guards it is the clearest statement of the whole idea. Two consecutive weekly boundaries around the October change, stored as UTC instants:
Different UTC instants, one hour apart. Both are 18:00 in Paris. If your two boundaries have the same UTC time on either side of a transition, your handover has drifted, and it will drift again in March. Never walk a calendar from midnight. Walk from noon.
To render a month you loop over days. The obvious loop starts at local midnight and adds one day at a time. It works everywhere in Europe and North America, which is exactly why it is dangerous: in some zones midnight does not exist.
Chile moves its clocks at midnight. So do a handful of others, and Brazil did until 2019. On the transition night, 00:00 is not a valid local time and PHP moves you to 01:00. From then on, the loop keeps that hour:
Every day after the transition now starts an hour late. A night recorded at 00:30 falls outside the day it belongs to, and the calendar cell for that day is empty while the previous one holds two.
Walk from noon, offset from a fixed origin rather than accumulating, then drop to midnight at the end. Noon is never skipped by a transition, offsetting from the origin means an error cannot compound, and the final setTime(0, 0) renormalises to a midnight that actually exists. The same trick guards the cycle anchor, for the same reason. A date is not an instant, and the type system will not tell you
