DEV Community

Eric Mollenthiel
Eric Mollenthiel

Posted on

A week is seven nights, not 168 hours: dates in a shared-custody calendar

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.

1. 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:

public function nights(\DateTimeZone $zone): int
{
    $from = $this->startsAt->setTimezone($zone)->setTime(0, 0);
    $to   = $this->endsAt->setTimezone($zone)->setTime(0, 0);

    return (int) $from->diff($to)->days;
}
Enter fullscreen mode Exit fullscreen mode

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.

2. 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:

$start = new DateTimeImmutable('2026-03-25 18:00', new DateTimeZone('Europe/Paris'));

$start->setTimezone(new DateTimeZone('UTC'))
      ->modify('+7 days');          // 1 April, 19:00 local. One hour late, forever.
Enter fullscreen mode Exit fullscreen mode

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.

private function boundary(\DateTimeImmutable $day): \DateTimeImmutable
{
    return $day
        ->setTime(intdiv($this->switchMinutes, 60), $this->switchMinutes % 60)
        ->setTimezone(new \DateTimeZone('UTC'));
}
Enter fullscreen mode Exit fullscreen mode

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:

self::assertSame('2026-10-23 16:00', $segments[0]->start->format('Y-m-d H:i'));
self::assertSame('2026-10-30 17:00', $segments[1]->start->format('Y-m-d H:i'));
Enter fullscreen mode Exit fullscreen mode

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.

3. 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:

$c = new DateTimeImmutable('2026-09-04 00:00', new DateTimeZone('America/Santiago'));
for ($i = 0; $i < 5; $i++) {
    echo $c->format('Y-m-d H:i P'), "\n";
    $c = $c->modify('+1 day');
}
Enter fullscreen mode Exit fullscreen mode
2026-09-04 00:00 -04:00
2026-09-05 00:00 -04:00
2026-09-06 01:00 -03:00   <- midnight does not exist here
2026-09-07 01:00 -03:00   <- and it never comes back
2026-09-08 01:00 -03:00
Enter fullscreen mode Exit fullscreen mode

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.

The fix is one line and it is the least obvious line in the codebase:

$noon = $firstDay->setTimezone($zone)->setTime(12, 0);

for ($i = 0; $i < $length; $i++) {
    $days[] = $noon->modify('+' . $i . ' days')->setTime(0, 0);
}
Enter fullscreen mode Exit fullscreen mode

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.

4. A date is not an instant, and the type system will not tell you

Three fields in the custody rule are days, not moments: the anchor date, the start date, the end date. They are Y-m-d, they have no time, and giving them one is how you lose a day.

They are stored at UTC midnight and read back by their Y-m-d only. In Symfony forms, the date field carries both model_timezone and view_timezone set to UTC:

->add('anchorDate', DateType::class, [
    'model_timezone' => 'UTC',
    'view_timezone'  => 'UTC',
])
Enter fullscreen mode Exit fullscreen mode

Set only one and a plain round trip through the form, with no edit at all, walks the date back by a day for anyone west of Greenwich. Save the form twice, lose two days, and the whole predicted calendar shifts under a family that changed nothing.

The handover time gets the same treatment from the other direction: it is a smallint of minutes since midnight, not a time column. 18:00 is 1080. It is not an instant, it has no date, and there is no timezone to attach to it. A time column invites exactly the conversion that must never happen.

5. The one line that silently poisons all of the above

None of this holds if PHP disagrees with you about what timezone the database is speaking.

Doctrine's datetime_immutable type reads a timestamp column with PHP's default timezone. My server runs Europe/Paris. So a value written as UTC comes back as a Paris wall clock reading of the same digits, which is a different instant:

date_default_timezone_set('Europe/Paris');
$v = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', '2026-08-12 23:30:00');

$v->format('Y-m-d H:i P');                          // 2026-08-12 23:30 +02:00
$v->setTimezone(new DateTimeZone('UTC'))->format(); // 2026-08-12 21:30 +00:00
Enter fullscreen mode Exit fullscreen mode

Two hours early, on every single row, in summer only. A handover recorded at 00:30 UTC is read as 22:30 UTC the day before: the night lands on the wrong day and is credited to the wrong parent. Nothing throws. The tests pass, because the test environment happened to agree with itself.

So the kernel says it out loud, before anything else boots:

public function __construct(string $environment, bool $debug)
{
    date_default_timezone_set('UTC');

    parent::__construct($environment, $debug);
}
Enter fullscreen mode Exit fullscreen mode

Display is the only place that leaves UTC, and it leaves for the family's timezone, not the server's.

How I know any of this holds

The three services that do the counting (the cycle, the timeline, the statistics) know nothing about Doctrine, entities or repositories. They take a list of durations, an anchor date, a switch time, a timezone, and they return values. No fixtures, no database, no HTTP.

That is not architectural taste. It is what makes the two properties worth asserting cheap enough to assert everywhere:

  • A week across a transition is still seven nights, and the handover is still 18:00 on both sides. One test, both directions, spring and autumn.
  • Regenerating the plan twice produces exactly the same plan. Cycle segments are always generated whole, never clipped to "now", so nothing shrinks by a sliver on each pass. The day this stops being true, the predicted calendar starts moving on its own.

An off-by-one in a date library shows up as a stack trace. An off-by-one in a custody calendar shows up as a parent driving to a school where nobody is waiting. Same bug class, very different failure mode, and it is the reason all of the above got written down instead of remembered.

The app is at nestido.com, and the reasoning behind the night as the unit of account is written up for parents rather than for developers in counting overnights without an argument.

Top comments (0)