feat(flights): context-ranked import with previous-flight and Autorouter - #19
Conversation
Import was two bordered buttons (Flight Plan, Weather) that filled the route step and stopped. This makes it one control with four methods, and makes an import reach the people step too. UI - One primary button showing the method the current context ranks first (a flight plan on the clipboard, else a previous flight, else weather), with the full list a chevron away. Ranked by context rather than by what was used last: two of the four methods are contextual, a sticky label moves under the pilot, and a fresh install has no last-used value. - Unavailable methods are listed greyed with the reason, not hidden. - The flights-list `+` is a menu, so repeating last week's trip is two taps without opening the form first. - The people step offers "Same as EGTF > LFRM": the crew and passengers of the most recent flight in the same aircraft. The route step is four fields; the people step was where the typing actually was. Structure - `FlightDraft` is what every method produces and `apply(_:)` is the only writer of form state, so a fifth method is a case plus a sheet. Deliberately not `FlightExchange`, which is PII-free by design and so cannot carry a repeated flight's crew. - `Flight.nextOccurrence` holds the reschedule rule on its own, unit tested: keep the time of day read in the origin's zone, land today if it is still ahead and tomorrow otherwise, and carry the leg's duration so an overnight leg is not flattened onto the departure day. - Autorouter reuses flyfun-common's shared client through a mounted router; the app parses the returned flight plan with RZFlight rather than making a second round trip, so it shares the clipboard parser. Linking stays on the weather app: one database, one key, one redirect URI registered upstream. Fixes - A clipboard that did not parse silently did nothing, which read as a dead button. It now says what is wrong. - The suggestion is gated on the non-prompting `hasStrings`, so the system paste alert appears only when the pilot commits to the method. - Return/next-leg/duplicate wrote `departureDate` directly, leaving `departureTimeUTC` empty and the instant nil, so the new flight read as having no time entered. They go through the schedule setters now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DKFCDhUUazkuqySW8Tcex4
Code ReviewReviewed the diff against 1. Re-importing doesn't clear fields the new source doesn't provide (bug)
if !draft.crew.isEmpty { selectedCrew = draft.crew }
if !draft.passengers.isEmpty { selectedPassengers = draft.passengers }
if let responsiblePerson = draft.responsiblePerson { selectedResponsiblePerson = responsiblePerson }
if let value = draft.nature { nature = value }
...Only the "previous flight" method populates these; clipboard/weather/Autorouter drafts leave them empty/nil. So if a pilot imports via "Previous Flight" (bringing crew, nature, observations) and then changes their mind and imports a different flight via clipboard/weather/Autorouter, the stale crew/passengers/nature/observations from the first import silently survive onto what the pilot believes is a fresh import from an unrelated flight. Given this is a customs/immigration form, wrong crew persisting is a real correctness problem, not just a UI nit. Since 2. Autorouter's
|
`GET {prefix}/status` answers {"linked": bool} from stored credentials
alone, with no call to Autorouter. A client offering an Autorouter
feature can then present it as unavailable-with-a-reason up front;
without it the only way to learn the account is unlinked is to open the
picker, wait on a spinner and get a 409.
Raised in review of roznet/flyfun-forms#19, where the import list gates
Autorouter on being signed in and so looked available to a pilot who had
never linked.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DKFCDhUUazkuqySW8Tcex4
…er on linked Addresses the code review on #19. 1. A second import merged into the first. Importing a previous flight (crew, nature, observations) and then importing an unrelated route from the clipboard left the first flight's crew in place — on a customs form, the wrong people on the document. `apply(_:)` now replaces what an import wrote. Scoped by ownership rather than blanket-clearing: `peopleCameFromImport` tracks whether the current selection came from an import, so a crew the pilot picked by hand survives re-importing a route to fix a typo. The form-level fields have no editor in this flow, so an import is their only source and they are replaced unconditionally. Route fields keep their non-empty guards: an empty departure is a parse defect, not an instruction to clear a good value. 2. Autorouter was offered to anyone signed in, so a pilot who had never linked the account tapped, waited on a spinner and got a 409 — exactly what listing unavailable methods with a reason was meant to avoid. `FlightImportContext.autorouterLinked` is now filled from a new `GET /api/autorouter/status`. Unknown counts as available: a check in flight must not hide a method that works. 3. `PeopleSuggestion` claimed to reuse `Person.coTravelers(minimumFlights:)` and did not. The claim was wrong rather than the code: `coTravelers` answers "who usually flies with this person" and needs an anchor, while the chip answers "who was on board last time", which is the only one that can fill an empty people step. Comment and design doc now say that, and why both exist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DKFCDhUUazkuqySW8Tcex4
|
All three findings addressed in 0e9e196. 1. Re-import merged instead of replacing — agreed, real bug, fixed. Importing a previous flight and then importing an unrelated route left the first flight's crew on the form. On a customs document that is the wrong people, not a UI nit. I did not blanket-clear, though, because that has its own failure mode: a pilot who picks crew by hand on step 2, goes Back, and re-imports the route to fix a typo would have that crew wiped. The replacement is scoped by ownership — Not unit-testable as it stands — 2. Autorouter gated on Unknown ( 3. Still outstanding from the PR description and unchanged by this round: no Swift has been compiled. There is no toolchain in the environment this was written in, so the 20 tests in Generated by Claude Code |
Code ReviewReviewed the full diff (both commits) against Findings: none blocking. The implementation matches the design doc closely: Specifically checked and found sound:
One thing to double check before merging, since the PR description already flags that none of the Swift has been compiled: No other correctness, duplication, or architecture issues found. Approving. |
`GET {prefix}/status` answers {"linked": bool} from stored credentials
alone, with no call to Autorouter. A client offering an Autorouter
feature can then present it as unavailable-with-a-reason up front;
without it the only way to learn the account is unlinked is to open the
picker, wait on a spinner and get a 409.
Raised in review of roznet/flyfun-forms#19, where the import list gates
Autorouter on being signed in and so looked available to a pilot who had
never linked.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DKFCDhUUazkuqySW8Tcex4
What & why
Import was two bordered buttons (Flight Plan, Weather) that filled the route step and stopped. This makes it one control with four methods, and makes an import reach the people step too.
Design doc:
designs/flight-import.md(indexed, linked fromios-app.md).UI
One primary button showing the context-ranked method — a flight plan on the clipboard, else a previous flight, else weather — with the full list a chevron away.
Ranked by context rather than by what was used last, which was the original sketch. Two of the four methods are contextual rather than habitual (a clipboard plan is only useful when there is one; a repeat trip is overwhelmingly a previous flight), a sticky label moves under the pilot, and a fresh install has no last-used value to show.
Unavailable methods are listed greyed with the reason, not hidden, so "Import from Autorouter" is discoverable before the account is linked.
The flights-list
+is a menu: New Flight / Repeat route / Import…, so repeating last week's trip is two taps without opening the form first.The people step offers "Same as EGTF > LFRM" — the crew and passengers of the most recent flight in the same aircraft, falling back to the most recent flight, falling back to
isUsualCrew. The route step is four fields; the people step was where the typing actually was.Structure
FlightDraftis what every method produces, andNewFlightFlow.apply(_:)is the only writer of form state. A fifth method is a case plus a sheet and touches neither the form nor the control.Deliberately not
FlightExchange: that is the cross-app wire format and is PII-free by design, so it cannot carry a repeated flight's crew.FlightExchangestays on the wire,FlightDraftis the in-app type.Flight.nextOccurrenceholds the reschedule rule alone, unit tested: keep the time of day read in the origin airport's zone (a pilot thinks "the 09:00 out of EGTF", not "the 08:00Z"), land today if it is still ahead and tomorrow otherwise, and carry the leg's own duration so an overnight leg is not flattened onto the departure day.Autorouter reuses flyfun-common's shared client through a mounted
create_autorouter_routes_router(). The app parses the returnedfplanon-device withRZFlight.ICAOFlightPlanParserrather than making a second round trip, so it shares one parser with the clipboard path.Linking stays on the weather web app: the flyfun apps share one database and one Fernet key, so an account linked there is linked here, and no second redirect URI is registered with Autorouter. Forms mounts the routes router without the linking router.
Fixes found along the way
guard … else { return }and silently did nothing, which read as a dead button. It now says what is wrong.UIPasteboard.hasStrings, so the system "Allow Paste?" alert appears only once the pilot commits to the method, not on every tap.createReturnFlight/createNextLegwrotedepartureDatedirectly, leavingdepartureTimeUTCempty anddepartureInstantnil, so the new flight read as having no time entered at all. All three duplication actions go through thedepartureDateTime/arrivalDateTimesetters now, which dual-write the legacy pair and the instant.Testing / verification
Python: 5 new tests in
tests/integration/test_autorouter_routes.py(routes for a linked account, 409 rather than an empty list when unlinked, 502 when Autorouter is down, the path the iOS client hard-codes, and that the linking flow is not mounted here). 187 tests pass.tests/unit/test_fillers.pyhas 2 failures around PDF flattening. They fail identically with these changes stashed, so they are pre-existing and unrelated.Swift:
flyfun-formsTests/FlightImportTests.swiftadds 18 tests overFlight.nextOccurrence,FlightDraft, the ranking, andPeopleSuggestion— including the overnight-leg duration case and the origin-zone case.foregroundStyleternary, astartproperty colliding with astart(_:)method, main-actor isolation on the pasteboard reads), but the firstxcodebuildis the real check. Please run the unit target before merging.Merge order
flyfun-common>=0.6.6Known limitation
AirportTimezoneCachereverse-geocodes, so the very first repeat of a flight to an airport the app has never resolved falls back to UTC and the rescheduled time can be an hour or two off what the pilot expects. The cache is on disk and shared with the date/time field, so in practice it is warm. Documented in the design doc's gotchas; happy to block briefly on resolution instead if you would rather trade a spinner for it.Generated by Claude Code