Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,737 changes: 1,737 additions & 0 deletions docs/superpowers/plans/2026-08-05-trip-flight-no-fly-countdown.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
# Trip Return-Flight No-Fly Countdown - Design

Date: 2026-08-05
Status: Approved pending user review
Branch: worktree-trip-flight-no-fly

## Problem

When a diver is on a trip with a booked return flight, the question that
matters on the last diving days is not "when can I fly?" but "how much longer
can I keep diving?" Flying too soon after diving risks decompression sickness
from reduced cabin pressure. The app already tracks backward-looking no-fly
status (Settings > Safety, `NoFlyService`); this feature adds the
forward-looking countdown: given the trip's return flight departure time,
show the remaining hours and minutes of dive window, i.e. the latest time the
diver must surface so that the required pre-flight surface interval fits
before departure.

## Decisions (from brainstorming)

- No-fly rule source: the existing "flying after diving" setting
(`DiverSettings.noFlyPreset`, standard 12/18/24h or strict 18/24/48h).
No tissue-model computation; `NoFlyService`'s fixed agency intervals are
deliberate and remain the single source of truth.
- Flight data stored: departure date/time only. No flight number, airline,
or airports.
- Deadline anchor: exactly at flight departure. No built-in or per-trip
buffer.
- Display surfaces: trip story view, No-Fly page, dashboard gauge strip,
and a warning in the dive logging flow.
- Architecture: Approach A - the safety feature owns all computation; the
trips feature only stores the flight time.

## Data Model and Migration (schema v142)

New nullable column on `Trips` in `lib/core/database/database.dart`:

- `return_flight_at` INT (epoch ms), wall-clock in device-local time, the
same convention as `startDate`/`endDate` and dive times. No timezone
column. Rationale: during the trip the device clock is trip-local time,
which is the frame the countdown needs. Caveat (documented, not
engineered around): setting the flight time from home for a trip in a
different timezone stores home-wall-clock; editing it on location
corrects it.

Migration mechanics (mirrors v135/v139 column-add pattern):

- Idempotent `_assertTripReturnFlightColumn()` called from both the
`if (from < 142)` onUpgrade block and the beforeOpen backstop.
- Version number: v142 per the schema ladder (v138 = divelogs #603,
v139 = equipment currency #805). Re-grep `currentSchemaVersion = ` on
current origin/main immediately before implementation; renumber upward if
main has advanced.
- Migration test `migration_v142_trip_return_flight_test.dart` using
`greaterThanOrEqualTo(142)` + `contains(142)`, plus a fresh-DB (onCreate)
case and a stranded-at-currentSchemaVersion (backstop) case.

Entity and repository:

- `Trip.returnFlightAt` (`DateTime?`) with copyWith support that can also
clear the value; the repository update path uses the established
clear-field `.toCompanion(false)` pattern so null actually persists.
- Sync: `Trips` is already HLC-synced. Whole-row export picks up the new
column automatically; schema-default hydration (post-#858) hydrates the
column as null from older changesets. Updating the flight time bumps the
trip HLC as any trip edit does. No further sync work.

Edit UI:

- Optional "Return flight departure" date + time picker on
`trip_edit_page.dart`, with a clear affordance. Localized in en plus all
10 non-English locales, l10n regenerated.

## Domain Logic (safety feature)

New pure method on `NoFlyService`
(`lib/features/safety/domain/services/no_fly_service.dart`), keeping `now`
as a parameter like the existing `NoFlyStatus.remaining(now)`:

```
FlightWindowStatus flightWindow({
required DateTime flightAt,
required NoFlyPreset preset,
required NoFlyCategory prospectiveCategory,
DateTime? currentNoFlyUntil,
required DateTime now,
})
```

- Deadline (latest safe surfacing time) = `flightAt` minus the interval for
(`preset`, category), reusing the exact interval table `evaluate()` uses.
- Prospective category: at least `repetitive` (a trip is multi-day diving
by definition; `single` would show up to 6 phantom hours under the
standard preset). Escalates to `deco` when any dive within the existing
48h lookback had a deco obligation - the same signal `evaluate()` uses.
This holds even before the first trip dive is logged (consistent and
conservative).
- States on `FlightWindowStatus`:
- `open`: now < deadline. Exposes `deadline` and `remaining(now)` - the
time left in which diving may continue; the diver must surface by the
deadline.
- `closed`: deadline <= now < flightAt. No more diving before this
flight.
- `conflict`: the backward-looking `NoFlyStatus.until` (from
`noFlyStatusProvider`) is after `flightAt`. The diver has already dived
too recently for this flight. Alert treatment; takes precedence over
open/closed.
- `none`: flight is in the past, or no flight set (provider returns null
before the service is even consulted).
- Category escalation mid-trip (first deco dive logged) legitimately jumps
the deadline earlier and may flip open -> closed or conflict.

## Providers and Reactivity (safety feature)

- `tripFlightWindowProvider` -
`FutureProvider.family<FlightWindowStatus?, String>` keyed by trip id.
Reads the trip (`tripByIdProvider`), the `noFlyPreset` from
`settingsProvider`, and `getNoFlyDiveInputs` over the same 48h lookback
as `noFlyStatusProvider`. Returns null when the trip has no
`returnFlightAt`. Self-invalidates on dive writes, mirroring
`noFlyStatusProvider`. The provider derives `prospectiveCategory` by
running `NoFlyService.evaluate()` over the lookback inputs (which yields
the current category and `until`) and flooring the category at
`repetitive`; the service method itself stays pure and takes the result
as a parameter.
- `activeTripFlightWindowProvider` - resolves the trip containing today
(`tripForDateProvider`) that has a flight time set, then delegates to the
family. Consumed by the dashboard gauge and No-Fly page; the trip story
passes its own trip id.
- Ticking: computation is pure against `now`; surfaces re-evaluate on a
shared coarse minute-tick provider so displayed hh:mm stays current
without re-reading dive inputs every tick.

Dependency direction note: the safety feature gains a read dependency on
the trip repository/providers (to fetch the active trip's flight time).
This matches the existing direction - the dashboard already reads safety
providers; trips never reads safety.

## UI Surfaces

1. Trip story (`TripStoryView`): a countdown card near the top while the
trip is in progress and a flight is set.
- open: "Time left to dive - 14h 32m (surface by Sat 09:15)"
- closed: "No more diving before your flight."
- conflict: alert-styled "Your no-fly time extends past your flight
departure."
2. No-Fly page (`no_fly_page.dart`): a "Your flight" section when an
active trip has a flight - departure time, latest safe surfacing time,
and the comparison against the current no-fly clock (where conflict is
most legible).
3. Dashboard gauge strip (`gauge_providers.dart` / `gauge_strip.dart`): a
new flight-window gauge kind, shown only when an active trip has a
flight and the state is open, closed, or conflict (i.e. inside the trip
with the flight ahead). Additive; no behavior change without a
trip/flight.
4. Dive logging (dive edit page): non-blocking warning banner when the
dive's end time falls after the deadline. Warn, never block - the diver
may be logging a past trip or knows better.

All new strings localized in en + 10 non-English locales with l10n regen.

## Edge Cases

- No dives logged on the trip yet: still assume `repetitive`.
- Trip endDate after the flight (fly out mid-trip): countdown anchors to
the flight regardless of trip end.
- Overlapping trips: `tripForDateProvider` picks the containing trip;
tie-breaking is its existing concern, not this feature's.
- Flight time cleared: all surfaces revert to current behavior.
- Flight in the past relative to now: `none`; nothing shown.

## Testing

- Unit tests for `flightWindow()` with fixed clocks: every state, both
presets, category escalation, exact-boundary at the deadline, conflict
precedence over open/closed.
- Migration test for v142: upgrade path, fresh DB, backstop.
- Widget tests for the trip story card states (open/closed/conflict).
- Provider tests for `tripFlightWindowProvider` null and populated paths.
- Known trap: adding a provider dependency to the dive edit page and
No-Fly page breaks their existing consumer tests in ways
`flutter analyze` does not catch; those test files get overrides updated
in the same change.

## Out of Scope

- Flight number / airline / airport fields.
- Outbound-flight or multi-segment itineraries.
- Timezone modeling on trips.
- Tissue-model (Buhlmann) desaturation countdown.
- Notifications/alarms for the approaching deadline (possible follow-up).
34 changes: 33 additions & 1 deletion lib/core/database/database.dart
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ class Trips extends Table {
TextColumn get tripType => text().withDefault(const Constant('shore'))();
TextColumn get notes => text().withDefault(const Constant(''))();
BoolColumn get isShared => boolean().withDefault(const Constant(false))();

/// Return flight departure, wall-clock-as-UTC epoch ms (v142). Drives the
/// remaining-dive-window countdown; null when the trip has no flight set.
IntColumn get returnFlightAt => integer().nullable()();
IntColumn get createdAt => integer()();
IntColumn get updatedAt => integer()();

Expand Down Expand Up @@ -2928,7 +2932,7 @@ class AppDatabase extends _$AppDatabase {

/// The current schema version as a static constant so that pre-open checks
/// (e.g. version-mismatch guard) can reference it without an instance.
static const int currentSchemaVersion = 141;
static const int currentSchemaVersion = 142;

/// Every schema version that has a migration block in onUpgrade.
/// Used to calculate progress step counts. When adding a new migration,
Expand Down Expand Up @@ -3106,6 +3110,8 @@ class AppDatabase extends _$AppDatabase {
// items). Renumbered from v138 and then v139 as those went to the
// divelogs.de branch and the cylinder configs respectively.
141,
// v142: trips.return_flight_at (return-flight dive-window countdown).
142,
];

/// Idempotent DDL for the v106 connector-suggestion columns (Lightroom
Expand Down Expand Up @@ -4097,6 +4103,22 @@ class AppDatabase extends _$AppDatabase {
}
}

/// Idempotent DDL for the v142 return-flight column. Called from the v142
/// onUpgrade step and the beforeOpen backstop, matching the
/// _assertWeatherCodeColumn pattern so a schema-version collision cannot
/// strand a database without it. Self-guarding when the table is absent
/// (minimal migration-test fixtures).
Future<void> _assertTripReturnFlightColumn() async {
final cols = await customSelect("PRAGMA table_info('trips')").get();
if (cols.isEmpty) return;
final names = cols.map((c) => c.read<String>('name')).toSet();
if (!names.contains('return_flight_at')) {
await customStatement(
'ALTER TABLE trips ADD COLUMN return_flight_at INTEGER',
);
}
}

/// One-time clear of weather descriptions this app generated itself.
///
/// Only rows whose weather_source is 'openMeteo' are touched -- those are
Expand Down Expand Up @@ -7328,6 +7350,13 @@ class AppDatabase extends _$AppDatabase {
await _assertDefaultCurrencyColumn();
}
if (from < 141) await reportProgress();
// v142: trips.return_flight_at (return-flight dive-window countdown).
// v138 (#603) and v140 (media section) are reserved by parallel
// branches; the beforeOpen backstop heals any DB stranded between.
if (from < 142) {
await _assertTripReturnFlightColumn();
}
if (from < 142) await reportProgress();
},
beforeOpen: (details) async {
// Enable foreign keys
Expand Down Expand Up @@ -7444,6 +7473,9 @@ class AppDatabase extends _$AppDatabase {
// version collision self-heals here.
await _assertCylinderConfigSchema();

// v142 backstop: re-assert trips.return_flight_at.
await _assertTripReturnFlightColumn();

// Built-in dive types are reference data: identical on every device and
// undeletable through DiveTypeRepository. Nothing else restores them --
// the seed runs only in onCreate and the one-shot v93 step -- yet a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import 'package:submersion/features/equipment/presentation/providers/equipment_p
import 'package:submersion/features/media_store/presentation/providers/media_store_providers.dart';
import 'package:submersion/features/pre_dive/presentation/providers/pre_dive_providers.dart';
import 'package:submersion/features/safety/domain/services/no_fly_service.dart';
import 'package:submersion/features/safety/presentation/providers/flight_window_providers.dart';
import 'package:submersion/features/safety/presentation/providers/no_fly_providers.dart';
import 'package:submersion/features/settings/presentation/providers/sync_providers.dart';
import 'package:submersion/features/trips/domain/entities/trip.dart';
Expand All @@ -34,6 +35,7 @@ enum HomeChipType {
backup,
sync,
dataQuality,
flightWindow,
}

/// The worst service clock for one equipment type, shown as one chip.
Expand Down Expand Up @@ -84,6 +86,9 @@ class DashboardGauges {
/// Open data-quality findings.
final int dataQualityFindings;

/// Dive window before the active trip's return flight, if one is set.
final FlightWindowStatus? flightWindow;

const DashboardGauges({
required this.gearGauges,
required this.hasGear,
Expand All @@ -99,6 +104,7 @@ class DashboardGauges {
this.syncEnabled = false,
this.syncPending = 0,
this.dataQualityFindings = 0,
this.flightWindow,
});
}

Expand Down Expand Up @@ -181,6 +187,7 @@ final dashboardGaugesProvider = FutureProvider<DashboardGauges>((ref) async {
final clocks = await ref.watch(activeEquipmentClocksProvider.future);
final diver = await ref.watch(currentDiverProvider.future);
final noFly = await ref.watch(noFlyStatusProvider.future);
final flightWindow = await ref.watch(activeTripFlightWindowProvider.future);
final daysSince = await ref.watch(daysSinceLastDiveProvider.future);
final certCount = await ref.watch(expiringCertificationCountProvider.future);
final trips = await ref.watch(allTripsProvider.future);
Expand Down Expand Up @@ -212,5 +219,6 @@ final dashboardGaugesProvider = FutureProvider<DashboardGauges>((ref) async {
syncEnabled: syncEnabled,
syncPending: syncPending,
dataQualityFindings: findings,
flightWindow: flightWindow,
);
});
34 changes: 34 additions & 0 deletions lib/features/dashboard/presentation/widgets/gauge_strip.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import 'package:submersion/core/providers/provider.dart';

import 'package:submersion/features/dashboard/presentation/providers/gauge_providers.dart';
import 'package:submersion/features/equipment/domain/entities/service_clock_status.dart';
import 'package:submersion/features/safety/domain/services/no_fly_service.dart';
import 'package:submersion/features/settings/presentation/providers/settings_providers.dart';
import 'package:submersion/l10n/l10n_extension.dart';

Expand Down Expand Up @@ -177,6 +178,39 @@ class GaugeStrip extends ConsumerWidget {
}
}

if (_shown(hidden, HomeChipType.flightWindow)) {
final flight = g.flightWindow;
if (flight != null) {
switch (flight.state) {
case FlightWindowState.open:
final remaining = flight.remaining(NoFlyService.wallClockNowUtc());
chips.add(
_chip(
context,
icon: Icons.flight_takeoff_outlined,
label: l10n.dashboard_gauges_flightWindow(
remaining.inHours.toString(),
(remaining.inMinutes % 60).toString().padLeft(2, '0'),
),
tone: _Tone.warn,
onTap: () => context.goNamed('noFly'),
),
);
case FlightWindowState.closed:
case FlightWindowState.conflict:
chips.add(
_chip(
context,
icon: Icons.flight_takeoff_outlined,
label: l10n.dashboard_gauges_flightWindowClosed,
tone: _Tone.alert,
onTap: () => context.goNamed('noFly'),
),
);
}
}
}

if (_shown(hidden, HomeChipType.lastDive)) {
final days = g.daysSinceLastDive;
final tone = days == null
Expand Down
Loading