feat(dates): container video dates, filesystem branch, manual edit (#115)

The exiftool branch of the date waterfall now pulls raw strings with
group prefixes (one `exiftool -j -q -G1` subprocess per batch, no
`-d %s`) and classifies each candidate:

- zoned strings (own offset / Z) are true instants;
- naive strings from container groups (QuickTime/Track*/Apple) are
  UTC-rendered creation_time fields -> true instants (utc_direct).
  Running them through the camera wall-clock cascade was the
  "videos off by 4-5 hours" bug: it reinterpreted a UTC instant in
  the server zone;
- naive strings from photo-EXIF groups are camera wall clocks and run
  the offset cascade (OffsetTime* pairing + GPS evidence pulled in
  the same exiftool pass).

The legacy date_taken column stays byte-identical to the old
server-local interpretation (reproduced in Rust, DST-aware); the
new-contract date_utc / camera_offset_minutes / date_taken_tz_source
columns carry the true instant.

Manual date edit (POST /image/exif/date) reinterprets the
naive-as-UTC epoch the client sent as a wall clock in the user's
display zone (camera_tz::manual_date_resolution, tz_source =
'manual'), snapshots the pre-override new-contract triple next to the
existing legacy snapshot pair (3 new columns, migration
2026-09-25-000000), and revert restores both pairs.

ADR-0001 records the container-date finding; CLAUDE.md date-pipeline
section updated.
This commit is contained in:
Cameron Cordes committed 2026-09-25 15:26:26 -04:00
1 parent 6f020f4201
commit d2263be6ac
13 files changed
+866 -178

No files matched your search

+71 -10
View File
@@ -40,9 +40,8 @@ pub enum TzSource {
/// The resolver branch already produced a true UTC instant (exiftool,
/// filesystem time, unix-timestamp filename). No offset is involved.
UtcDirect,
/// An operator set the date manually.
// Written by the manual-edit rework (#118); nothing constructs it yet.
#[allow(dead_code)]
/// An operator set the date manually (POST /image/exif/date, ticket
/// #115): the wall the user picked, interpreted in their display zone.
Manual,
}
@@ -218,13 +217,39 @@ pub const DEFAULT_DISPLAY_TZ: &str = "America/New_York";
/// NULL / empty / unparseable prefs fall back to the app default, so
/// read paths (tickets #119/#120) never have to special-case the
/// unset state themselves.
pub fn display_zone_for(stored: Option<&str>) -> &str {
pub fn display_zone_for(stored: Option<&str>) -> String {
match stored.map(str::trim).filter(|s| !s.is_empty()) {
Some(name) if Tz::from_str(name).is_ok() => name,
_ => DEFAULT_DISPLAY_TZ,
Some(name) if Tz::from_str(name).is_ok() => name.to_string(),
_ => DEFAULT_DISPLAY_TZ.to_string(),
}
}
/// Pure seam for the manual date-edit endpoints (ADR-0001, ticket #115):
/// reinterpret the naive-as-UTC epoch the UI sent as a wall clock in the
/// user's display zone, and produce the new-contract
/// `(date_utc, camera_offset_minutes)` pair. The caller writes
/// `date_taken_tz_source = 'manual'` itself and stores the epoch verbatim
/// in the legacy `date_taken` column (byte-identical to the pre-rework
/// behavior).
///
/// `stored_zone` is `user_ai_prefs.display_tz` (NULL / unparseable → app
/// default). The offset follows the wall's own date through the zone's DST
/// rules — ambiguous fall-back walls take the earlier (pre-transition)
/// offset; nonexistent spring-forward walls take the zone's offset at the
/// wall-as-UTC reading, within an hour of truth (see
/// [`offset_minutes_for_zone`]). `None` only when the zone name is
/// unparseable, which [`display_zone_for`] already rules out — the caller
/// then leaves the new-contract triple untouched.
pub fn manual_date_resolution(
stored_zone: Option<&str>,
wall_naive_epoch: i64,
) -> Option<(i64, i32)> {
let zone = Tz::from_str(&display_zone_for(stored_zone)).ok()?;
let wall = chrono::DateTime::from_timestamp(wall_naive_epoch, 0).map(|dt| dt.naive_utc())?;
let minutes = offset_minutes_for_zone(zone, wall);
Some((wall_to_instant(wall, minutes), minutes))
}
/// Display-zone offset, in minutes, for a true UTC instant — DST-aware.
/// `None` when the zone name doesn't parse.
#[allow(dead_code)] // consumed by the read-side cutover (#119/#120)
@@ -240,10 +265,13 @@ mod tests {
#[test]
fn display_zone_for_falls_back_to_default() {
assert_eq!(display_zone_for(None), DEFAULT_DISPLAY_TZ);
assert_eq!(display_zone_for(Some("")), DEFAULT_DISPLAY_TZ);
assert_eq!(display_zone_for(Some(" ")), DEFAULT_DISPLAY_TZ);
assert_eq!(display_zone_for(Some("Not/A_Zone")), DEFAULT_DISPLAY_TZ);
assert_eq!(display_zone_for(None).as_str(), DEFAULT_DISPLAY_TZ);
assert_eq!(display_zone_for(Some("")).as_str(), DEFAULT_DISPLAY_TZ);
assert_eq!(display_zone_for(Some(" ")).as_str(), DEFAULT_DISPLAY_TZ);
assert_eq!(
display_zone_for(Some("Not/A_Zone")).as_str(),
DEFAULT_DISPLAY_TZ
);
}
#[test]
@@ -359,6 +387,39 @@ mod tests {
);
}
#[test]
fn manual_date_resolution_display_zone_edt() {
// The UI sends the wall rendered in the display zone, as a
// naive-as-UTC epoch: 1720080000 = "2024-07-04 04:00:00". July in
// New York is EDT (-240): the instant is 08:00:00Z.
let (utc, off) = manual_date_resolution(Some("America/New_York"), 1_720_080_000).unwrap();
assert_eq!(off, -240);
assert_eq!(utc, 1_720_080_000 + 4 * 3600);
}
#[test]
fn manual_date_resolution_display_zone_est() {
// Same function in January: EST (-300). Wall 2024-01-15 04:00:00
// (naive epoch 1705291200) → 09:00:00Z.
let (utc, off) = manual_date_resolution(Some("America/New_York"), 1_705_291_200).unwrap();
assert_eq!(off, -300);
assert_eq!(utc, 1_705_291_200 + 5 * 3600);
}
#[test]
fn manual_date_resolution_positive_zone_and_default() {
// Tokyo is +09:00 year-round: wall 04:00 → previous day 19:00Z.
let (utc, off) = manual_date_resolution(Some("Asia/Tokyo"), 1_720_080_000).unwrap();
assert_eq!(off, 540);
assert_eq!(utc, 1_720_080_000 - 540 * 60);
// NULL stored zone → app default (America/New_York), July → EDT.
let (_, off) = manual_date_resolution(None, 1_720_080_000).unwrap();
assert_eq!(off, -240);
// Unparseable stored zone also falls back to the default.
let (_, off) = manual_date_resolution(Some("Not/A_Zone"), 1_720_080_000).unwrap();
assert_eq!(off, -240);
}
#[test]
fn display_zone_offset_minutes_unknown_zone_is_none() {
assert_eq!(