Address review comments: unrenderable dive weather and day-key normalization - #1325
Conversation
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.
There was a problem hiding this comment.
Pull request overview
This PR addresses follow-up review items from #1319 by (1) preventing “unrenderable” dive weather (precipitation = none only) from masking stored trip-day weather, and (2) enforcing day-key normalization (local midnight) inside the trip-day weather repository.
Changes:
- Introduces
TripStoryDayWeather.isRenderableand uses it to drive backfill skip logic and header precedence. - Delegates
TripDayWeather.hasRenderableWeatherto the shared renderability rule to avoid drift. - Normalizes
(trip, date)identity insideTripDayWeatherRepository(write + read keying) and adds targeted regression tests.
Reviewed changes
Copilot reviewed 8 out of 8 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_day_header_test.dart | Adds coverage ensuring stored weather is shown when dive weather can’t render. |
| test/features/trips/domain/services/trip_day_weather_backfill_test.dart | Adds tests for renderability-based backfill targeting (none-only precipitation, active precip, cloud-cover-only). |
| test/features/trips/data/repositories/trip_day_weather_repository_test.dart | Adds tests ensuring time-bearing dates are normalized and don’t create duplicate rows. |
| lib/features/trips/presentation/widgets/story/trip_story_day_header.dart | Updates weather precedence so dive weather wins only when it can render. |
| lib/features/trips/domain/services/trip_day_weather_backfill.dart | Updates backfill skip condition to use isRenderable instead of non-null presence. |
| lib/features/trips/domain/entities/trip_story_day.dart | Adds TripStoryDayWeather.isRenderable as the shared “badge would draw” rule. |
| lib/features/trips/domain/entities/trip_day_weather.dart | Delegates hasRenderableWeather to TripStoryDayWeather.isRenderable. |
| lib/features/trips/data/repositories/trip_day_weather_repository.dart | Adds _dayKey normalization and uses it for storage + normalized map keys on read. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
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.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.
Suppressed comments (1)
lib/features/trips/data/repositories/trip_day_weather_repository.dart:145
upsertdeletes same-day stray rows inside a DB transaction, then logs their tombstones afterward. If_syncRepository.logDeletionthrows (e.g. clock config / DB error), the strays are already permanently deleted locally but no tombstone is recorded, so a peer that originally sent the stray can re-send it on the next pull and you may reintroduce duplicates.
Consider making stray deletion + tombstone logging atomic, e.g. by adding a SyncRepository.logDeletionWithExecutor/logDeletionInTransaction helper that can participate in the existing _db.transaction, or by writing the deletion_log rows using the same transaction executor as the stray delete + canonical insert (so failures roll back everything together).
await _db.transaction(() async {
// Strays go before the insert, not after. insertOnConflictUpdate
// targets the primary key, so a stray sitting on this same
// (trip_id, date) is not a conflict it can absorb: the insert misses
// the ON CONFLICT target and hits the unique index instead, which
// throws and fails the whole write. One transaction, so a day is
// never left with its old row deleted and no new one in its place.
if (strays.isNotEmpty) {
await (_db.delete(
_db.tripDayWeather,
)..where((t) => t.id.isIn(strays))).go();
}
await _db
.into(_db.tripDayWeather)
.insertOnConflictUpdate(
TripDayWeatherCompanion(
id: Value(id),
tripId: Value(weather.tripId),
date: Value(dateMillis),
latitude: Value(weather.latitude),
longitude: Value(weather.longitude),
airTemp: Value(weather.airTemp),
cloudCover: Value(weather.cloudCover?.name),
precipitation: Value(weather.precipitation?.name),
windSpeed: Value(weather.windSpeed),
windDirection: Value(weather.windDirection?.name),
humidity: Value(weather.humidity),
surfacePressure: Value(weather.surfacePressure),
weatherCode: Value(weather.weatherCode),
weatherSource: Value(weather.weatherSource.name),
fetchedAt: Value(weather.fetchedAt.millisecondsSinceEpoch),
createdAt: Value(
createdAt ?? weather.createdAt.millisecondsSinceEpoch,
),
updatedAt: Value(now),
),
);
});
// After the row is in place, so a failed write leaves no tombstone for
// a day that still has its original row. A stray is a synced record:
// dropping it without one lets the peer that sent it hand it back on
// the next pull.
for (final stray in strays) {
await _syncRepository.logDeletion(
entityType: 'tripDayWeather',
recordId: stray,
);
}
Addresses the two review comments on #1319. Stacked on
worktree-trip-day-weather, so the diff here is just the two fixes.1. Unrenderable dive weather blocked the backfill
WeatherRepository.fetchAndSaveWeatherwritesprecipitationunconditionally fromWeatherMapper, which never returns null: a missing reading becomesPrecipitation.none. So a dive whose archive lookup resolved nothing carriesnoneand nothing else.TripStoryDay.weatheris non-null for that day, so the backfill skipped it as "already has weather". ButweatherIconForgivesnoneno glyph and there is no temperature, so the header drew nothing. The day was permanently badge-free, and nothing would ever fill it.This is the same root cause as the
hasRenderableWeatherguard already in the branch, which I had applied only to the rows being written and not to the skip decision.The rule now lives in one place,
TripStoryDayWeather.isRenderable, with three callers:TripDayWeather.hasRenderableWeather, which now delegates rather than restating the conditionKeeping them on one definition is the point: three copies of "is this worth showing" would drift.
2. Day identity normalized inside the repository
upsertusedweather.date.millisecondsSinceEpochdirectly. The only current caller normalizes first, but the repository owns the(trip, date)uniqueness invariant and should enforce it rather than trust every caller. ADateTimecarrying a time component would store a second row for the same calendar day, invisible to every midnight-keyed lookup and refetched on every view.Normalization applies on read as well as write, because a row can also arrive through sync from a peer and bypass this class entirely.
3. Rows
upsertdid not writeA second round of review asked what happens to a row for the same trip and calendar day that this repository did not write: a peer on a build predating the derived id, or a database written before this class normalized. Two things went wrong with it.
The upsert threw. The unique index is on
(trip_id, date), but Drift'sinsertOnConflictUpdateemitsON CONFLICT("id"). A foreign-id row on the same midnight is therefore not a conflict the statement can absorb: the insert misses the ON CONFLICT target and hits the index instead, failing withSqliteException(2067). The day could not be written at all, not merely written twice. The off-midnight variant is the milder case, where the dates differ, the index does not fire, and you get a silent duplicate.The read collapsed it arbitrarily.
getForTripkeyed a map by normalized day over an unordered result, so which of two same-day rows showed depended on the order SQLite returned them in.upsertnow 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 back.createdAtis 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 of cleaning up: canonical row, then most recently updated, then id. A write on the read path would fire the table tick
tripDayWeatherProvidersubscribes to and invalidate the read in flight.The backfill also no longer passes a placeholder
id: ''. The local-midnight conversion moved next totripDayWeatherRowIdastripDayMillis, so the caller derives the real id and the repository's_dayKeydelegates 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.Testing
none-only precipitation is not weather, active precipitation is, cloud-cover-only is.upsertdid not write: a foreign-id row on the same midnight, an off-midnight row, a row for another day left alone, the stray's deletion logged for sync,createdAtcarried across, the canonical row preferred on read, the most recently updated preferred otherwise, and reads leaving the table untouched.692 trips and weather tests pass.
flutter analyzeclean.