Skip to content

feat(flights): a section selector bar for the flight editor - #23

Merged
roznet merged 6 commits into
mainfrom
claude/forms-section-nav
Sep 19, 2026
Merged

roznet merged 6 commits into
mainfrom
claude/forms-section-nav

Conversation

@roznet

@roznet roznet commented Sep 19, 2026

Copy link
Copy Markdown
Owner

Compact width gets a pill bar above the flight Form. Tap a section and it shows alone; All restores the full list.

All · Route · Schedule · Details · Crew · Passengers · <ICAO> arr · <ICAO> dep · Actions

Why a selector and not a scroll-spy

The first cut tried to port flyfun-weather's briefing scroll-spy (SectionSpyBar / ScrollSpyScroll) and failed three times. That rail rests on .scrollTargetLayout(), ScrollPosition and onScrollTargetVisibilityChange — all ScrollView-only — because the briefing content is a ScrollView. FlightEditView is a List-backed Form, which gives neither an addressable scroll target nor a usable position signal:

  • Section headers are not rowsScrollViewProxy.scrollTo can't address them and they take no part in row geometry, so taps were dead and the highlight frozen.
  • A .named(_:) coordinate space declared on the Form doesn't resolve inside List's separately-hosted row contexts — every anchor reported the same number, so "topmost" became whichever entry the dictionary yielded.
  • A List row can't be zero-height — injected anchor rows drew a stray separator at the top of every card.

So this ports the weather web rail's other mode instead: focus mode (enterFocus/exitFocus in sidebar-layout.ts). The pill is the state, so there's nothing to keep in sync and nothing to measure — no ScrollViewReader, no geometry, no anchor rows. The Form body is byte-for-byte what it was before this branch.

Details

  • hasForms(airport:direction:) is the single predicate shared by formIDs (the pills) and formSection (the render), so a pill can never select an empty section.
  • Picking a section also expands it — a lone collapsed DisclosureGroup would be a title and nothing else.
  • effectiveSelection falls back to All when the selection names a section this flight doesn't have: switchToFlight reuses the same view instance, so the previous flight's arrival-forms pill can survive the swap.
  • All is pinned outside the horizontal scroller, and re-tapping the focused section also returns to the full list — two routes back, neither depending on where the bar is scrolled.

Returning to the full list parked on the section you were reading would be nicer, but needs scroll-to-section — the capability Form/List wouldn't give us, and the reason this is a selector at all.

Scope

Wide layout and macOS untouched — shows(_:) is a no-op at sizeClass == .regular. They already split the flight across two columns and need their own pass.

Verification

  • xcodebuild build — green
  • xcodebuild test — 95 tests, 95 passed, 0 failed (count read back from the result bundle)
  • Adds one new string key, "All", with de/es/fr; the other six labels already existed in Localizable.xcstrings
  • Behaviour confirmed on device by @roznet

Deferred

  • Section ids are stringly-typed across navSections / expandSection / compactLayout; worth an enum if this grows.
  • designs/ios-app.md doesn't list FlightSectionNav.swift yet.

🤖 Generated with Claude Code

A flight is one long Form — route, schedule, details, crew, passengers,
the forms for each airport, actions — and reaching the forms means
scrolling past everything else. Compact width now carries the same
scroll-spy bar the flyfun-weather briefing uses: it highlights the
section nearest the top and jumps to one on tap.

The weather app's `ScrollSpyScroll` could not be reused as-is. It is
built on `.scrollTargetLayout()` / `ScrollPosition` /
`onScrollTargetVisibilityChange`, which are ScrollView-only, and this
view is a `List`-backed `Form`. So the jump goes through
`ScrollViewReader` (which addresses rows a lazy List has not
materialised yet) and the highlight through `onScrollVisibilityChange`,
a per-view modifier that does fire inside a List.

Anchors sit on each section's header, so a jump lands with the title in
view; the four collapsible sections expand before they are scrolled to.
`hasForms` is now the single predicate for whether an airport's form
section renders, read by both the section and the nav, so a pill can
never point at a section that is not there.

Wide layout and macOS are untouched — they already split the flight
across two columns, and they need their own pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Sep 19, 2026

Copy link
Copy Markdown

Code Review

Bug: switchToFlight doesn't reset visibleSections (FlightEditView.swift:1089-1095)

switchToFlight(_:) (called from Create Return Flight / Next Leg / Duplicate Flight) explicitly clears the other per-flight caches — formDetails, notifications, extraFieldValues — but not the new visibleSections set:

private func switchToFlight(_ newFlight: Flight) {
    // Reset cached state for the new flight
    formDetails = [:]
    notifications = [:]
    extraFieldValues = [:]
    flight = newFlight
}

Since these three actions reuse the same FlightEditView instance (they mutate @State var flight in place rather than pushing a new view), stale ids from the previous flight's scroll position remain in visibleSections. activeSection (line 214-216) then picks the first navSections entry still present in that stale set, so the pill bar can highlight the wrong section (e.g. still "Passengers") right after the jump to the new flight, until the user scrolls enough to refresh visibility. Worth adding visibleSections = [] alongside the other resets.

Everything else — the hasForms predicate shared between formSection/formAnchors, the pill-id namespacing to avoid colliding with the outer ScrollViewReader, and the wide/compact layout split — looks correct and consistent.

One thing worth double-checking on a simulator/device per the PR's own note: onScrollVisibilityChange(threshold: 0.01) marks a section "visible" as soon as any sliver of it is on screen, and activeSection picks the first (topmost) visible entry. With several short Form rows able to fit on screen at once, this could make the active pill lag — sticking to a section that's nearly scrolled away — until it's fully off-screen. Not blocking, just flagging since the PR description already notes highlight-tracking is unverified live.

… events

Review caught that `switchToFlight` — Create Return Flight, Next Leg,
Duplicate — swaps the flight under the same `FlightEditView` instance and
resets every other per-flight cache, but not the set of seen section ids,
so the pill bar could highlight a section from the previous flight.

Clearing the set alongside the others does not actually fix it:
`onScrollVisibilityChange` only fires on a *change*, so headers already on
screen would never re-report and the bar would sit on "Route" until the
next scroll. The set was event truth, and events cannot be replayed.

Replaced with `FlightSectionSpy`, which holds each header's offset from
the top of the `Form` and picks the deepest one that has reached the top
edge. Offsets describe where headers actually are, so a flight swap needs
no reset at all. It also fixes the second thing review flagged: a
"topmost visible header" rule moves the highlight to the next section as
its header creeps in from the bottom, whereas holding the last header
that passed the top keeps it on a section taller than the screen.

Offsets are measured against the form's own named coordinate space rather
than `.scrollView`, so this does not rest on that space resolving inside a
`List`, and `onDisappear` drops headers the list recycles. Only `active`
is observable — offsets churn every scroll frame and must not re-evaluate
the view.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@roznet

roznet commented Sep 19, 2026

Copy link
Copy Markdown
Owner Author

Addressed in 02f6154.

The switchToFlight bug is real — but clearing visibleSections alongside the other resets doesn't fix it. onScrollVisibilityChange only fires on a change, so headers already on screen would never re-report and the bar would sit on "Route" until the next scroll. The set was event truth, and events can't be replayed.

Both findings share that root cause, so the mechanism is replaced rather than patched. FlightSectionSpy holds each header's offset from the top of the Form and picks the deepest one that has reached the top edge:

  • Flight swap needs no reset at all — offsets describe where headers actually are, so they stay correct when the flight changes under the same view instance.
  • The lag you flagged is gone for the right reason: "topmost visible header" moves the highlight to the next section as its header creeps in from the bottom, whereas "last header that passed the top" stays on a section taller than the screen. When no header qualifies (above the first, or deep inside a long section whose header was recycled) the previous answer holds.

Two implementation notes: offsets are measured against the form's own named coordinate space, not .scrollView, so this doesn't rest on that space resolving inside a List; and only active is observable (@ObservationIgnored on the offsets), since they churn every scroll frame and must not re-evaluate FlightEditView.

activeSection also now filters the spy's answer through navSections, so a stale id for a section this flight doesn't have (the previous flight's arrival forms) falls back to the first pill instead of highlighting nothing.

Build green, 95/95 tests passing. Live behaviour still pending a simulator run.

@claude

claude Bot commented Sep 19, 2026

Copy link
Copy Markdown

Code Review

Reviewed FlightEditView.swift and the new FlightSectionNav.swift against designs/ios-app.md (no CLAUDE.md exists in this repo). The scroll-spy design (position-derived active section, namespaced pill ids, hasForms as the single predicate shared by pills and sections) is sound and the tricky parts are well-documented in comments.

One issue found:

Unlocalized pill labels for the form-anchor pills (FlightEditView.swift:202,207)

anchors.append((id: formAnchorID(direction: "arrival"), title: "\(flight.destinationICAO) arr"))
...
anchors.append((id: formAnchorID(direction: "departure"), title: "\(flight.originICAO) dep"))

The other four pill labels (Route/Schedule/Details/Crew/Passengers/Actions) are built with String(localized: "..."), matching the established codebase convention for interpolated user-facing strings (e.g. String(localized: "\(distance) nm") in AutorouterRoutePickerView.swift, String(localized: "\(people) on board") in PreviousFlightPickerView.swift). These two labels instead hardcode the English abbreviations "arr"/"dep" as a plain String, so they won't pick up the de/es/fr translations the PR description says already exist for the other labels, and will read as English fragments spliced into otherwise-localized UI in non-English locales. Suggest String(localized: "\(flight.destinationICAO) arr") / String(localized: "\(flight.originICAO) dep") (or equivalent format strings) with corresponding xcstrings entries.

No other high-confidence issues — logic for tracking gating (trackingSections), fallback on stale/invalid active ids, and the hasForms predicate shared between pill generation and section rendering all check out.

Tapping a pill did nothing and the highlight named the wrong section —
at the top it said Schedule, then held there until Passengers came into
view and flipped to Details.

One cause behind both. The anchors were on the `Section` headers, and in
a `List` a header is not a row: `ScrollViewProxy.scrollTo` cannot address
it, and it does not take part in row geometry. On top of that, the
offsets were measured in a `.named(_:)` coordinate space declared on the
`Form`, which does not resolve from inside a `List`'s row hosts — so
every anchor reported the same number, "deepest" became whichever entry
the dictionary happened to yield, and the answer only moved when rows
materialised.

Each section now opens with a zero-height `FlightSectionAnchor` row.
Rows are real scroll targets, so the jump works, and the anchor sits
exactly at the section boundary, so the highlight means what it says.
Offsets are measured in `.global`, which resolves everywhere.

Anchor positions are also paired with the scroll offset they were taken
at and corrected by how far the list has scrolled since. Scrolling moves
rows 1:1 with the content offset, so this stays exact even if a row's
geometry callback does not re-fire mid-scroll; the offset comes from
`onScrollGeometryChange` on the form, a container-level modifier that
does report continuously.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Sep 19, 2026

Copy link
Copy Markdown

Code Review

Went through the scroll-spy implementation (FlightSectionSpy, FlightSectionAnchor, FlightSectionNavBar) and the FlightEditView wiring. The design is careful about the traps it calls out in the description (headers vs. rows for scrollTo, position-based tracking to survive switchToFlight, pill-id namespacing to avoid colliding with the shared ScrollViewReader), and hasForms(airport:direction:) correctly consolidates the predicate so formAnchors and formSection can't disagree. No functional bugs found.

Two minor, non-blocking points:

  1. Docs driftdesigns/ios-app.md's Views/ file tree doesn't list the new FlightSectionNav.swift, and the Status section doesn't mention the compact section navigator. Worth a follow-up doc update since this file otherwise tracks the view list closely.
  2. No unit coverage for FlightSectionSpy — its recompute()/currentTop() math (the passed-threshold logic, scroll-offset correction) is pure, dependency-free logic, the same shape as DocumentResolverTests/PeopleCSVImporterTests elsewhere in this test target. The PR description already flags the live highlight/jump behavior as unverified on-device; a few unit tests around the spy's arithmetic would cover the part that doesn't need a simulator.

Nothing here blocks merging.

…to one

Taps never landed and the highlight named the wrong section, and the
zero-height anchor rows drew a stray separator at the top of every card.

The pattern was ported onto the wrong substrate. flyfun-weather's
scroll-spy rail rests on `.scrollTargetLayout()`, `ScrollPosition` and
`onScrollTargetVisibilityChange` — all `ScrollView`-only — because the
briefing content is a `ScrollView`. The flight editor is a `List`-backed
`Form`, which offers neither an addressable scroll target nor a usable
position signal: section headers are not rows, a `.named(_:)` coordinate
space declared on the `Form` does not resolve inside row hosts, and a row
cannot be zero-height. Each workaround bought a new artifact.

So this ports the rail's *other* mode instead. The bar now selects: a pill
renders that section alone, "All" restores the full list. The pill is the
state, so there is nothing to keep in sync and nothing to measure — no
`ScrollViewReader`, no geometry, no anchor rows. The `Form` body is byte
for byte what it was before this branch.

Picking a section also expands it, since a lone collapsed DisclosureGroup
would be a title and nothing else. A selection naming a section the flight
does not have falls back to showing everything, because `switchToFlight`
keeps the same view instance. Wide layout and macOS are untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Sep 19, 2026

Copy link
Copy Markdown

Reviewed the diff (FlightEditView.swift, new FlightSectionNav.swift, Localizable.xcstrings) against designs/ios-app.md. No CLAUDE.md exists in this repo, so no standards violations to check there.

No bugs found. The core logic holds up:

  • hasForms(airport:direction:) is genuinely shared between formIDs (pill list) and formSection (render), so a pill can't point at an empty section — matches the PR's stated correctness goal.
  • effectiveSelection's fallback to "All" when selectedSection no longer exists in navSections (e.g. right after switchToFlight resets formDetails) is sound and won't crash or dead-end the UI.
  • shows(_:) is a no-op under sizeClass == .regular, so wide/macOS layouts are correctly unaffected, as claimed.

Two minor, non-blocking observations:

  1. Stringly-typed section IDs ("schedule", "crew", "form-arrival", etc.) are duplicated across navSections, expandSection, and the if shows(...) chain in compactLayout with no compiler check tying them together. A typo in any one spot would silently just hide a section rather than fail to build. Worth an enum if this grows further, not urgent now.
  2. PR description drift: the description states the six pill labels "already existed... no new keys," but the diff adds a new "All" key to Localizable.xcstrings, and the described scroll-spy/ScrollViewReader.scrollTo/onScrollVisibilityChange mechanism isn't what shipped — the final code (per FlightSectionNav.swift's own doc comment) replaced scrolling with pure show/hide selection. Worth updating the description so it matches what's actually merged.

Approving — nothing here blocks the merge.

Focusing a section late in the bar scrolled the bar along with it, so
"All" ended up several swipes off to the left — the way back was the
hardest thing to reach.

"All" now sits outside the horizontal scroller, pinned at the leading
edge behind a divider, so it never scrolls away. Tapping the section you
are already in also returns to the full list, so there are two routes
back and neither depends on where the bar happens to be scrolled.

Returning to the full list parked on the section you were reading would
be nicer still, but that needs a scroll-to-section, which is exactly what
`Form`/`List` would not give us and why this is a selector at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@roznet roznet changed the title feat(flights): jump between flight sections from a pill bar feat(flights): a section selector bar for the flight editor Sep 19, 2026
@claude

claude Bot commented Sep 19, 2026

Copy link
Copy Markdown

Code Review

Reviewed the diff against designs/ios-app.md (no CLAUDE.md exists in this repo). The SwiftUI logic itself is sound — hasForms(airport:direction:) is correctly shared between formIDs and formSections so a pill can't point at content that isn't rendered, effectiveSelection correctly falls back to "All" when the flight in view (after switchToFlight) no longer has the selected section, and shows(_:) correctly no-ops for wide/macOS layouts. Two non-blocking issues worth fixing before merge:

1. PR description no longer matches the implementation. The description says the jump is done via ScrollViewReader.scrollTo with anchors on section headers, and highlighting via onScrollVisibilityChange (a scroll-spy port from flyfun-weather), plus a note about pill-<id> namespacing to avoid ScrollViewReader id collisions. None of that exists in the merged code — FlightSectionNavBar.swift implements a "focus mode" (conditionally show/hide sections based on a selected id), explicitly documented in its own header comment as not the scroll-spy approach, with no ScrollViewReader/scrollTo/anchors anywhere. This matches the commit history (refactor(flights): make the section bar select a section, not scroll to one), so the description is just stale from before the pivot. Worth updating so reviewers/future readers aren't debugging the wrong mental model.

2. designs/ios-app.md wasn't updated. The doc maintains an explicit Views/ file tree and a granular feature Status checklist, but neither reflects the new Views/FlightSectionNav.swift file or the compact section-nav-bar behavior. Given the doc's own convention (every other view file and feature is listed), this should get an entry.

Minor: section ids ("route", "schedule", "details", "crew", "passengers", "actions", "form-arrival", "form-departure") are raw string literals duplicated across compactLayout, navSections, expandSection, and formSectionID/formSections. They're consistent today, but nothing catches a typo in one of the five call sites at compile time. An enum would remove that risk — not blocking, just worth considering if this file grows further.

🤖 Generated with Claude Code

The doc keeps an explicit `Views/` file tree and feature status list, and
every other view file is in it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@roznet
roznet merged commit bc066d1 into main Sep 19, 2026
2 checks passed
@roznet
roznet deleted the claude/forms-section-nav branch September 19, 2026 15:37
@claude

claude Bot commented Sep 19, 2026

Copy link
Copy Markdown

Code Review

Reviewed the diff (FlightSectionNav.swift, FlightEditView.swift, Localizable.xcstrings, designs/ios-app.md).

No high-confidence issues found:

  • hasForms(airport:direction:) is correctly shared between the pill list (formIDs) and the render predicate (formSection), so a pill can't select an empty section — verified it's logically equivalent to the old inline forms.first != nil || !webForms.isEmpty check.
  • effectiveSelection's fallback to "All" correctly handles switchToFlight reusing the same view instance (stale selectedSection naming a section the new flight doesn't have).
  • shows(_:) is a no-op outside compact width, so formSections (shared with wideLayout) is unaffected there.
  • Section expand-on-select (expandSection) and the "tap again to go back to All" toggle in select(_:) are consistent with the documented UX.
  • No repo CLAUDE.md exists to check against; designs/ios-app.md is updated to reference the new file, matching the stated architecture.

Approving.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant