A weekly streak counter I wrote walked backwards through history by subtracting 604 800 seconds per week from a Unix timestamp. It skipped an entire week — but only after the last Sunday of March, and only in that direction. Written and tested in summer, it would have passed every test until late March.
TL;DR — When stepping through weeks (or days) with raw epoch arithmetic over a local timezone, anchor the cursor at local noon, not midnight: across a spring-forward boundary, Monday 00:00 − 604800 lands on Sunday 23:00 of the previous ISO week, while Monday 12:00 − 604800 lands on Monday 11:00 and stays in the right one.
The setup
I worked on the backend of a couples’ mobile game (PHP 8.5, SQLite). Each couple sets a weekly goal — complete at least N challenges — and a streak counts consecutive weeks that hit it. Completions are stored as Unix timestamps; a week is identified by the ISO week of the couple’s local timezone (ISO weeks run Monday to Sunday, so 2026-W13 is Mon Mar 23 through Sun Mar 29). Two helpers do the mapping:
function weekId(int $ts, string $tz): string { // e.g. "2026-W13"
return (new DateTimeImmutable('@' . $ts))
->setTimezone(new DateTimeZone($tz))->format('o-\WW');
}
function weekStartTs(int $ts, string $tz): int { // Monday 00:00 local
$dt = (new DateTimeImmutable('@' . $ts))->setTimezone(new DateTimeZone($tz));
return $dt->modify('monday this week')->setTime(0, 0)->getTimestamp();
}
Completions are pre-counted into $perWeek, a map from week id to the number of challenges completed that week. The streak loop starts a cursor at the current week’s Monday and repeats $cursor -= 7 * 86400, checking each week’s count until one misses the goal.
What I expected
Seven days is 604 800 seconds, so subtracting it from Monday midnight should give the previous Monday midnight. I knew daylight saving time makes two local weeks per year an hour shorter or longer, so I expected up to an hour of drift — but drift within Monday, which a bucket seven days wide would never notice. That reasoning has a hole: midnight is not inside the week, it is the edge of it. Any drift in the wrong direction, however small, exits the bucket.
What actually happens
I probed the two Mondays right after the 2026 transitions in Europe/Paris (spring-forward on Mar 29, fall-back on Oct 25):
$tz = new DateTimeZone('Europe/Paris');
foreach (['2026-03-30', '2026-10-26'] as $monday) {
foreach (['00:00:00' => 'midnight', '12:00:00' => 'noon'] as $time => $label) {
$ts = (new DateTimeImmutable("$monday $time", $tz))->getTimestamp() - 604800;
printf("%s %-8s - 604800s = %s\n", $monday, $label,
(new DateTimeImmutable("@$ts"))->setTimezone($tz)->format('D Y-m-d H:i'));
}
}
2026-03-30 midnight - 604800s = Sun 2026-03-22 23:00
2026-03-30 noon - 604800s = Mon 2026-03-23 11:00
2026-10-26 midnight - 604800s = Mon 2026-10-19 01:00
2026-10-26 noon - 604800s = Mon 2026-10-19 13:00
The week Mar 23 → Mar 30 contains the spring-forward, so it lasts 167 real hours, not 168. Subtracting a fixed 168 hours from its far edge overshoots by one hour — Sunday 23:00, which weekId files under 2026-W12. The bug is asymmetric: fall-back overshoots the other way, to Monday 01:00, which is harmless. Only one DST direction, only when stepping backwards.

The loop consequence is worse than one wrong read, because every later step inherits the shift. I replayed the streak loop with both anchors, with $now on Mon Mar 30 and every week from 2026-W09 on meeting the goal (scenario A), then the same with 2026-W13 missed (scenario B):
Scenario A: every week met (expect streak 6)
midnight anchor (+0h) streak=5 visited: 2026-W12 -> 2026-W11 -> 2026-W10 -> 2026-W09 -> 2026-W08 (stop)
noon anchor (+12h) streak=6 visited: 2026-W13 -> 2026-W12 -> 2026-W11 -> 2026-W10 -> 2026-W09 -> 2026-W08 (stop)
Scenario B: W13 missed (expect streak 1)
midnight anchor (+0h) streak=5 visited: 2026-W12 -> 2026-W11 -> 2026-W10 -> 2026-W09 -> 2026-W08 (stop)
noon anchor (+12h) streak=1 visited: 2026-W13 (stop)
With the midnight anchor, 2026-W13 is never visited at all. The counter returns 5 in both scenarios: one week silently undercounted when everything was met, and a missed week silently forgiven when it was not. Every unit test I could have written between April and February would have passed — the same trap shape as my optimal-solver tests that all passed for a solver that was not optimal, green because they never crossed the boundary.
The fix
One line — start the cursor at noon of the current Monday instead of midnight:
$cursor = weekStartTs($now, $tz) + 12 * 3600; // noon of the current Monday
$cursor -= 7 * 86400;
while (($perWeek[weekId($cursor, $tz)] ?? 0) >= $goal) {
$streak++;
$cursor -= 7 * 86400;
}
If the code is not committed to integer timestamps, $dt->modify('-7 days') on a timezone-aware DateTimeImmutable does calendar arithmetic and avoids the problem entirely. The noon anchor is the minimal patch when the loop, the buckets, and the storage all speak epoch seconds already.
Why it works
Noon is at least eleven hours from both edges of the local day. Each DST transition the cursor steps across shifts it by ±1 hour, and the two yearly transitions push in opposite directions, so the drift never accumulates: over any range the cursor stays between 11:00 and 13:00 local, always inside the intended Monday. Since weekId only reads the local date, the bucket is always the right one. The same rule applies to stepping days with ± 86400: anchor at 12:00 and a one-hour DST shift can never change which day you are on.
What I did not test
Only Europe/Paris and its one-hour transitions, on PHP 8.5.4; a Python 3.14 zoneinfo cross-check gave the identical four timestamps. Half-hour DST zones (Lord Howe Island shifts 30 minutes) drift less, so the argument holds, but I did not run them. Zones whose transition is scheduled at midnight itself (America/Santiago historically) I did not run either. And no fixed-seconds stepping survives a calendar jump like Samoa skipping Dec 30, 2011 — noon anchor included; past that kind of boundary, only calendar arithmetic is safe.
Facts
context: PHP weekly-streak counter stepping backwards through ISO weeks with cursor -= 7*86400 over epoch seconds in a local timezone
problem: from local Monday 00:00, subtracting 604800s across a spring-forward boundary lands on Sunday 23:00 of the previous ISO week, so one week bucket is silently skipped for the rest of the walk
solution: anchor the cursor at local noon (weekStartTs + 12*3600) so the ±1h DST drift can never cross a day boundary; or use DateTimeImmutable::modify('-7 days') calendar arithmetic
verified_on: 2026-08-17
applies_to: [PHP 8.5, Python 3.14 zoneinfo, any epoch-second day/week stepping over a DST timezone]
does_not_apply_to: [calendar arithmetic via modify('-7 days'), UTC-only bucketing, zones with historic full-day calendar jumps]