Skip to content

Store trip day weather instead of fetching it on every view - #1319

Merged
ericgriffin merged 23 commits into
mainfrom
worktree-trip-day-weather
Aug 27, 2026
Merged

ericgriffin merged 23 commits into
mainfrom
worktree-trip-day-weather

Conversation

@ericgriffin

@ericgriffin ericgriffin commented Aug 26, 2026 •

Copy link
Copy Markdown
Member

Problem

A trip story day that logs no dives showed a weather badge only if the app fetched one from the network, and it fetched on every view. surfaceDayWeatherProvider cached in memory for the provider container's lifetime, but that cache died with the process, so every cold open of a trip re-hit the Open-Meteo archive for weather that cannot change, because it is historical.

Two gaps followed from the same design:

  • Only surface days fetched at all. TripStoryDay.isSurface is false whenever a day has an itinerary row, so travel days, port days, and sea days showed no weather even though the trip records where the diver was.
  • Nothing was durable. The result never reached a backup, another device, or an export.

What this does

Weather for a trip day is now fetched at most once, stored as synced trip data, and read from the database on every later view. Coverage widens from surface days to every trip day whose dives supply no weather of their own.

Storage

A new synced table trip_day_weather holds one row per (trip, date), with a unique index on that pair so the day is the identity: two devices that both fetch the same day converge on one row.

The row id is not a v4 uuid. It is a deterministic UUIDv5 over (tripId, day), following the same convention as importedDiveComputerId and qualityFindingId, and the day is keyed at UTC midnight so it does not move with the device's timezone. Both are load-bearing for convergence and are covered in the follow-up PRs below.

It gets its own table rather than columns on trips or trip_itinerary_days for two reasons:

  1. Row-level conflict resolution. HLC conflicts resolve per row. Parking an automatic, derived write on a row the diver also edits by hand would let a weather write race a trip rename or an itinerary note edit and lose it. Issue Dive sites / Create a new dive site or edit an existing one – Location based on coordinates #1187 is the standing example of partial-entity writes wiping fields.
  2. Surface days have no itinerary row. Materializing one to hold weather would stop the day being a surface day, since TripStoryDay.hasContent counts any itinerary row as content, and would surface a weather-only row in the itinerary tab under a forced dayType.

The full WeatherData payload is stored, not just the three fields the header renders. The API returns them in one response at no extra cost, dive rows already store exactly this set, and widening the table later is a six-place change on a collision-prone version ladder.

Fetch policy

A day is fetched only when all of these hold: no dive on that day supplies weather, the day is not in the future, no row is stored, and the story's map geometry yields a coordinate. Requests run sequentially so a two-week trip does not open with a burst of parallel calls, and rows land progressively.

A miss writes nothing and is retried on the next view. This mirrors the rule already stated on ReefDataCache and BathymetryCache, and it is what makes the design correct against the Open-Meteo archive's few-day publication lag: a day fetched too early simply has no row yet.

Notes for review

Two things worth a look:

hasRenderableWeather deliberately excludes Precipitation.none. WeatherMapper.mapPrecipitation returns non-null always, folding a missing reading into none, even though WeatherData.precipitation is declared nullable. Counting none as data meant a fetch that resolved nothing still stored a row; the header draws nothing for it (weatherIconFor gives none no glyph), so the day would have gone permanently blank with its retry suppressed forever. Pinned by trip_day_weather_test.dart.

The backfill provider is marked // no-tick:. It must not subscribe to the weather tick: it renders nothing (its value is void), and every row it writes would invalidate the pass that wrote them. Its rows reach the UI through tripDayWeatherProvider, which does subscribe.

Schema

v171. Renumbered from 168 as parallel branches landed their own rungs while this PR was open; the ladder is non-contiguous by design, so the audit asserts monotonic, unique, and scalar equals max, never contiguous. Derived by scanning open PR diffs for the scalar rather than grepping main, because two branches writing the same number auto-merge with no conflict marker. minimumCompatibleSchemaVersion stays at 160, since a new table is additive.

Sync

tripDayWeather is registered end to end: hlc target, serializer payload and switch arms, merge order, updatedAt flag, and the trips FK parent so a changeset never imports weather ahead of its trip. Deleting a trip takes its weather rows with it, logged for sync like every other child record.

The FK completeness guard needed the table too; without it, that guard would have silently skipped the new table and a peer's weather row for a locally deleted trip would dangle its FK and fail the whole sync at COMMIT.

Testing

Tests were written first throughout. New: 6 migration tests, 10 repository tests, 5 sync tests, 10 backfill rule tests, 9 entity tests, 5 provider tests, plus rewritten header and story-view tests. surface_day_weather_provider.dart and its test are deleted.

flutter analyze is clean. The full suite was run; the only failures were known cross-test flakes in unrelated areas (media_item_view_provenance_test, security_settings_page_test), each verified passing when run alone.

Not included

  • Range batching. The archive endpoint accepts a date range, so days sharing a coordinate could collapse into one request. Every day is fetched exactly once ever, so this is an optimization rather than a fix.
  • A manual refresh action. Nothing in the UI re-fetches a stored day.

Surface-day weather is fetched from Open-Meteo on every trip view and
never persisted. Store it as trip data in a new synced table instead,
covering every trip day whose dives supply no weather.
Seven tasks: schema v168 and the entity, repository plus trip-delete
cascade, sync registration, pure backfill rules, providers and the
sequential fetch loop, the read path with the per-view fetch deleted,
and whole-project verification.

Also corrects the spec: these tables store epoch milliseconds, not
seconds, and the repository method names now follow
ItineraryDayRepository.
One row per trip day, holding fetched historical weather for days whose
dives supply none. A separate table rather than columns on an existing
row so an automatic derived write never conflicts with hand-entered data
under row-level HLC resolution. The unique index on (trip_id, date) makes
the day the identity, so two devices that both fetch it converge.
Upsert is keyed by (trip, date) rather than by id, reusing an existing
row's id when the day already has one, so two devices that both fetch the
same day converge instead of colliding on the unique index. Deleting a
trip takes its weather rows with it, logged for sync like every other
child record.
Registers tripDayWeather end to end: hlc target, serializer payload and
switch arms, merge order, updatedAt flag, and the trips FK parent so a
changeset never imports weather ahead of its trip. The FK completeness
guard needed the table too, or its foreign key would have gone
unverified.
Pure rules over the built story and the rows already stored: dive-logged
weather wins, future days have no archive to read, stored days are done,
and a day with no mappable point anywhere in the trip has nowhere to ask.
Dates normalize to midnight so a stray time component cannot make a day
refetch on every view.
The display provider rides the table tick; the backfill deliberately does
not, so the rows it writes cannot invalidate it into another pass. A miss
writes nothing and is retried on a later view, which is what makes this
correct against the archive's publication lag.

hasRenderableWeather excludes Precipitation.none: WeatherMapper never
returns null precipitation, defaulting a missing reading to none, and
weatherIconFor gives none no glyph. Counting it would have stored rows
that render as nothing and permanently suppress the retry.
The day header no longer fetches: it renders whatever weather it is
handed, with dive-logged weather still winning. The story view reads the
trip's stored rows once and threads each day's row down, watching above
the LayoutBuilder because that builder runs at layout time, not build
time. surfaceDayWeatherProvider and its per-view Open-Meteo call are
deleted.

The two new providers default to inert in getBaseOverrides so no widget
test reaches a real repository or the network.
The change-tick architecture guard flags any provider that reads a
repository without subscribing to a tick. This one must not subscribe:
it renders nothing, and the rows it writes would invalidate the pass that
wrote them. The marker has to sit within twelve lines of the declaration
for the scanner to see it.

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 makes trip-story day weather durable by storing historical weather per (trip, date) in a new synced trip_day_weather table, and rendering badges from stored data instead of fetching from the network on each view. It also broadens coverage from surface days to any trip day whose dives do not provide renderable weather.

Changes:

  • Add synced trip_day_weather storage (schema v168), repository, and sync registration for cross-device/backup durability.
  • Add a backfill/provider flow that fetches missing historical weather sequentially and persists only renderable results.
  • Update trip story UI and tests so the day header is render-only (no fetching), reading stored weather from the DB and removing surfaceDayWeatherProvider.

Reviewed changes

Copilot reviewed 25 out of 25 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
lib/core/database/database.dart Adds TripDayWeather Drift table, v168 migration ladder entry, and idempotent schema assertion + unique index.
lib/features/trips/domain/entities/trip_day_weather.dart Introduces domain entity for stored trip-day weather with renderability logic and mapping to header view model.
lib/features/trips/data/repositories/trip_day_weather_repository.dart Adds DB repository for per-trip weather reads, upserts, deletes, and sync pending/deletion logging.
lib/features/trips/domain/services/trip_day_weather_backfill.dart Adds pure backfill target selection rules based on story + stored rows.
lib/features/trips/presentation/providers/trip_day_weather_providers.dart Adds providers for reading stored rows and running backfill fetch loop.
lib/features/trips/presentation/widgets/story/trip_story_view.dart Watches stored weather once per trip and triggers backfill; threads stored rows into day headers.
lib/features/trips/presentation/widgets/story/trip_story_day_header.dart Removes fetch behavior; renders only the provided (dive-logged or stored) weather.
lib/features/trips/presentation/providers/surface_day_weather_provider.dart Deleted (replaced by stored weather + backfill).
lib/features/trips/data/repositories/trip_repository.dart Ensures trip deletion cascades to stored trip-day weather rows.
lib/core/data/repositories/sync_repository.dart Registers tripDayWeather as an HLC target.
lib/core/services/sync/sync_service.dart Adds tripDayWeather to merge order, updatedAt map, and FK parent completeness map.
lib/core/services/sync/sync_data_serializer.dart Adds tripDayWeather to sync payload, export/import switch arms, and delta export.
test/core/database/migration_v168_trip_day_weather_test.dart Adds migration + schema assertions for v168 and unique-per-(trip,date) behavior.
test/features/trips/data/repositories/trip_day_weather_repository_test.dart Adds repository tests for round-trip persistence, scoping, and cascade delete.
test/features/trips/domain/entities/trip_day_weather_test.dart Adds tests for hasRenderableWeather, toStoryWeather, and copyWith semantics.
test/features/trips/domain/services/trip_day_weather_backfill_test.dart Adds tests for backfill targeting rules (skip conditions, normalization, ordering).
test/features/trips/presentation/providers/trip_day_weather_providers_test.dart Adds provider tests for sequential fetch, skip rules, and “don’t store unrenderable” behavior.
test/features/trips/presentation/providers/surface_day_weather_provider_test.dart Deleted (provider removed).
test/features/trips/presentation/widgets/story/trip_story_day_header_test.dart Updates widget tests to pass stored weather directly and assert precedence vs dive-logged weather.
test/features/trips/presentation/widgets/story/trip_story_view_test.dart Updates story view tests to override trip-day weather providers and verify no render-time fetch.
test/core/services/sync/trip_day_weather_sync_test.dart Adds sync serializer round-trip and delta-export tests for tripDayWeather.
test/core/services/sync/sync_parent_refs_completeness_test.dart Adds parent-ref completeness coverage for trip_day_weather.
test/helpers/mock_providers.dart Adds base overrides to keep trip-day weather inert by default in widget tests.
docs/superpowers/specs/2026-08-26-trip-day-weather-storage-design.md Design spec documenting the motivation, schema, sync, and fetch policy.
docs/superpowers/plans/2026-08-26-trip-day-weather-storage.md Implementation plan documenting step-by-step work and constraints.

💡 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/services/trip_day_weather_backfill.dart Outdated
Comment thread lib/features/trips/data/repositories/trip_day_weather_repository.dart Outdated
@ericgriffin ericgriffin self-assigned this Aug 27, 2026
@ericgriffin ericgriffin added the enhancement New feature or request label Aug 27, 2026
@ericgriffin ericgriffin moved this from Backlog to In review in Submersion Release Tracker Aug 27, 2026
v168 was already claimed and pushed by PR #1237 (issue #638,
buddies.is_favorite). That claim was resolved locally and not yet pushed when
this branch picked its number, so the open-PR diff scan this plan prescribes
could not see it. Two branches writing the same scalar auto-merge with no
conflict marker, so the collision would have surfaced only as a database
silently skipping a rung.

Moves all six coupled sites together plus the test filename: the scalar, the
migrationVersions entry, the _assertTripDayWeatherSchema docstring, the
onUpgrade guard, its reportProgress twin, and the beforeOpen backstop comment.
The migration test is renamed to migration_v171_trip_day_weather_test.dart with
its greaterThanOrEqualTo and contains assertions updated. Its stranded-database
fixture moves from 168 to 171: 168 is now a real rung owned by #1237, so a
database stamped there upgrades normally and no longer exercises the backstop.

The design doc and plan are updated too, including the reasoning passages that
were wrong rather than merely stale: the plan recorded #1237 as a stale v161
claim when it was a live v168 one. Both now say to scan every worktree's
working-tree scalar alongside open PR diffs, and to re-run both immediately
before pushing rather than only when picking the number.

Ladder is left non-contiguous by design: 165 #1290, 166 #1300, 167 #1276,
168 #1237, 169 the dive-computer gear-twin branch, 170 #1322.

Verified: ladder monotonic, unique, scalar == max, 168 absent; helper defined
exactly once and referenced three times; guard and twin both at 171;
flutter analyze clean; flutter test test/core/database/ 471 passed.
Copilot AI review requested due to automatic review settings August 27, 2026 00:31

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 25 out of 25 changed files in this pull request and generated 2 comments.

Comment thread lib/features/trips/presentation/providers/trip_day_weather_providers.dart Outdated
Comment thread lib/core/database/database.dart
Addresses two review comments on PR #1319.

A dive whose weather lookup resolved nothing still stores
Precipitation.none, because WeatherMapper never returns null
precipitation. TripStoryDay.weather was therefore non-null for such a
day, so the backfill skipped it and the header drew nothing: a
permanently badge-free day. The renderability rule now lives on
TripStoryDayWeather and is shared by three callers, the backfill skip
decision, the header's precedence, and the entity's is-this-worth-storing
check, so the three cannot drift apart.

The repository also normalizes the day to local midnight on both read and
write. It owns the (trip, date) uniqueness invariant, and a caller
passing a time component, or a row arriving through sync from a peer,
would otherwise store a second row for the same calendar day that no
midnight-keyed lookup could see.
ericgriffin and others added 3 commits August 26, 2026 22:02
A v4 id per device meant two devices that both fetched the same day
inserted two rows. The serializer upserts by primary key, so the peer's
differing id missed the ON CONFLICT target and hit the unique
(trip_id, date) index instead: SqliteException 2067, thrown inside the
merge transaction, which aborts the whole sync pull rather than one row.
Reproduced in a test before fixing.

The id is now a UUIDv5 over (tripId, dayMillis), following the same
convention as importedDiveComputerId and qualityFindingId, whose
docstring already warned that a unique constraint on a replicated table
turns an inbound insert into a throw rather than a merge. The repository
derives it and ignores whatever id a caller passes.

_mapRow also normalizes the entity's date, not just the map key, so a row
carrying a time component cannot hand a time-bearing date to downstream
logic.
A row for the same trip and calendar day that this repository did not write
is not a hypothetical: a peer on a build that predates the derived id, or a
database written before this class normalized, produces one. Two things went
wrong with it.

The upsert threw. The unique index is on (trip_id, date) but Drift emits
ON CONFLICT("id"), so a foreign-id row on the same midnight is not a conflict
the statement can absorb: the insert misses the ON CONFLICT target and hits
the index instead. The day could not be written at all, not merely written
twice.

The read collapsed it arbitrarily. getForTrip keyed a map by normalized day
over an unordered result, so which of two same-day rows showed depended on
the order SQLite happened to return them in.

upsert now deletes every same-day row whose id is not the derived one before
inserting the canonical row, both in one transaction so a day is never left
with its old row gone and no new one in its place. The strays are tombstoned
after the commit: they are synced records, and dropping one without a
tombstone lets the peer that sent it hand it straight back. createdAt is the
minimum across the day's rows, because a stray is this day under an old id
rather than a different record.

Reads resolve deterministically instead: the canonical row wins, then the
most recently updated, then the id. They deliberately do not clean up, since
a write here would fire the table tick the display provider subscribes to and
invalidate the read in flight.

The backfill no longer passes a placeholder id. The local-midnight conversion
moved next to tripDayWeatherRowId as tripDayMillis, so the caller derives the
real id and the repository's _dayKey delegates to the same function; the two
agree by construction rather than by comment. The repository still derives
its own id and ignores the caller's.
…r-review

Address review comments: unrenderable dive weather and day-key normalization
Copilot AI review requested due to automatic review settings August 27, 2026 05:31

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 26 out of 26 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

Previously missed (2) — in code that hasn't changed since the last review.

lib/features/trips/presentation/widgets/story/trip_story_view.dart:254

  • This duplicates the "normalize day to local midnight millis" logic even though trip_day_weather.dart already provides tripDayMillis() for consistency across callers. Using the shared helper reduces the risk of key drift if the normalization rule ever changes and keeps the lookup aligned with the repository/storage key.
    // Stored rows are keyed on local midnight millis.
    final dayDate = DateTime(day.date.year, day.date.month, day.date.day);
    final stored = storedWeather[dayDate.millisecondsSinceEpoch];

test/features/trips/data/repositories/trip_day_weather_repository_test.dart:139

  • This test uses a nested immediately-invoked async closure (return () async { ... }();), which is inconsistent with the surrounding tests and makes failures harder to read/diagnose in stack traces. The test body can be declared async directly.
      return () async {
        await repository.upsert(sample(date: DateTime(2026, 3, 8, 17, 30)));

        final stored = await repository.getForTrip(testTripId);

Comment thread lib/features/trips/presentation/providers/trip_day_weather_providers.dart Outdated
@codecov

codecov Bot commented Aug 27, 2026 •

Copy link
Copy Markdown

@github-actions

github-actions Bot commented Aug 27, 2026 •

Copy link
Copy Markdown
Contributor

📦 Build artifacts for this PR · commit fc2ede8

Platform Download
Android (APK) android-apk
macOS macos-build
Windows windows-build
Linux linux-build

Artifacts expire in 7 days. Downloading requires being signed in to GitHub. macOS needs two extractions: unzip the downloaded artifact, then unzip the submersion-macos.zip inside it to get a runnable submersion.app. The build is ad-hoc signed — right-click → Open on first launch.

Updated automatically on each push.

Let the weather backfill actually retry a miss on the next view
Copilot AI review requested due to automatic review settings August 27, 2026 11:43

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 26 out of 26 changed files in this pull request and generated 1 comment.

Comment thread lib/features/trips/domain/entities/trip_day_weather.dart Outdated
ericgriffin added a commit that referenced this pull request Aug 27, 2026
Resolves the schema conflict in database.dart and moves this branch's rung
off v169.

main landed v170 (#1322, gas_consumption_display) while this branch held
169. main had reserved 169 here, but a reservation stops being safe the
moment main's scalar passes it: a database already at 170 skips
'if (from < 169)' entirely, so backfillDiveComputerGearTwins would never
run and no existing diver would get gear twins for the computers they
already own. The column alone would still arrive via the beforeOpen
backstop, which is exactly what would have made this hard to notice.

The rung therefore moves 169 -> 175, the next number no open branch claims
(171 #1319, 172 #1328, 173 #1276, 174 #603). 169 is now permanently
skipped, alongside 162 and 167. The v169 doc references across the gear
backfill, gear identity, sync service, buoyancy feature and dive-computer
repository are swept to v175, and
migration_v169_dive_computer_gear_test.dart is renamed to migration_v175_
with its ladder assertions and its stranded-at-the-rung PRAGMA updated.

analyze clean; 1,382 tests pass across core/database, core/buoyancy,
features/equipment and architecture.
Resolves the schema-ladder conflict in database.dart: main landed v170
(#1322, gas_consumption_display) while this branch held v171, so main's
ladder entry, onUpgrade step and beforeOpen backstop are kept ahead of this
branch's and currentSchemaVersion stays at 171. The v171 comment is
refreshed: 167 and 169 are now permanently skipped rather than claimed,
because main landed past both and PR #1276 moved to 173, PR #1320 to 175.

Also moves the existing no-tick marker on tripDayWeatherBackfillProvider
onto the declaration itself. main's provider_change_tick_test scans only
eleven lines above a declaration, and the marker sat above a thirteen-line
doc comment, so the guard never saw it and reported the provider as a
violation. The reasoning in the marker is unchanged and was already
correct; only its position moved.
ericgriffin added a commit that referenced this pull request Aug 27, 2026
Two conflicts, one of which was not mechanical.

database.dart: main landed v170 (#1322, gas_consumption_display) while this
branch held v172, so main's ladder entry, onUpgrade step and backstop are
kept ahead of this branch's and currentSchemaVersion stays at 172. The v172
comment is corrected: 169 is permanently skipped now that main landed 170
past it and PR #1320 moved up to 175, and 171 belongs to PR #1319.

dive_repository_impl.dart: main added the whole conditions-fetch block
(_hasConditionsGap, _needsConditions, fillConditions) immediately above
getDivesNeedingSiteMatch, and carried its own doc comment for that method
along with it. All of main's new code is kept, but main's doc for
getDivesNeedingSiteMatch is dropped in favour of this branch's: the
surviving body is this branch's, which also matches on photo GPS and
excludes dives whose suggestion was dismissed. Keeping main's doc would
have left a correct method described by a stale comment that no test would
catch.

analyze clean; 4,399 tests pass across core/database, dive_log, dive_sites,
weather and architecture.
ericgriffin added a commit to readme42/submersion that referenced this pull request Aug 27, 2026
Resolves the schema-ladder conflict in database.dart: main landed v170
(submersion-app#1322, gas_consumption_display) while this branch held v173, so main's
ladder entry, onUpgrade step and beforeOpen backstop are kept ahead of this
branch's and currentSchemaVersion stays at 173, which is still above main.

The v173 comment is refreshed for the new ladder: main has now also taken
170, 165 is still claimed by PR submersion-app#1290, 171 by PR submersion-app#1319 and 172 by PR submersion-app#1328,
and 169 has joined 167 as permanently skipped, since main landed 170 past
PR submersion-app#1320 and that branch moved up to 175.

analyze clean; 3,719 tests pass across core/database, dive_log and
architecture.
The strays were deleted inside the transaction but tombstoned after it, so a
logDeletion failure could leave a stray deleted locally with nothing to stop
the peer that sent it from handing it back.

I had justified that ordering as the safe direction, on the assumption that a
resurrected stray would be cleaned up by the next upsert for the day. It would
not. TripDayWeatherBackfill.targetsFor skips any day that already has a stored
row, so once the canonical row exists the day is never upserted again and the
stray would sit in the table for good, syncing.

The delete, the insert, and both pieces of sync bookkeeping now commit
together, with notifyLocalChange still fired after the commit. This is the
pattern the #553 review established in BuddyRepository.deleteBuddy, for the
same reason: a row deleted without its tombstone resurrects on the next sync.
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

Copilot reviewed 26 out of 26 changed files in this pull request and generated 5 comments.

Suppressed comments (3)

lib/features/trips/domain/entities/trip_day_weather.dart:18

  • tripDayMillis derives the day identity from local-midnight epoch millis. That makes the (trip, day) key (and thus the UUIDv5 row id and unique (trip_id, date) constraint) vary by device timezone, which breaks cross-device convergence and contradicts the PR description’s UTC-midnight requirement.
/// Local midnight for [date], as epoch milliseconds.
///
/// The day is the identity of a weather row, so this is what turns a
/// DateTime into one. Shared rather than reimplemented per caller: the row
/// id, the repository's storage key, and the map key reads come back under
/// all have to agree, and three copies of the same two lines would drift.
int tripDayMillis(DateTime date) =>
    DateTime(date.year, date.month, date.day).millisecondsSinceEpoch;

lib/features/trips/data/repositories/trip_day_weather_repository.dart:218

  • Same issue as getForTrip: _rowsForDay converts r.date using the local timezone. That can make a UTC-midnight day fall on the previous/next local date and prevent same-day stray detection.
    return rows
        .where(
          (r) =>
              _dayKey(DateTime.fromMillisecondsSinceEpoch(r.date)) == dayMillis,
        )
        .toList();

lib/features/trips/data/repositories/trip_day_weather_repository.dart:260

  • _mapRow normalizes the stored day using DateTime.fromMillisecondsSinceEpoch without isUtc. With a UTC-midnight day key, that can hand downstream logic a date that is off by one day in some timezones.
      date: DateTime.fromMillisecondsSinceEpoch(
        _dayKey(DateTime.fromMillisecondsSinceEpoch(row.date)),
      ),

Comment thread lib/features/trips/data/repositories/trip_day_weather_repository.dart Outdated
Comment thread lib/features/trips/domain/services/trip_day_weather_backfill.dart Outdated
Comment thread lib/features/trips/presentation/widgets/story/trip_story_view.dart Outdated
Comment thread lib/core/database/database.dart Outdated
Comment thread test/features/trips/presentation/widgets/story/trip_story_view_test.dart Outdated
ericgriffin and others added 2 commits August 27, 2026 17:40
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.
Key a trip weather day in UTC so devices in different timezones converge
Copilot AI review requested due to automatic review settings August 27, 2026 21:47
The backfill's skip check was the one read site the UTC move missed. It built
its lookup from a local DateTime's epoch while getForTrip keys by
tripDayMillis, which is UTC midnight. The two agree only at UTC+0, so on any
other device every stored day read as missing and was fetched again on every
view: exactly the behaviour storing the rows exists to stop.

The tests could not have caught it. Each one keyed its stored fixture the same
local way the buggy lookup did, so both sides moved together and the mismatch
stayed invisible in any timezone. They now key through tripDayMillis, which is
what getForTrip actually returns, and one names the invariant outright.

The repository's raw-insert fixtures had the same shape: rows placed at a
local DateTime's epoch land on a neighbouring UTC day under a large enough
offset and stop being strays for the day under test. They now build from the
day key plus an explicit offset, so they assert reconciliation rather than
timezone arithmetic. Two had already been half-migrated to DateTime.utc.

The trip_day_weather.date schema comment still described local midnight, which
is the scheme this branch replaced, and it sits on the column that is half the
row identity.

Verified in four timezones, including a half-hour offset: 634 trips tests pass
under UTC, America/New_York, Asia/Tokyo and Australia/Adelaide. Before the fix
the new tests fail under all three non-UTC zones.

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 26 out of 26 changed files in this pull request and generated 5 comments.

Suppressed comments (2)

lib/features/trips/domain/services/trip_day_weather_backfill.dart:63

  • TripDayWeatherBackfill.targetsFor checks stored.containsKey(date.millisecondsSinceEpoch), but stored rows are keyed with tripDayMillis(...) (UTC-midnight day key) from TripDayWeatherRepository. In non-UTC timezones this will miss existing rows, causing unnecessary refetches and preventing the "already stored" skip rule from working.
      // Ask with the key the rows are actually stored under. getForTrip keys
      // by tripDayMillis, which is UTC midnight so two devices converge on one
      // row; a local DateTime's epoch agrees with that only at UTC+0. Building
      // the lookup from the local value instead made every stored day read as
      // missing on any other device and refetch on every view, which is the

lib/core/database/database.dart:157

  • The trip_day_weather.date column is documented as "local midnight", but the implementation derives and stores a UTC-midnight day key (tripDayMillis) so the identity is timezone-stable across devices. This comment should be updated to avoid future code writing local-midnight millis into the same column.
  /// UTC midnight for the day, as epoch milliseconds (milliseconds being the
  /// convention TripItineraryDays.date is written with).
  ///

Comment thread lib/features/trips/domain/entities/trip_day_weather.dart
Copilot AI review requested due to automatic review settings August 27, 2026 21:54

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 26 out of 26 changed files in this pull request and generated no new comments.

Suppressed comments (4)

Previously missed (1) — in code that hasn't changed since the last review.

test/core/services/sync/trip_day_weather_sync_test.dart:16

  • This test uses DateTime(2026, 3, 8).millisecondsSinceEpoch as the canonical trip-day key. That value depends on the machine timezone, but trip-day identity in production is UTC-midnight via tripDayMillis(...), so this test can fail (or validate the wrong behavior) when run under a non-UTC TZ.

This issue also appears in the following locations of the same file:

  • line 37
  • line 124
  final dayMillis = DateTime(2026, 3, 8).millisecondsSinceEpoch;
  final rowId = tripDayWeatherRowId(tripId: 'trip-1', dayMillis: dayMillis);

test/core/services/sync/trip_day_weather_sync_test.dart:42

  • The inserted fixture row is keyed using DateTime(...).millisecondsSinceEpoch (local-midnight epoch). Since trip_day_weather.date is defined as a UTC-midnight day key (via tripDayMillis), the test should insert using the same dayMillis key; otherwise the row id and unique (trip_id, date) behavior being tested can vary by timezone.
            id: tripDayWeatherRowId(
              tripId: 'trip-1',
              dayMillis: DateTime(2026, 3, 8).millisecondsSinceEpoch,
            ),
            tripId: 'trip-1',
            date: DateTime(2026, 3, 8).millisecondsSinceEpoch,

test/core/services/sync/trip_day_weather_sync_test.dart:142

  • This test re-derives the day key using DateTime(...).millisecondsSinceEpoch, which is timezone-dependent. For consistency with the production invariant (UTC-midnight keys), use tripDayMillis(...) for both the base day and the comparison day.
  test('the row id is derived from trip and day, not minted per device', () {
    final day = DateTime(2026, 3, 8).millisecondsSinceEpoch;

    expect(
      tripDayWeatherRowId(tripId: 'trip-1', dayMillis: day),
      tripDayWeatherRowId(tripId: 'trip-1', dayMillis: day),
    );
    expect(
      tripDayWeatherRowId(tripId: 'trip-1', dayMillis: day),
      isNot(tripDayWeatherRowId(tripId: 'trip-2', dayMillis: day)),
    );
    expect(
      tripDayWeatherRowId(tripId: 'trip-1', dayMillis: day),
      isNot(
        tripDayWeatherRowId(
          tripId: 'trip-1',
          dayMillis: DateTime(2026, 3, 9).millisecondsSinceEpoch,
        ),
      ),

lib/features/trips/domain/entities/trip_day_weather.dart:80

  • TripDayWeather.date is described as “Local midnight”, but the storage and lookup key for a trip day is explicitly UTC-midnight (tripDayMillis / tripDayDate), and repository reads normalize back to UTC. This docstring is likely to mislead future callers into thinking local-midnight semantics are required or preserved.
  /// Local midnight for the day this describes.
  final DateTime date;

@ericgriffin
ericgriffin merged commit 734c4c7 into main Aug 27, 2026
26 checks 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-trip-day-weather branch August 27, 2026 22:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants