From 615941a51142f23296f6c539ad97c6d79f4d8c79 Mon Sep 17 00:00:00 2001 From: Cameron Cordes Date: Sat, 26 Sep 2026 23:38:50 -0400 Subject: [PATCH] fix(dates): assumed_server fallback uses the offset at the photo's wall, not now's A container whose /etc/localtime is a copy rather than a symlink makes server_zone() return None; the assumed_server rung then fell back to Local::now()'s offset, which is only correct for captures from the current season. Historical winter walls on a DST host skewed date_utc by an hour. Resolve the process-local zone at the wall instead (earliest reading for ambiguous walls). Surfaced by test-server validation of #119. --- src/camera_tz.rs | 45 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/src/camera_tz.rs b/src/camera_tz.rs index 9c4d46c..8c697bf 100644 --- a/src/camera_tz.rs +++ b/src/camera_tz.rs @@ -170,7 +170,16 @@ pub fn resolve_camera_offset( } let minutes = match server_zone { Some(zone) => offset_minutes_for_zone(zone, wall), - None => Local::now().offset().fix().local_minus_utc() / 60, + // Zone name unresolvable on this host: assume the process-local + // zone, but at the PHOTO's wall (a DST host's current-season + // offset would skew historical captures by an hour). Ambiguous + // walls take the earliest (pre-transition) reading; truly + // nonexistent local times fall back to the current offset. + None => match Local.from_local_datetime(&wall) { + LocalResult::Single(dt) => dt.offset().fix().local_minus_utc() / 60, + LocalResult::Ambiguous(dt, _) => dt.offset().fix().local_minus_utc() / 60, + LocalResult::None => Local::now().offset().fix().local_minus_utc() / 60, + }, }; OffsetResolution { minutes, @@ -338,6 +347,40 @@ mod tests { assert_eq!(res.source, TzSource::AssumedServer); } + /// The offset the process-local zone assigns to a wall clock — + /// independent of the current season. + fn local_offset_at(wall: NaiveDateTime) -> i32 { + Local + .from_local_datetime(&wall) + .earliest() + .map(|dt| dt.offset().fix().local_minus_utc() / 60) + .unwrap_or_else(|| Local::now().offset().fix().local_minus_utc() / 60) + } + + #[test] + fn assumed_server_fallback_is_dst_aware_at_the_wall_when_zone_unresolvable() { + // `server_zone() = None` (e.g. a container whose /etc/localtime is a + // copy, not a symlink): the fallback assumes the process-local zone + // AT THE PHOTO'S WALL, not the current season's offset. On a DST + // host this test catches the old behavior whenever the host is in + // either season (one of the two walls disagrees with `now`); + // on a fixed-offset host both assertions hold trivially. + let evidence = OffsetEvidence::default(); + let winter = resolve_camera_offset(wall_january(), &evidence, None, &StubLookup(None)); + let summer = resolve_camera_offset(wall_july(), &evidence, None, &StubLookup(None)); + assert_eq!( + winter.minutes, + local_offset_at(wall_january()), + "winter wall must carry the winter offset" + ); + assert_eq!( + summer.minutes, + local_offset_at(wall_july()), + "summer wall must carry the summer offset" + ); + assert_eq!(winter.source, TzSource::AssumedServer); + } + #[test] fn gps_miss_falls_through_to_server_zone() { let evidence = OffsetEvidence {