Store trip day weather instead of fetching it on every view - #1319
Conversation
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.
There was a problem hiding this comment.
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_weatherstorage (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.
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.
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.
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
There was a problem hiding this comment.
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.dartalready providestripDayMillis()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. Thetestbody can be declaredasyncdirectly.
return () async {
await repository.upsert(sample(date: DateTime(2026, 3, 8, 17, 30)));
final stored = await repository.getForTrip(testTripId);
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
|
📦 Build artifacts for this PR · commit
Artifacts expire in 7 days. Downloading requires being signed in to GitHub. macOS needs two extractions: unzip the downloaded artifact, then unzip the Updated automatically on each push. |
Let the weather backfill actually retry a miss on the next view
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.
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.
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.
There was a problem hiding this comment.
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
tripDayMillisderives 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:_rowsForDayconvertsr.dateusing 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
_mapRownormalizes the stored day usingDateTime.fromMillisecondsSinceEpochwithoutisUtc. 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)),
),
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
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.
There was a problem hiding this comment.
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.targetsForchecksstored.containsKey(date.millisecondsSinceEpoch), but stored rows are keyed withtripDayMillis(...)(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.datecolumn 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).
///
There was a problem hiding this comment.
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).millisecondsSinceEpochas the canonical trip-day key. That value depends on the machine timezone, but trip-day identity in production is UTC-midnight viatripDayMillis(...), 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). Sincetrip_day_weather.dateis defined as a UTC-midnight day key (viatripDayMillis), the test should insert using the samedayMilliskey; 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), usetripDayMillis(...)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.dateis 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;
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.
surfaceDayWeatherProvidercached 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:
TripStoryDay.isSurfaceis 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.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_weatherholds 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 asimportedDiveComputerIdandqualityFindingId, 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
tripsortrip_itinerary_daysfor two reasons:TripStoryDay.hasContentcounts any itinerary row as content, and would surface a weather-only row in the itinerary tab under a forceddayType.The full
WeatherDatapayload 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
ReefDataCacheandBathymetryCache, 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:
hasRenderableWeatherdeliberately excludesPrecipitation.none.WeatherMapper.mapPrecipitationreturns non-null always, folding a missing reading intonone, even thoughWeatherData.precipitationis declared nullable. Countingnoneas data meant a fetch that resolved nothing still stored a row; the header draws nothing for it (weatherIconForgivesnoneno glyph), so the day would have gone permanently blank with its retry suppressed forever. Pinned bytrip_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 throughtripDayWeatherProvider, 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.
minimumCompatibleSchemaVersionstays at 160, since a new table is additive.Sync
tripDayWeatheris registered end to end: hlc target, serializer payload and switch arms, merge order, updatedAt flag, and thetripsFK 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.dartand its test are deleted.flutter analyzeis 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