Skip to content

Key a trip weather day in UTC so devices in different timezones converge - #1337

Merged
ericgriffin merged 2 commits into
worktree-trip-day-weatherfrom
worktree-tdw-utc-daykey
Aug 27, 2026
Merged

ericgriffin merged 2 commits into
worktree-trip-day-weatherfrom
worktree-tdw-utc-daykey

Conversation

@ericgriffin

Copy link
Copy Markdown
Member

Addresses the remaining review comments on #1319. Stacked on worktree-trip-day-weather.

The day key was device-local

tripDayMillis derived the day identity as DateTime(y, m, d).millisecondsSinceEpoch. That is a local DateTime, so its epoch value differs in every timezone. The calendar triple is device-independent (the story builds each day from the trip's stored start date and a day index), but the number derived from it was not.

Consequences, since that number is the row identity:

  • it feeds tripDayWeatherRowId, so two devices derived different UUIDv5 primary keys for the same trip day and never converged
  • it is the date half of the unique (trip_id, date) index, so the two rows did not even collide; each device quietly stored and refetched its own copy

Divers cross timezones by definition. A single diver flying home from a trip is enough to trigger this, without a second device involved.

The key is now UTC midnight for the calendar day. The fields are taken as given rather than converted: toUtc() would shift a late evening onto the following day, and the story hands over a date whose y/m/d is the day it means. That also matches the app's existing wall-clock-as-UTC convention for dive timestamps.

The read path needed the inverse

Storing in UTC is only half a fix. DateTime.fromMillisecondsSinceEpoch returns a local DateTime, so re-extracting y/m/d from a stored UTC-midnight value read the calendar fields in the device's frame: on any negative UTC offset, UTC midnight is the previous evening locally, and the day walked backwards on every round trip. tripDayDate is the explicit inverse, and the three read sites in the repository use it.

The view was computing the key itself

trip_story_view.dart built the lookup key inline rather than through the shared helper. Left alone it would have looked up local midnight against UTC-keyed rows, found nothing, and silently rendered no weather badges at all. It goes through tripDayMillis now, so the lookup cannot drift from the write. The existing view test caught this, which is how I know it exercises the real lookup.

Docs

The design doc still described id as "uuid v4" and date as local midnight. Both now describe what is implemented, with the reasons.

The PR description on #1319 still says v168 while the ladder is at v171; that is a description edit on the parent PR, not a code change here.

Testing

  • tripDayMillis is the calendar day in UTC, not local midnight. This fails on any machine that is not UTC before the change (verified on UTC-4).
  • the calendar fields are taken as given, never shifted. This guards the wrong fix, toUtc(), which would move a late evening onto the next day.
  • tripDayWeatherRowId is stable for one calendar day regardless of the time of day handed in.
  • Repository fixtures now key through the shared helper rather than restating the conversion, so a future change to the derivation cannot leave the tests agreeing with themselves and disagreeing with the code.

One behaviour note: "same day" is now a UTC question, so a legacy row stored off-midnight can fall on a neighbouring UTC day and stop being reconciled as a stray. That only affects rows written before this branch derived ids, and the table is unreleased.

1,848 tests pass across trips, sync, and database. flutter analyze clean.

tripDayMillis built the key from a local DateTime(y, m, d), whose epoch
value differs in every timezone. Two devices looking at the same trip day
derived different keys, therefore different UUIDv5 row ids, and never
converged: each stored and refetched its own copy. Divers cross timezones
by definition, and one diver flying home is enough to trigger it.

The key is now UTC midnight for the calendar day, taking the fields as
given rather than converting: toUtc() would shift a late evening onto the
next day, and the story hands over a date whose y/m/d is the day it means,
matching the app's wall-clock-as-UTC convention for dive timestamps.

Reading needed the inverse or the fix would have been half a fix.
DateTime.fromMillisecondsSinceEpoch returns a LOCAL DateTime, so
re-extracting y/m/d walked the day backwards on every negative offset.
tripDayDate is that inverse, and the three read sites use it.

The story view was computing the key inline instead of through the shared
helper, so it would have looked up local midnight against UTC-keyed rows
and silently rendered no badges at all. It goes through tripDayMillis now,
which is what makes the lookup unable to drift from the write.

Also updates the design doc, which still described the id as a v4 uuid and
the date column as local midnight.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes trip-day weather row convergence across devices by making the “day key” timezone-independent: trip days are now keyed at UTC midnight for the calendar day, and the read path explicitly inverts that mapping so stored keys don’t drift when interpreted on devices in negative UTC offsets.

Changes:

  • Update tripDayMillis to use UTC midnight for day identity and add tripDayDate for correct UTC interpretation on readback.
  • Switch repository and UI lookup paths to consistently key/interpret stored rows via the shared helpers (avoiding inline/local-midnight derivations).
  • Update tests and design doc to reflect UTC-keyed behavior and ensure stability across times-of-day.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
test/features/trips/presentation/widgets/story/trip_story_view_test.dart Uses tripDayMillis for fixture map keys to match production lookup.
test/features/trips/domain/entities/trip_day_weather_test.dart Adds coverage for UTC day-keying and stable deterministic row IDs.
test/features/trips/data/repositories/trip_day_weather_repository_test.dart Updates expectations to UTC-midnight storage and keying through tripDayMillis.
lib/features/trips/presentation/widgets/story/trip_story_view.dart Uses shared helper for stored-weather lookup to prevent key drift.
lib/features/trips/domain/entities/trip_day_weather.dart Implements UTC day keying and adds tripDayDate helper for correct readback.
lib/features/trips/data/repositories/trip_day_weather_repository.dart Reads stored date values in UTC (via tripDayDate) before day-key derivation and normalization.
docs/superpowers/specs/2026-08-26-trip-day-weather-storage-design.md Updates design doc to describe deterministic UUIDv5 and UTC-midnight day identity.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread lib/features/trips/domain/entities/trip_day_weather.dart Outdated
@ericgriffin ericgriffin added the bug Something isn't working label Aug 27, 2026
@ericgriffin ericgriffin self-assigned this Aug 27, 2026
@ericgriffin ericgriffin moved this from Backlog to In review in Submersion Release Tracker Aug 27, 2026
tripDayDate was documented as returning "a UTC DateTime at midnight". It
returns the instant it is given, read in UTC, and normalizes nothing, so it
is the inverse of tripDayMillis only for a value tripDayMillis produced. The
repository deliberately calls it on raw stored dates that can carry a time
component, so the claim was not merely loose. It now says what the function
does and tells callers to run the result through tripDayMillis when they want
the day rather than the instant.

Three more comments still described the local-midnight scheme this branch
replaced:

tripDayWeatherRowId told callers the day key "must already be normalized to
local midnight", which is the exact mistake the branch exists to fix. A
caller who followed it would derive a per-timezone id and lose the
convergence the deterministic id buys.

TripDayWeatherRepository._dayKey was still headed "Local midnight for [date]"
while delegating to a UTC helper.

_rowsForDay justified filtering in Dart by claiming SQLite cannot derive
local midnight without the zone and its DST history. That reason died with
the UTC move: the day is now integer arithmetic on the stored millis. The
filter stays in Dart because a trip holds a few dozen rows and one Dart
function cannot drift from tripDayMillis, which is the honest reason.

No behaviour change.
Copilot AI review requested due to automatic review settings August 27, 2026 21:40

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.

@ericgriffin
ericgriffin merged commit fc3791e into worktree-trip-day-weather Aug 27, 2026
1 check passed
@github-project-automation github-project-automation Bot moved this from In review to Done in Submersion Release Tracker Aug 27, 2026
@ericgriffin
ericgriffin deleted the worktree-tdw-utc-daykey branch August 27, 2026 21:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants