Compare commits

..

39 Commits

Author SHA1 Message Date
Trey T ba0688a412 Search: FlightAware backbone, blob catalog, diagnostic infra
route-explorer's /api/token sits behind invisible Cloudflare Turnstile
that requires Apple's Private Access Token attestation. Third-party
iOS apps don't qualify for PAT issuance, and Linux Docker containers
can't pass it either (cross-OS fingerprint, even with patchright /
Camoufox). Migrates direct-flight search to FlightAware; multi-stop
and where-can-I-go remain via embedded SFSafariViewController.

- FlightAwareScheduleClient — scrapes route.rvt + trackpoll JSON for
  real schedules without auth. T+0..2 day window. Tests against
  captured HTML fixtures.
- BlobRouteClient — pulls the public Vercel blob route catalog
  route-explorer's frontend reads (no auth, no Turnstile).
- DiagnosticLogger + LoggingURLSessionDelegate + DiagnosticsView —
  device-shareable forensic trace. Boot header captures device, OS,
  locale, UA; share-sheet export of session logs.
- TurnstileDebugView — live WKWebView gate inspector. Used to prove
  the PAT-entitlement gap on a real device.
- RouteExplorerBrowserView — SFSafariViewController wrapper. Real
  Safari clears Turnstile naturally; the in-app browser opens at
  pre-filled search URLs. Surfaced from Search ("Open in
  route-explorer") and Settings → Tools.
- RouteExplorerTokenStore + RouteExplorerSetupView — bookmarklet
  capture flow (token round-tripped via flights://routeexplorer-token
  URL scheme). Kept dormant for future use.

backend/ — Docker proxy attempts (Playwright, patchright, Camoufox).
All fail on Linux because Cloudflare auto-denies before the Turnstile
widget renders. Documented; kept as scaffolding for a future paid-
solver integration.

scripts/probe_flightaware.py — reference algorithm for the FA path.
scripts/probe_nodriver.py — local-Mac sanity check confirming the
gate clears with real macOS Chrome (proves the blocker is
fingerprint-level, not network-level).
2026-06-06 01:09:59 -05:00
Trey T d122c95342 RouteExplorer: real Safari UA + surface real upstream status
Two follow-ups to the WebView-routing commit after user reported
"Could not get any token HTTP -1":

1. WKWebView's default UA is "Mozilla/5.0 (iPhone; ...) Mobile/15E148"
   — missing the "Version/17.5 Safari/604.1" suffix real Safari sends.
   Cloudflare and other bot filters use that suffix to ID true Safari.
   Now we explicitly set a complete Safari UA on the WebView before
   navigating to route-explorer.

2. WebViewFetcher returns its errors as "HTTP <code>: <body>" strings;
   we were always throwing tokenFetchFailed(status: -1) regardless.
   New extractStatus helper parses the real upstream HTTP code out
   of the error string so the thrown error reflects what the server
   actually said — "HTTP 403" instead of "HTTP -1" makes it
   diagnosable from the device.

If the deployed app still 403s after this, the issue is more than UA
(probably Cloudflare clearance cookie needed via interactive challenge)
and we'd have to consider ASWebAuthenticationSession or fall back to
a paid schedule API.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 13:32:05 -05:00
Trey T 9612ef558f Route RouteExplorer through WKWebView to bypass URLSession block
User reported the Search tab is 403'ing on the deployed app — visible
on their phone, but route-explorer.com itself loads in their phone's
Safari. So the gating differentiates between Safari/WKWebView and
URLSession: same network, same UA strings, different verdict. Most
likely TLS-fingerprint-based.

Solution: route both /api/token and /api/flight-search through the
existing WebViewFetcher service (originally built for the same
purpose against an Akamai-protected airline API). XHRs run from
inside a WKWebView navigated to route-explorer.com, so the request
hits the edge with Safari's TLS fingerprint and any first-party
cookies the gate expects.

Touched:
- RouteExplorerClient.currentToken — now goes via WKWebView XHR
- callFlightSearch — same, with one retry on token rotation
- searchSchedule — same path
- New fetchViaWebView helper takes (method, apiPath, headers, body)
  and returns the response body string

Trade-offs:
- Each call now starts with a WKWebView navigation (~2s). Token is
  cached for 30 min so this hits once per session for most usage.
- Searches are slower than before. Can pool the WKWebView later if
  needed; for now correctness > speed.
- Per-call WKWebView allocation runs on MainActor (forced by
  WebViewFetcher's @MainActor isolation). Awaiting from the actor
  is fine — the bridge is automatic.

If route-explorer relaxes the gate later we can switch back to
URLSession by reverting this commit; the URLSession code path was
preserved up to deletion.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 13:27:36 -05:00
Trey T 5c1d7871c6 Flight detail: bearing-aware icons + auto type lookup
Icons:
- Route card's central airplane and the two map markers now rotate
  by the actual dep→arr compass bearing instead of a hardcoded -45°.
  DAL→SAN (westbound) shows planes pointing left; LAS→DAL (eastbound)
  points right; DAL→HOU (south) points down. New
  HistoryDetailView.bearing(from:to:) computes great-circle bearing;
  rotation is (bearing - 45) since SF's airplane glyph naturally
  sits at ~45°.
- Replaced the FlightRouteMap's `Marker(systemImage:)` for departure
  and arrival with custom `Annotation` views (routeMarker helper)
  that wrap an SF airplane.departure/arrival glyph in a tinted
  circle and rotate the icon by the same bearing.

Aircraft data lookup:
- HistoryDetailView now auto-enriches the missing aircraftType on
  .task via the same two-step chain the bulk Aircraft Stats button
  uses: route-explorer first, FlightAware activity-log fallback,
  normalized through AircraftDatabase.normalizedICAO before saving.
- Aircraft card's Type cell now shows the friendly name when
  available — "B738 · Boeing 737-800" instead of just "B738".
- When neither registration nor icao24 is set (typical for CSV-
  imported historical flights), the card shows an honest one-line
  caption explaining why tail / first-flight / ICAO24 are blank
  and what populates them.

Honest about what we can't get:
- Tested OpenSky /flights/departure (anonymous tier blocks
  historical, 403 "You cannot access historical flights"),
  FlightAware /history/<date>/<time>Z/... (needs exact UTC
  scheduled departure time we don't have for CSV rows), JetPhotos
  and Planespotters flight search (Cloudflare-gated). For
  historical Southwest flights from a PNR-style CSV, the most we
  can pull for free is the typical aircraft type for that flight
  number from FlightAware's activity log — which we now do
  automatically when the detail view appears.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 13:13:50 -05:00
Trey T cb981c5380 Route map: filter from allFlights + filters internally
When the user opened the map and picked a year (e.g. 2026) in the
in-map filter sheet, the map kept drawing every flight instead of
just 2026. Root cause: the map was taking a pre-filtered `flights`
prop from HistoryView via the .sheet content closure. SwiftUI does
propagate the `filters` Binding into the sheet correctly, but it
doesn't reliably re-render the .sheet content closure with the new
pre-filtered prop value when the parent rerenders mid-presentation —
so .onChange(of: filters) fires, reset() runs, but `self.flights`
is the old (unfiltered) prop value, and the rebuilt schedule still
includes every flight.

Fix: stop relying on the parent doing the filtering. The map now
takes `allFlights` (unfiltered) and computes the displayed set
itself via `allFlights.filter { filters.matches($0) }`. Since
`filters` is a Binding that propagates cleanly, the computed set
is always in sync with whatever the user just selected in the
filter sheet — no parent-render-order race.

Also: when opening the map from HistoryView's toolbar or quick-link
card, fold the year-strip `selectedYear` into `filters.years` so
the map opens scoped to whatever the user was browsing on the home
list.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 13:05:21 -05:00
Trey T 0c9d02f7d4 Aircraft enrichment: FlightAware fallback + IATA→ICAO normalization
The user reported 0 enrichment hits. Two root causes:

1. route-explorer's schedule endpoint only covers FUTURE flights —
   verified by curl: WN1942 / 2024-01-27 → no flights; WN7 /
   2026-05-27 → 1 result with equipmentIata=73H. The user's CSV is
   mostly historical, so route-explorer was a dead end for ~all rows.

2. Even when route-explorer DID return data, it ships IATA aircraft
   codes ("73H") while the rest of the app expects ICAO designators
   ("B738"). The saved string wouldn't have matched displayName or
   type-filter tables anyway.

Two fixes:

- FlightAwareLookup actor scrapes flightaware.com/live/flight/
  <CALLSIGN> for the trackpollBootstrap JSON embedded in the page.
  The activityLog.flights[] array contains 8–15 recent operations
  of that flight number, each with a real ICAO aircraftType
  ("B738", "B38M", etc.) and the route IATA. We walk braces to
  extract the JSON literal, then pick the best match:
    1. Same dep/arr route → most common type on that route
    2. Reverse direction (same airframe usually flies the return)
    3. Fallback: most common type across the entire activity log
  Verified by curl that FA isn't Cloudflare-gated and returns
  ICAO codes directly. Per-callsign result cached.

- AircraftDatabase.normalizedICAO(forCode:) converts either input
  form (IATA or ICAO) to canonical ICAO. New iataToICAO map covers
  the common 60+ codes (737 family, A320 family, widebodies,
  regionals, MD-80s). Anything missing falls through unchanged.

- Both enrichers (EnrichAircraftTypesView for existing flights,
  ImportCSVView during import) now run the two-step lookup:
  route-explorer first, FlightAware on miss. Result is normalized
  through normalizedICAO before saving so the Aircraft Stats screen
  recognizes the value.

Expected outcome: the user's 80 historical Southwest flights should
mostly get B737 / B738 / B38M codes assigned via FlightAware's
per-flight-# activity log (Southwest reuses flight numbers reliably
on the same routes).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 19:00:02 -05:00
Trey T e5333ff965 Enrich existing flights with aircraft types
The user re-imported the CSV expecting the new enrichment-during-import
to kick in, but every row dedupe-matched their existing log so nothing
got updated. Adds a dedicated "look up missing types" flow for
already-saved flights.

- EnrichAircraftTypesView: scans every LoggedFlight where
  aircraftType == nil and carrierIATA + flightNumber are present,
  walks them sequentially through routeExplorer.searchSchedule for
  that carrier/flight/date window, patches in equipmentIata when the
  schedule has a match. Live progress (N / total), found count,
  Stop to cancel. Saves once at the end so SwiftData batches the
  writes.
- AircraftStatsView now takes a RouteExplorerClient and:
  - Surfaces a "Look up missing types" CTA button in the empty
    state (the most useful place to discover this).
  - Adds a wand.and.stars toolbar button so the flow is reachable
    even when stats are already populated (for top-ups after
    new imports).
- HistoryView passes the existing routeExplorer through to
  AircraftStatsView.

Net behavior: user opens Aircraft Stats → sees empty state → taps
"Look up missing types" → modal scans the log, queries
route-explorer per flight, fills in types, hits Done. Stats screen
populates without needing a re-import.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 18:48:59 -05:00
Trey T 2e5cf6b9b3 History: filter auto-dismiss, toolbar map access, aircraft empty state, CSV enrichment
Four fixes the user called out plus the lookup-during-import idea
they asked about:

1. Filter sheet auto-dismisses when a year is toggled ON. Years are
   the user's primary "scope this whole screen" filter — committing
   one is a commitment, so close the sheet so the underlying view
   (especially the map animation) can react immediately. Toggling
   OFF or picking airline/airport/type doesn't dismiss — those are
   refinement actions.

2. When filtering on the History tab, the hero deck (with the Map /
   Aircraft / Year-in-Review quick links) hides — leaving no way
   to reach those screens. Re-added them to the + circle toolbar
   menu under an "Explore" section, separate from the "Add" section.
   Always reachable now regardless of filter state.

3. Aircraft Stats screen looked blank when no flights had an
   aircraftType (CSV imports don't include type). Now shows an
   explanatory empty state with three concrete paths to populate
   the data: live tap, manual fill-in, or new manual entries.

4. CSV import now enriches each row with the scheduled aircraft
   type via routeExplorer.searchSchedule(carrier+flight#+date).
   Takes the first result that matches the route's dep/arr (or any
   match for that flight#/day if route doesn't match), pulls
   equipmentIata as the aircraft type. Best-effort: old flights
   without schedule data, unmappable carriers, or network failures
   are silently skipped — the flight still saves without a type.

   Preview screen has a "Look up aircraft type" toggle (on by
   default). Importing-phase shows live count of enriched rows.
   ImportCSVView now takes a RouteExplorerClient; HistoryView
   passes the existing instance.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 18:43:19 -05:00
Trey T 572e81406d Route map fixes: swipe-dismiss, filter button, plane scale
Three fixes the user called out:

1. Can't swipe down to dismiss the map screen anymore — the inner
   .sheet for the drawer was eating the parent sheet's
   swipe-to-dismiss gesture. Replaced with a custom overlay drawer
   built from a ZStack + DragGesture, so the map surface is now
   free for the parent sheet's interactive dismiss. Drawer has
   two snap states (peek + expanded), tap the handle or drag up
   to expand, drag down to collapse. Live dragOffset gives
   immediate finger feedback; on release we spring-snap to a state.

2. Filter button added to the map's toolbar. Opens the full
   HistoryFilterSheet so users can edit filters without going
   back to History. Icon switches to .fill variant when any
   filter is active.

3. Plane icons now scale 0 → 1 → 0 across each flight (half-sine
   curve) so they fade in at takeoff, peak at the midpoint, and
   fade out at landing. New FlightSegment.planeScale(at:) returns
   sin(localProgress * π). Scale is applied to both .scaleEffect
   and .opacity so it's a real "appear-and-vanish."

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 18:32:48 -05:00
Trey T f97d5f52ec Route map: full rewrite — plane fly-through, fit camera, real drawer
The old map opened at a default camera with arcs staggered in over
seconds and dots/lines in random order. This rewrites it end-to-end
to match the spec the user signed off on.

Behaviors:
- Camera fits to the bounding region of all filtered dep/arr coords
  with 40% padding on first appear AND on every filter change. You
  always see your data on open.
- A 4-second total animation auto-plays from oldest flight to
  newest. Each flight gets a plane icon (rotated to its travel
  bearing) that flies along the great-circle from dep to arr,
  drawing a solid orange line behind it. Arcs accumulate and stay
  drawn at full color.
- Airport dots are invisible at start. Each one pops in with a brief
  scale-up pulse (~200ms eased) when its first flight reaches it —
  departure when the plane takes off, arrival when it lands.
- Most-recent flight stays highlighted in yellow with a thicker
  stroke as a focal point.
- Map style switched from `.imagery` to `.standard(.muted)` —
  desaturated political map with borders + labels so dots actually
  read against the surface.
- The always-on bottom overlay is now a real swipe-up sheet with
  detents [.fraction(0.14), .medium, .large]. Peek shows the count
  + a circular progress ring + a replay button. Expanded shows
  filter chips and quick stats (airports / routes / years).

Architecture:
- AnimationSchedule value type holds per-flight FlightSegments and
  per-airport AirportLights with their start/end progress windows.
  Built once per filter change, immutable thereafter. Reads from
  the view body are O(1) sliced lookups.
- FlightSegment.coordsVisible(at:) slices the precomputed
  great-circle array for partial draw. FlightSegment.head(at:)
  returns the plane's current coord + travel bearing.
- runAnimation() drives progress 0→1 over 4s via a Task loop at
  ~60fps using system clock (so dropped frames don't slow it down).
- Old code dropped: the staggered .task(id:) reveal, the
  always-visible drawer overlay, the AirportDot view, the inline
  great-circle helper. All consolidated into AnimationSchedule and
  AirportPulseDot.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 18:26:51 -05:00
Trey T e1b7fd4b0d History row: boarding-pass classic design
Replaces PassportFlightRow with the "Classic" boarding-pass design
selected from design/boarding-pass-variants.html.

Anatomy:
- BoardingPassShape: custom SwiftUI Shape — rounded rect with two
  semicircular cutouts at the perforation column (top + bottom).
  Hand-drawn clockwise from top-left so the path closes cleanly.
- Stub (88pt wide): orange linear-gradient background. "WN"
  monospaced eyebrow at 9pt/tracking 2.2 at top, padded flight
  number ("0007") at 28pt monospaced heavy in the middle,
  BarcodeStripe at the bottom.
- BarcodeStripe: Canvas-drawn faux barcode — 16-element width
  pattern cycles across the width, even indices fill, odd are gaps.
- Body (flex): card background. Route IATA pair at 24pt mono heavy
  with an orange ▶ between, date in 10pt tracked mono uppercase,
  meta row of EQP / TAIL / MI metadata with mono labels in tertiary
  ink and values in primary.
- Perforation: GeometryReader-driven dashed line drawn between stub
  and body, inset top/bottom to stop short of the cutouts.

Distance is recomputed inline via haversine from the AirportDatabase
since the row doesn't get the FlightHistoryStore passed in. Mile
display only — clean integer rounded value.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 18:08:55 -05:00
Trey T 86582cea4a History tab: passport redesign
Replaces the History tab end-to-end with a passport-styled experience
modeled on Flighty's Passport but with its own identity:

- New HistoryStyle palette: runway orange (#FF5722) + midnight navy
  + warm cream paper. Adaptive light/dark surfaces, mono-digit
  display numbers, card chrome modifier. Scoped to History so the
  rest of the app's FlightTheme stays untouched.
- New PassportComponents library: HeroStatCard (orange / navy / gold /
  green / photo variants), YearTabStrip, OCRPassportFooter (the OCR
  passport-bottom flex text), StatColumn, HistorySectionLabel.

Screens rewritten:
- HistoryView — ScrollView feed with title header, year tab strip,
  stacked hero cards (this-year passport, most-flown aircraft, quick
  links to map/aircraft/year-in-review), and passport-styled flight
  rows in cards. Search, sort, filter, and add affordances live in
  the toolbar.
- PassportView (was LifetimeStatsView) — stacked colored hero cards
  for flights, distance, time aloft, top route, top airline, longest
  flight, plus repeated-airframes list. Year tabs at top scope
  everything. OCR-passport flex footer at the bottom.
- AircraftStatsView (new) — Total / Newest / Oldest header tiles,
  ranked list of types with the airframe photo as the row background,
  "Repeat Offender" hero card with the most-flown tail's photo
  full-bleed.
- HistoryRouteMapView — satellite map style (.imagery), brighter
  arcs in runway orange with the most-recent leg in fluorescent
  yellow, persistent bottom navy drawer showing the passport summary
  + active filter chips + replay button.
- YearInReviewView — horizontal TabView paged card deck, each card a
  full-bleed hero composition optimized for screenshot share. Cover
  card with year number set in 140pt monospaced bold.
- HistoryDetailView — restyled with passport palette. Aircraft card
  uses a labeled grid (Type/Tail #/First Flight/Age/Repeats/ICAO24)
  with em-dashes for missing data. New Detailed Timetable card with
  Scheduled vs Actual columns, late times in red.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 11:13:24 -05:00
Trey T a33a56176d History v3: search, sort, filters, interactive lifetime map
Pulls the History tab toward Flighty's Passport in three ways:

Search + sort + filters on the list
- .searchable text field across flight #, route, IATA codes
- 6-way sort menu (newest/oldest, longest/shortest, by airline,
  by flight number); list either groups by year (date sorts) or
  goes flat (everything else)
- New HistoryFilters value type: year set + airline set + airport
  set + aircraft-type set + query. .matches(flight) predicate
- New HistoryFilterSheet with multi-select chips and live counts
  per option (we only show years/airlines/airports/types you've
  actually flown)
- Active-filter chip row above the list, tap to remove individual
  filters; "Clear" wipes all
- Totals strip retitles to FILTERED TOTALS when filters are on
  and recomputes against the visible subset

Interactive route map
- Tap an arc style preserved, but most-recent flight now
  highlighted brighter so it stands out at a glance
- Tap an airport dot → AirportFlightsView for that airport
- Filter sync: dots tied to currently-filtered airports light up
- Replay button in the toolbar restarts the reveal animation
- Map respects History tab's filters (visible arcs match) but
  draws airport dots from the FULL log so geography stays stable

Airport drilldown (new)
- AirportFlightsView shows summary (departed/arrived/total),
  top 10 destinations from that airport, and the full list of
  flights through it
- "Filter list" toolbar action sets the History filter to just
  that airport and pops back

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 10:38:28 -05:00
Trey T d639cdef15 History: import flights from CSV (Southwest PNR format)
Adds a "Import CSV…" entry to the History tab's + menu, opening a
file picker → preview → save flow.

- CSVFlightImporter: RFC-4180-ish quote-aware parser + format
  detection. Today only recognizes the Southwest PNR export schema
  (columns Flt No / ORG / DST / Dep Date / OPNG Flt). Returns
  ParsedFlight values with carrier, flight number, route, and
  scheduled departure.
- ImportCSVView: SwiftUI .fileImporter picks a CSV from Files (iCloud
  Drive / On My iPhone / etc.), parses on a Task, dedupes against
  the existing log via FlightHistoryStore.exists(...), shows a
  preview with "N new · M dupes" counts, imports on confirm.
- LoggedFlights created from import store the PNR in notes
  ("PNR: ABC123") and source "csv-import".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 10:27:20 -05:00
Trey T 9e1dbfbf90 Re-enable flights:// URL scheme (with UISceneConfigurations)
Switching the app target's Info.plist back to a manual file at
Flights/Info.plist so we can register CFBundleURLTypes for the
flights:// scheme. The Info.plist now includes UISceneConfigurations
inside UIApplicationSceneManifest — missing that key was the likely
cause of the device-launch crash on the previous attempt.

Verified launch + xcrun simctl openurl flights://import?... on the
iPhone 17 Pro simulator. App stays running after URL handoff.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 10:27:25 -05:00
Trey T f40b02f68d Fix launch crash: drop CloudKit init, lazy Wallet, revert plist
Three changes that together stop the crash-on-launch the previous
build hit on device:

1. FlightsApp: stop attempting `cloudKitDatabase: .private(...)`
   when the iCloud entitlement isn't set — SwiftData's
   ModelContainer init can fatalError validating the cap (not just
   throw), so try? doesn't save us. Go straight to local config,
   with an in-memory fallback if the disk store is incompatible
   with the current schema.

2. WalletPassObserver: don't touch PKPassLibrary in init().
   `@StateObject` accesses .shared at view body time, which on
   first launch can race against PassKit subsystem init. Move the
   library bring-up into an explicit start() called from
   RootView's .task.

3. Flights app target Info.plist: revert from manual
   INFOPLIST_FILE back to GENERATE_INFOPLIST_FILE = YES (with the
   INFOPLIST_KEY_* entries restored). The manual plist I wrote
   was missing some auto-generated keys the device launch path
   needs. Loses the custom URL scheme — `.onOpenURL` handler
   stays in code but won't fire until we re-add the scheme via a
   manual plist that's been verified end-to-end.

Verified launch on iPhone 17 Pro simulator — scene becomes key
window, no fatalError. The earlier on-device crash was almost
certainly the CloudKit init.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 10:21:47 -05:00
Trey T d444a5caac Share Extension: back out target, keep URL scheme
The signed export needs a provisioning profile for the extension's
bundle id (com.flights.app.share) which the dev portal hasn't
provisioned for this team. Reverted the pbxproj surgery that added
the FlightsShareExtension target.

Kept:
- Flights/Info.plist with CFBundleURLTypes registering flights://
- `.onOpenURL` handler in RootView that consumes
  `flights://import?carrier=...&num=...&dep=...&arr=...&date=...`
- FlightsShareExtension/ source files (ShareViewController.swift +
  Info.plist) in the repo for later — when the extension bundle id
  is provisioned, the target can be re-added in Xcode UI in a few
  minutes (no need to redo the Swift work)

Until the extension is built, users can still get Mail integration
by installing a one-time Apple Shortcut that takes selected text
and opens `flights://import?...`. The host app handles the rest.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:55:09 -05:00
Trey T 803c812f86 History v2: everything — Wallet auto-prompt, age, track replay, share
Adds the deferred pieces from the v1 ship, plus a Mail Share
Extension target so the iOS share sheet picks up flight emails.

Track replay
- `LoggedFlight.icao24` field — populated from FR24 enrichment on
  live-tap adds.
- HistoryDetailView's track query now fires for any flight younger
  than 7 days that has an icao24, pulling the actual flown path
  from OpenSky's /tracks/all endpoint. Falls back to a clean
  great-circle arc otherwise.

Wallet auto-prompt
- RootView subscribes to WalletPassObserver.shared. When the user
  adds a boarding pass to Apple Wallet, the observer's published
  `pendingPass` flips and we present AddFlightView pre-filled with
  the parsed origin / destination / flight # / date.

Airframe age + first-flight date
- `AirframeMetadataService` queries OpenSky's
  /api/metadata/aircraft/icao/{icao24} endpoint. Caches results in
  the existing `AirframeMetadata` SwiftData model so we never
  re-fetch the same airframe twice. (jetphotos and planespotters
  pages are both Cloudflare-gated; OpenSky's metadata API is the
  cleanest free source.)
- HistoryDetailView fires the lookup on appear and persists the
  result; the aircraft card already renders "Age" when a date is
  cached.

Mail Share Extension
- New `FlightsShareExtension` Xcode target (app-extension product
  type) built into the app bundle via an Embed Foundation
  Extensions copy phase.
- `ShareViewController` (SLComposeServiceViewController) parses
  shared text + URLs for flight-shaped codes ("AA 2178"), route
  hints ("DFW → ORD"), and date strings.
- On Save, the extension builds a `flights://import?carrier=…&num=
  …&dep=…&arr=…&date=…` URL and opens it via the responder-chain
  openURL trick (Share Extensions can't access UIApplication
  directly).
- Host app handles the URL via `.onOpenURL` in RootView, switches
  to the History tab and presents AddFlightView prefilled.
- App now has an actual Info.plist (CFBundleURLTypes registered
  for `flights://`); switched from GENERATE_INFOPLIST_FILE to
  INFOPLIST_FILE for the app target.

If the dev portal hasn't registered bundle id
`com.flights.app.share` for the team, the signed archive will
fail. In that case the simpler URL-scheme path still works —
users can hit `flights://import?...` from a Shortcut.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:51:30 -05:00
Trey T 8308d9cf03 History: drop iCloud entitlement (cap not provisioned)
The signed iPhone archive failed because the team's iOS provisioning
profile doesn't include the iCloud capability or our
iCloud.com.flights.app container. Empty out the entitlements payload
so signing succeeds with no caps requested. The app's ModelContainer
init already falls back to local-only SwiftData when the CloudKit
container isn't reachable — history persists on-device, just doesn't
sync across devices yet.

When the iCloud cap is provisioned for com.flights.app, restore the
entitlements payload (one diff) and CloudKit sync turns back on
automatically.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:35:52 -05:00
Trey T 847e5c6035 Flight History (v1): logbook, stats, animated route map, year-in-review
Adds a new History tab implementing the core of Flighty's Passport
feature set, free + iCloud-synced.

Data layer
- `LoggedFlight` and `AirframeMetadata` @Model classes (SwiftData)
- ModelContainer with CloudKit private DB; falls back to local-only
  when CloudKit cap isn't provisioned so the app stays functional.
- `FlightHistoryStore` wraps the ModelContext for save/delete +
  dedupe + great-circle distance / duration helpers + tail-repeat
  counting.

History UI
- `HistoryView` — list grouped by year, totals strip at top, swipe
  to delete, empty state with instructions.
- `HistoryRowView` — airframe photo thumbnail (planespotters),
  flight#, route, type, date.
- `HistoryDetailView` — title → route → photo → flown-path / great-
  circle map → aircraft card (type, tail, age, "Nth time on this
  airframe") → editable notes → delete.

Add paths
- "+ Add to my flights" button on the live aircraft sheet —
  pre-fills the form from FR24 enrichment (carrier, flight#,
  route, aircraft type, tail).
- Manual entry form (`AddFlightView`) with route-explorer autofill
  via `searchSchedule(carrierCode:flightNumber:startDate:endDate:)`.
- Calendar scan (`CalendarFlightImporter` + `CalendarImportView`) —
  EventKit access prompt → regex-detect flight-shaped events
  across last 5 years → dedupe → batch-confirm with route-explorer
  enrichment.
- `WalletPassObserver` (PassKit) — observes the library for new
  boarding passes and parses origin/destination/flight#/seat.
  Service is wired; explicit UI prompt deferred to follow-up.

Stats + visualization
- `StatsEngine` — totals (flights / miles / hours / airports /
  airlines / aircraft / countries) + narrative stats (top airline,
  top route, top airport, longest, shortest, repeated tails).
- `LifetimeStatsView` — big-number tile grid + highlights cards +
  repeated airframes list.
- `HistoryRouteMapView` — every great-circle arc the user has
  flown, animating in oldest → newest on first appear. Airport
  dots sized log-scale by visit count.
- `YearInReviewView` — Spotify-Wrapped-style horizontal card deck
  for the current year: total miles, airports + countries, hours
  airborne, top airline, top route, longest flight.

Entitlements
- New `Flights.entitlements` with `iCloud.com.flights.app` CloudKit
  container.

Risk note: the build falls back to local-only SwiftData if the
CloudKit container isn't provisioned for team V3PF3M6B6U / bundle
id com.flights.app. The History feature works fully either way;
sync requires the cap to land.

Deferred to follow-ups
- Wallet auto-prompt UI binding (service exists, view hook TBD)
- Mail Share Extension (separate app-extension target)
- Jetphotos first-flight-date scraping
- OpenSky historical track replay (great-circle fallback ships)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:34:38 -05:00
Trey T a1831d0034 Detail sheet: reorder to title → route → photo
User asked for this order. Photo now slots between routeSection and
LIVE STATE rather than living as a hero above the title. Used a -16
horizontal padding so the photo still goes full-bleed inside the
otherwise-16pt-padded content stack.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 08:51:11 -05:00
Trey T 16b874a7ad Detail sheet: hero aircraft photo via planespotters
Adds a 200pt-tall hero image at the top of the detail sheet showing
the actual airframe (registration-keyed), surfacing special liveries
naturally — photographers chase one-off paint jobs first, so the
most recent photo is usually the most recent livery scheme.

AircraftPhotoService wraps planespotters.net's public API:
  - Lookup by registration (FR24 enrichment) first
  - Falls back to ICAO24 hex when no registration
  - In-memory cache (hits and misses) so we never re-query the same
    airframe twice in a session
  - User-Agent includes contact URL per planespotters' TOS
  - Photographer attribution rendered in the AIRCRAFT section,
    tap to open the planespotters page

Sheet hides the banner entirely when no photo exists.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 08:45:28 -05:00
Trey T 92bc6ed52e Live feed: FR24 primary, OpenSky fallback
OpenSky's free anonymous tier has sparse ground coverage — at DAL it
returned a single airborne aircraft when there were 3+ SWA jets
visibly parked on the apron. FR24's feed.js aggregates ASDE-X, MLAT,
and multiple community ADS-B feeds and reliably surfaces ground
aircraft at major airports. We now query FR24 first and fall back to
OpenSky only when FR24 errors.

FR24's payload also carries departure + arrival IATA + flight number
+ aircraft type + tail number inline, so we shortcut the
route-explorer schedule lookup in the detail sheet: a new
`LiveAircraft.Enrichment` struct holds those fields, and the
ResolvedRoute cascade gains a `.fromFR24` first-tier case that uses
them directly. The `typeCode` and `airlineICAO` computed properties
prefer enrichment values over the AircraftDatabase / callsign-prefix
heuristics — this also fixes the case where FR24 callsigns use the
IATA carrier ("AA0013") which our 3-letter-prefix derivation would
have rejected.

OpenSky still owns trail polylines and recent-flights history; only
the live position fetch swapped sources.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 07:52:56 -05:00
Trey T 68c60ec087 Detail sheet: lead with depart → arrival, push LIVE STATE below
When you tap an aircraft you almost always want to know where it
came from and where it's going. Moving routeSection above the
divider so it's the first block under the callsign header — no
scrolling to find it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 07:44:48 -05:00
Trey T dee6df1ac6 Live map: center on user, persist region, zoom-based aircraft cap
- Initial region cascade: restore last viewed region → on location
  grant, animate to a city-level view centered on user; otherwise
  fall back to the saved/continental default. User pans are detected
  via a center-delta threshold so a late location grant doesn't yank
  the camera away from where the user is looking.
- LocationService: thin one-shot CLLocationManager wrapper with
  CheckedContinuation. Added NSLocationWhenInUseUsageDescription via
  INFOPLIST_KEY_ build setting.
- Visible-aircraft cap scales with zoom (<2°: uncapped, <8°: 100,
  <25°: 150, else 200). Active filters bypass the cap entirely so
  every match always renders. When capped, we keep the N closest to
  the map center.
- Footer shows "Showing N of M" when the cap clips, "N aircraft"
  otherwise.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 07:37:34 -05:00
Trey T de7a70b198 Live tab: hardening pass for smooth, snappy feel
Targeted every hitch in the live-tracker flow:

1) Async DB loading. AircraftDatabase (1.5MB JSON) and
   AircraftRegistry (200KB JSON) were parsing synchronously on the
   main thread the first time anything touched them — typically when
   the user first opened the Live tab. Both now bootstrap with a
   safe fallback in init() and parse the full JSON on a background
   Task at app launch (FlightsApp.init calls preload() on both).
   Reads are NSLock-guarded with lock.withLock {} (async-safe).

2) Crossfade suppression on refresh. The 15s auto-refresh swaps the
   `aircraft` array wholesale, which made SwiftUI try to crossfade
   every annotation. Wrapped the assignment in a Transaction with
   disablesAnimations = true so the swap is instant.

3) Cached filtered aircraft. `filteredAircraft` was a computed
   property running through every aircraft on every body re-render
   (e.g. while a sheet was animating in). Moved to @State,
   recomputed via .onChange handlers on each dependency.

4) Lighter pin view. AircraftPin no longer carries the full
   LiveAircraft struct or contains a conditional ZStack — just the
   minimal {tint, rotation, isSelected} props, conforms to
   Equatable so SwiftUI can skip diffing identical pins, and uses
   .animation(nil) on the tint to prevent color crossfades during
   refresh.

5) Coarse relative-time bucketing. The footer's "updated 5s ago"
   text was ticking every second, which dirtied the footer subtree
   on every body pass. Now snaps to {just now, <30s ago, <1m ago,
   Nm ago} — no second-by-second ticks.

Net effect: tab opening is instant (DBs are warm), refreshes don't
flicker, filter sheet animation is smooth, map panning isn't fighting
view-tree rebuilds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 07:27:26 -05:00
Trey T 390a158487 Live tab: restore type filter via bundled aircraft DB + sheet pickers
Two issues:

1) Menu list stutters. SwiftUI's Menu renders all rows in a
   non-virtualized popover. With 30+ airlines and Buttons containing
   Labels, scrolling jank starts immediately. Switched the airline +
   type filters to a sheet-based picker (LiveFilterPicker) backed by
   a real List — virtualized scrolling, plus a search bar that
   filters as you type.

2) Type filter was non-functional because OpenSky's anonymous tier
   returns ADS-B emitter category as null for most aircraft.
   Replaced with a real type-code lookup: bundled aircraftDB.json
   (1.5MB slimmed copy of OpenSky's aircraft metadata, 100k
   commercial-class airframes, filtered to skip GA / gliders /
   ultralights). AircraftDatabase.shared.typeCode(forICAO24:)
   returns the ICAO type designator (B738, A21N, etc.).
   AircraftDatabase.displayName(forTypeCode:) maps the top ~130
   common codes to friendly names ("B738" → "Boeing 737-800").

LiveAircraft now exposes a `typeCode` computed property that
indexes into the DB. The type filter chip → sheet flow uses the
same LiveFilterPicker as airlines, with multi-select + counts +
search.

Both pickers keep the "Selected (N)" group pinned at the top so
the user always sees what they have active without scrolling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 07:21:49 -05:00
Trey T d6fb73db2c Live tab: stable refresh loop, replace dead Type filter with Altitude
Two reported bugs:

1) Bottom bar appeared to be constantly refreshing. Cause:
   .task(id: refreshTick) restarted every time isLoading flipped
   (twice per refresh cycle) AND every time aircraft.count changed.
   On top of that, on-pan refresh fired on every map camera settlement
   — including the micro-settlements that happen when annotations
   re-render after each fetch. The cumulative effect looked like a
   tight loop.

   Replaced the cascade with a single long-lived .task running a
   while-loop that sleeps refreshInterval then refreshes. SwiftUI
   cancels it when the view disappears. Added a bbox-delta gate
   (refreshIfRegionChanged) so pan-triggered refreshes only fire
   when the visible center moved >15% of the box width or the zoom
   changed >20%. Removed nextFetchAllowedAt — the throttle is now
   structural rather than time-based.

2) Tapping the "Type" filter did nothing. Cause: OpenSky's anonymous
   tier returns the ADS-B emitter category as null/0 for the vast
   majority of aircraft, so cachedCategoryItems was empty and the
   menu opened with no rows. Replaced the Type filter with an
   Altitude filter (below 10k / 10–25k / 25–40k / above 40k ft),
   driven by data we always have (baroAltitude / geoAltitude). The
   menu always has 4 rows with live counts.

Also: tightened boundingBox(of:) to return a labeled tuple so the
delta-gate code can use .latMin / .lonMax directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 07:01:54 -05:00
Trey T a031a1aafd Live tab: resolve dep/arr for every aircraft via schedule cascade
OpenSky alone can't answer "where is this plane going" — /flights/aircraft
only returns landed flights, so an in-progress flight produced an empty
route card. Built a 3-tier resolution cascade that always lands somewhere
useful:

1) Scheduled lookup via route-explorer's /schedule endpoint. Parse the
   ADS-B callsign (AAL3055 → carrier AA, number 3055), pull the day's
   operating record, get real departure + arrival airports and times.
   Works for every carrier route-explorer indexes (mainline + many
   regionals). Smoke-tested live: AAL3055 → DFW→MIA, DAL1050 → IAH→MSP,
   AAL1753 → XNA→DFW, AAL2978 → XNA→PHL. All from live aircraft caught
   in the DFW area at the moment.

2) OpenSky historical (/flights/aircraft). If route-explorer doesn't
   have the carrier, fall back to whatever OpenSky last logged for
   this airframe. Labeled "LAST FLIGHT · 3h AGO" etc.

3) Trail-derived inference. Last resort for ICAO24s nothing knows about
   (private jets, cargo, ad-hoc callsigns). Pull the OpenSky track,
   take the first position, find the nearest airport in the bundled
   3,900-entry DB (new AirportDatabase.nearestAirport(to:)). Shows
   "Departed from KAUS — Austin Bergstrom" with "Heading to —"
   acknowledging arrival is unknown.

Plumbed RouteExplorerClient through LiveFlightsView → RootView →
FlightsApp. Added searchSchedule(carrierCode:flightNumber:startDate:
endDate:) that returns [RouteFlight] directly (the /schedule envelope
is `{ flights: [...] }`, distinct from /route's `{ connections: [...] }`).

Three distinct route cards now render based on what we resolved:
  - scheduled  → green live dot + "Departed / Heading to"
  - openSky    → "Departed / Arrived" with age label
  - inferred   → "Departed from X / Heading to —"
  - none       → explicit "Route unavailable" message

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 06:48:26 -05:00
Trey T ddfcf3e0e4 Live tab: cached filter lists, tighten airline filter, clearer route fallback
Three fixes from the latest round of testing:

1) Tapping the airline filter froze the app for several seconds. Cause:
   `visibleAirlines` / `visibleCategories` were computed properties
   evaluated on every body re-render, each iterating the aircraft array
   and hitting the 2,695-entry registry. Caching them in @State and
   refreshing only via .onChange(of: aircraft) takes the menu-open
   cost to near-zero.

2) Picking "Southwest" in the airline filter still left other carriers
   on screen. Cause: when no callsign-derivable airline ICAO was
   present (N-number GA traffic, ad-hoc callsigns), the filter `guard
   let` silently let the aircraft through. Tightened to require an
   ICAO match when any airline filter is active.

3) The detail sheet showed no departure / arrival airport on most
   selections. Cause: OpenSky's /flights/aircraft endpoint only
   returns flights *after they've landed*, so an in-progress flight
   has no entry. We were waiting for one to appear forever.

   Rewrote the route section: now always shows whatever's most
   recent, labeled "IN FLIGHT" when the snapshot is < 6h old (with a
   green live-dot), "LAST FLIGHT · 3h AGO" otherwise, and an explicit
   "Route not available from OpenSky for this aircraft" card when
   the endpoint returned nothing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 06:41:22 -05:00
Trey T 0550376e3d Live tab: fix sheet-collision freeze, safe-area layout, A→Z filters
Three reported bugs:

1) Tap-a-plane freezes the app — two .sheet modifiers stacked on the
   same view fight each other in SwiftUI. Consolidated into a single
   .sheet(item:) backed by an ActiveSheet enum (aircraft / settings).
   Also dropped the Map's selection binding; relying purely on the
   pin's .onTapGesture eliminates the dual-binding race.

2) Filter bar sits behind the nav title / tab bar — replaced the
   ZStack overlay layout with safeAreaInset(edge:) so the search +
   chip bar at the top and the count/refresh/gear strip at the
   bottom are first-class inset views. Map fills the rest properly.

3) Aircraft type / airline menus not A→Z — both filter lists sorted
   by displayed name (localizedCaseInsensitiveCompare) instead of by
   ICAO code / category number. AirlineFilterItem and
   CategoryFilterItem now carry the displayed `name` separately and
   sort on it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 06:26:55 -05:00
Trey T 6b33a104c8 Live tab: bundled airline DB, OpenSky login, in-flight trails
Three follow-ups to the live tab landed together:

1) Bundled airline registry
   - airlines.json (208KB, 2,695 entries sourced from FR24's
     /mobile/airlines feed and slimmed to {icao,iata,name,logo}).
   - AircraftRegistry rewritten as an instance singleton that loads
     the bundle at startup, indexes by both ICAO and IATA, and falls
     back to a small hardcoded subset if the bundle is unavailable.
   - Detail sheet now shows the airline's logo (loaded from FR24's
     CDN via AsyncImage) alongside the callsign. Filter chips use
     the real names everywhere.

2) OpenSky account login
   - OpenSkyCredentials: Keychain wrapper that stores username +
     password using SecItem APIs. Posts a notification on change so
     the OpenSkyClient can refresh its in-memory copy.
   - OpenSkyClient now sends HTTP Basic auth when credentials are
     present. Anonymous fallback unchanged.
   - OpenSkySettingsView: tap the gear in the footer to sign in.
     Credentials are verified against /states/all before being
     stored; sign-out clears Keychain. Raises the quota from ~100
     to ~4000 requests/day.

3) Flight trails
   - AircraftTrack model decodes OpenSky's /tracks/all heterogeneous
     path array into typed TrackPoint entries.
   - OpenSkyClient.track(icao24:) fetches the current/most-recent
     track for an aircraft.
   - LiveFlightsView renders a MapPolyline trail along the path of
     whichever aircraft is currently selected. Cleared on
     deselection; race-guarded against rapid selection changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 06:16:49 -05:00
Trey T 888943deb4 Add Live Flights tab: real-time aircraft map with filters + tap detail
New top-level TabView (RootView) splits the app into:
  Tab 1 (Search): existing RoutePlannerView home
  Tab 2 (Live):   live flight tracker

Live tab features:
- MapKit map showing every aircraft in the visible viewport, rotated
  to true heading. Color-coded by vertical state: climbing/level/
  descending/on-ground.
- Auto-refresh every 15s + on map pan/zoom (debounced); manual
  refresh button. Rate-limit aware (60s backoff on HTTP 429).
- Tap any aircraft → modal sheet with live state grid (altitude,
  speed, heading, vertical rate, squawk, last-contact), current
  route (lazily fetched per-aircraft from OpenSky's /flights/
  aircraft endpoint, mapped from ICAO to IATA airport codes), and
  recent flight history (up to 8 prior legs).
- Filters: airline (multi-select from currently visible callsigns,
  with counts), aircraft type (ADS-B emitter category), airborne-
  only toggle. All filters render as horizontal chips and clear
  with a single tap.
- Search bar: callsign/flight number — submitting centers the map
  on the match and opens its detail sheet.

Data source: OpenSky Network REST API. Free, anonymous (~100 req/
day cap), JSON. Same ADS-B data FR24 starts with — without satellite
ADS-B coverage but more than enough for the in-flight tracker use
case. Reviewed FR24's APK and confirmed they migrated their live
feed to gRPC+protobuf with anti-bot device-id headers; OpenSky's
plain JSON is the right tradeoff for our build.

Implementation:
- LiveAircraft model: decodes OpenSky's mixed-type position arrays
  into a typed struct; computed properties for ft/knots/heading and
  airline ICAO extracted from callsign.
- OpenSkyClient: actor with /states/all + /flights/aircraft. Bbox
  query, throttle-aware errors.
- AircraftRegistry: ~80 ICAO → (IATA, name) entries for the major
  carriers; everything else falls through to the raw ICAO code.
- LiveFlightsView: the main map + filter UI.
- LiveFlightDetailSheet: tap modal with live state + route history.
- RootView: TabView wrapping RoutePlannerView (Search) and the new
  LiveFlightsView (Live).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 06:08:58 -05:00
Trey T 92a69cf16c Add Sun Country (SY) load integration
Sun Country runs Navitaire (same PSS as JSX) but exposes their public
availability search endpoint that returns BETTER load data than AA:
per-flight `capacity` AND `sold` (booked passenger count), so we can
compute exact load factor.

Implementation:
- AirlineLoadService.fetchSunCountryLoad: POSTs to
  syprod-api.suncountry.com/api/nsk/v4/availability/search/simple.
  Parses results→trips→journeysAvailableByMarket, matches by flight
  number, pulls capacity + sold + equipmentType from legInfo.
- Returns a single Economy CabinLoad with capacity/booked = sold.
  No standby program — SY is single-cabin Y.
- Auth: Azure APIM subscription key + a long-lived dotREZ JWT
  (both static, captured from suncountry.com network traffic, neither
  is a user session token).
- Anti-bot: Imperva WAF in front of syprod-api.suncountry.com is gated
  on User-Agent + Referer + Origin headers. applySunCountryBrowserHeaders
  mirrors the pattern we use for UA / AA. NO WebView needed.
- Explicit ⚠️ log when 403 Incapsula response detected, pointing at
  the header helper.

Test infrastructure:
- knownDailyFlights now carries a dayOffset (today vs tomorrow) per
  carrier — different upstreams have different snapshot windows:
  AM is T-1d..T+0 (today); SY's Navitaire only returns future flights
  (tomorrow); others default to tomorrow as a safer choice.
- Added test_SY_sunCountry with hubs MSP/LAS/MCO/DEN. Fallback is
  SY104 LAS-MSP tomorrow.

Docs:
- AIRLINE_INTEGRATION_GUIDE: SY status row + full section 5c covering
  endpoint, auth, headers, response shape, failure modes, and how to
  re-capture tokens when they rotate.

Reverse-engineering notes:
- SY app is Flutter (Dart AOT) — bridge smali is minimal. Strings
  extracted from libapp.so revealed isNonRevTrip/isStandby/
  inventoryControl keywords + the syprod-api hostname.
- Token endpoint is PUT (not POST). Returns {"data":null} — token is
  the existing Authorization JWT, not a session refresh.
- Confirmed working from plain curl with browser headers (no Imperva
  TLS-fingerprint gate beyond UA/Referer/Origin).

Test run 2026-05-26 (xcodebuild test):
   AA, AM, AS, B6, EK, KE, SY (capacity=186 sold=184 load=99%), UA
  ⏭️ XE
  8 passing, 1 skipped, 0 failures, 11s total.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:30:55 -05:00
Trey T 398862e88b Add Aeromexico (AM) load integration
AM exposes a public Sabre GetPassengerListRQ proxy via AWS API Gateway —
no auth, no API key — used by the consumer app's flight-status widget.
The endpoint returns per-cabin authorized/available plus full standby +
upgrade passenger lists with isStaff flag, numeric priority, fare class,
position movement, and PII (matching what we get from AA but with
better cabin capacity data).

Implementation:
- AirlineLoadService.fetchAeromexicoLoad: parallel GETs against
  /rb/passengerliststandby and /rb/passengerlistupgrade, merging
  cabin info + per-list passengers into a single FlightLoad. Headers
  channel=web / flow=CHECKIN extracted from the AM APK Constant.smali.
  Cabin codes Y/C/P/F mapped to readable names (Economy / Clase Premier /
  Premier One / First).
- 4-digit zero-padding of the operating flight code (server validates
  ^[0-9]{4}$).
- "NONE LISTED" warning treated as nil (snapshot outside T-1d/T+2d
  window or no pax yet); explicit log so future failures are
  diagnosable.

Test infrastructure:
- Added test_AM_aeromexico using MEX/GDL/MTY/CUN hubs.
- Cascading fallback in runAirlineLoadTest: try the route-explorer
  discovered flight first; if it returns nil (typical for AM Connect
  regionals that aren't in Sabre), fall back to the known-daily flight
  (AM0058 MEX-MTY). Pattern useful for any future carrier whose
  regional ops don't show up in the load system.
- knownDailyFlights extended with AM0058 MEX-MTY.

Docs:
- AIRLINE_INTEGRATION_GUIDE: AM status row + full section 5b with
  endpoint params, response shape, snapshot window timing, failure
  modes, cabin code mapping, regional carrier caveat.

Test run 2026-05-26:
   AA, AM (cabins=1 upgrade=1), AS, B6, EK, KE, UA  ⏭️ XE
  7 passing, 1 skipped, 0 failures, 12s total.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 15:31:59 -05:00
Trey T 4a939340a2 Remove Spirit Airlines (defunct — merged into Frontier)
Spirit ceased operations, so the fetchSpiritStatus path and all NK
references are dead code. Pulled out:

- AirlineLoadService: drop `case "NK"` from the router, delete
  fetchSpiritStatus (the GetFlightInfoBI POST that was returning 403
  even after our APIM key was accepted).
- FlightLoadDetailView: drop the `schedule.airline.iata == "NK"` branch
  and the spiritUnavailableView placeholder.
- FlightLoad model: update the airlineCode comment.
- AirlineLoadIntegrationTests: remove test_NK_spirit and drop "NK" from
  statusOnlyAirlines / knownDailyFlights fallback table.
- AIRLINE_INTEGRATION_GUIDE.md: tombstone the Spirit section and remove
  it from the cheat-sheets and recommendations.

Test suite now: 6 airlines passing (AA, AS, B6, EK, KE, UA), 1 skipped
(XE — WKWebView host required), 0 failures, runs in ~10s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 14:23:33 -05:00
Trey T 62729213d7 Add FlightsTests target + fix AA load fetcher (Android UA version bump)
AA was silently returning nil because the server now rejects User-Agent
"Android/2025.31" with HTTP 403 ("Please update your version of the
American Airlines app"). Bumped to "2026.14" (matches the APK in
airlines/) and centralized to a constant so the next bump is one line.
Added comprehensive logging to fetchAmericanLoad (was zero) so the next
breakage won't be silent — including an explicit ⚠️ when the server
returns the "update your version" payload.

New FlightsTests target with AirlineLoadIntegrationTests — hits live
airline APIs to verify each fetcher still returns data. Per-airline
strategy:
- Try route-explorer /departures from carrier hubs for a flight in the
  next 24h (works for AA/UA/AS/B6).
- Fall back to a known-good daily flight when route-explorer doesn't
  have the carrier in its data (NK/EK/KE — ULCC + some intl carriers).
- B6/EK/NK are status-only by design (no standby data without a PNR);
  asserted as non-nil only.
- XE (JSX) skipped: needs WKWebView host.

Retries on route-explorer 429 by parsing the `retryAfter` field and
sleeping the indicated number of seconds. Static-shared client+services
across tests so the token cache survives.

Results 2026-05-26 (xcodebuild test -scheme Flights):
   AA, AS, B6, EK, KE, UA   NK  ⏭️ XE
NK (Spirit) is now broken: GetFlightInfoBI returns HTTP 403 with
{"getFlightInfoBIResult":null}. APIM key still accepted (401 without
it), but the call itself is rejected. Documented in
AIRLINE_INTEGRATION_GUIDE.md as a known regression to fix; likely
needs reverse-engineering against the current Spirit APK in airlines/.

Also: enable shared schemes in .gitignore so `xcodebuild test` works
out of the box for anyone cloning the repo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 14:14:09 -05:00
Trey t 0c4777216e Make RoutePlannerView the home; merge "Where can I go?" into it
Single unified search at the app root. TO is optional: filled goes through
/route (connections); blank flips to /departures with a time-window picker
("Where can I go?"). Same per-leg load card detail screen for any tap, so
direct flights and multi-stop connections share the same UX.

- Drop ContentView entirely (favorites + browse + entry cards). FlightsApp
  instantiates RoutePlannerView directly.
- Delete WhereToGoView; DepartureLegRow is inlined into RoutePlannerView
  as the where-can-I-go result row.
- SearchRoute enum trimmed to just the cases DestinationsListView still
  references and moved to its own file (Models/SearchRoute.swift).

Sort bar moved out of the controls cards into a dedicated row between the
Search button and the results — only visible once results exist. Switched
from segmented to dropdown menu picker. Options narrowed to the four
the user asked for: Departure Earliest / Departure Latest / Fewest Stops
/ Most Stops in connection mode, just the two time-based options for
where-can-I-go (single-leg, stop-count is meaningless). All sorts apply
client-side; upstream still gets `departure_time` for a stable base order.

Two real bugs fixed in connection search:
- Past flights weren't filtered. Same-day searches return mostly already-
  departed itineraries because the API sorts earliest-first. Added a
  `firstDeparture > now` filter applied before sort. Header surfaces the
  dropped count ("12 itineraries · 38 already departed"). When every
  result is past, the error message says so explicitly instead of going
  blank.
- 100-result limit was way too low for hub→hub with maxStops:2 — the
  combinatorial explosion of valid permutations filled the cap with
  morning flights and never reached afternoon. Bumped to 500.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 11:50:02 -05:00
Trey t df4a74726c Route Explorer: unified per-leg load card + multi-leg fan-out
Single ConnectionLoadDetailView is now the universal detail screen for
both Find Connections (1+ legs) and Where Can I Go (single-leg). For
multi-stop connections it fetches each leg's load in parallel via
withTaskGroup so the slowest carrier doesn't block the rest. Each leg
card shows airline + flight + IATAs + airport names + aircraft + an
open/standby summary, with a "Full details" drill-down to
FlightLoadDetailView for waitlists/passenger lists.

Bug fixes along the way:
- Empty origin/destination in carrier API URLs (HTTP 400 from AA): the
  4 separate @State vars feeding .sheet(item:) raced — sheet captured
  empty strings before the other writes settled. Bundled into one
  Identifiable RouteLoadDetailRequest / ConnectionLoadRequest so updates
  are atomic.
- Flight numbers rendered with locale separators ("AA 6,380", "3,189").
  Text("\(int)") resolves to the LocalizedStringKey initializer; switched
  to Text(verbatim:).
- "Load data not available for {airline}" was misleading when the
  airline IS supported but a specific flight has no data. Reworded to
  flight-scoped copy.
- AA fetcher had no logging — added URL/status/body/keys diagnostics
  matching the UA pattern.

UI cleanup:
- DepartureLegRow: big IATAs on their own row, full airport names on a
  middle-truncated subtitle, aircraft pill single-line tail-truncated.
- LegSummary (ConnectionRow): airport-name subtitle line below
  times+IATAs row.
- airportName priority: bundled airports.json first ("Dallas-Fort
  Worth") over the route-explorer appendix ("Dallas Dallas/Fort Worth
  Intl") which truncated to garbage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 11:19:20 -05:00
124 changed files with 103554 additions and 1096 deletions
+8 -1
View File
@@ -15,8 +15,9 @@ xcuserdata/
*.perspectivev3
!default.perspectivev3
# Xcode Scheme
# Xcode Scheme — keep shared schemes (so `xcodebuild test` works for everyone)
*.xcscheme
!*.xcodeproj/xcshareddata/xcschemes/*.xcscheme
# Swift Package Manager
.build/
@@ -43,3 +44,9 @@ airlines/
# Claude
.claude/
# Playwright MCP scratch captures
.playwright-mcp/
# BTS bulk-download cache (regenerated by scripts/generate_bts_bundle.py)
.bts_cache/
+150 -15
View File
@@ -1,6 +1,21 @@
# Airline API Integration Guide
Drop-in reference for integrating flight load / seat availability data from 11 airlines. Each section tells you **what works today, how to call it, what you get back, and what's blocked**. Verified 2026-04-12.
Drop-in reference for integrating flight load / seat availability data from 11 airlines. Each section tells you **what works today, how to call it, what you get back, and what's blocked**. Verified 2026-04-12, with regression-test runs 2026-05-26.
## Quick status (run `xcodebuild test -scheme Flights` to re-verify)
| Carrier | Status | Notes |
|---|---|---|
| AA | ✅ Working | UA version gate — bump `aaAppVersion` in `AirlineLoadService.swift` when AA rejects with "Please update your version" |
| UA | ✅ Working | Anonymous token, 30min TTL |
| AS | ✅ Working | Static APIM key |
| B6 | ✅ Status-only | Confirms flight exists; no load data without check-in session |
| EK | ✅ Status-only | Confirms flight exists; load data requires PNR |
| KE | ✅ Working | Returns seat count only (no capacity) |
| AM | ✅ Working | Public AWS gateway Sabre proxy. Returns per-cabin `authorized`+`available` + full standby/upgrade passenger lists with `isStaff` flag and priority. Snapshot window: T-1d to T+2d. |
| SY | ✅ Working | Navitaire availability search returns **`capacity` + `sold` per flight** (true load factor, better than AA). Imperva WAF gated on browser-shaped headers. No standby list (SY is single-class). |
| ~~NK~~ | Removed | Spirit Airlines ceased operations (merged into Frontier). Removed from `AirlineLoadService` and tests. |
| XE | Manual only | WKWebView path; unit tests can't exercise it |
---
@@ -195,23 +210,145 @@ Script ready at `scripts/jsx_availability.js`.
---
## 5. Spirit Airlines — PARTIAL (status only, no standby)
## 5. ~~Spirit Airlines~~DEFUNCT
**What you get:** Flight status, station/route data. **No standby — Spirit is a ULCC and doesn't run standby lists.**
Spirit ceased operations and merged into Frontier. Removed from the codebase entirely. Section retained as a placeholder so the numbering below doesn't shift.
**Auth:** Static APIM key (decrypted). Plain curl for GETs; POSTs mostly blocked by Akamai CyberFend sensor.
---
**Key:** `c6567af50d544dfbb3bc5dd99c6bb177`
## 5b. Aeromexico — WORKING (richer than AA in some ways)
```bash
curl -X POST "https://api.spirit.com/customermobileprod/2.8.0/v3/GetFlightInfoBI" \
-H "Ocp-Apim-Subscription-Key: c6567af50d544dfbb3bc5dd99c6bb177" \
-H "Content-Type: application/json" \
-H "Platform: Android" \
-d '{"departureStation":"FLL","arrivalStation":"ATL","departureDate":"2026-04-08"}'
**What you get:** per-cabin `authorized` (capacity) + `available` (open seats), full standby + upgrade passenger lists with `isStaff` flag, numeric priority, fare class, booking class, ascendsToClass, original/new position, check-in / board status, PII (firstName, lastName, reservationCode/PNR).
**Auth:** None. Public AWS API Gateway. Headers required: `channel: web`, `flow: CHECKIN`, `x-transaction-id: <uuid>`. Values extracted from `com.aeromexico.aeromexico.amwidgets.utils.Constant` in the APK.
**Flow:**
```
GET https://lw18yj0mhb.execute-api.us-east-1.amazonaws.com/rb/passengerliststandby
?departureAirport=<IATA>
&code=<4-digit, zero-padded>
&departureDate=<YYYY-MM-DD>
&operatingCarrier=AM
&operatingFlightCode=<4-digit, zero-padded>
GET https://lw18yj0mhb.execute-api.us-east-1.amazonaws.com/rb/passengerlistupgrade
?<same params>
```
Seat maps require JWT + CyberFend sensor data (real device + Frida hook only).
`operatingFlightCode` is validated against `^[0-9]{4}$` — zero-pad short flight numbers.
**Response shape:**
```json
{
"itineraryInfo": {"airline":"AM","flight":"0058","origin":"MEX","destination":"MTY","aircraftType":"789"},
"cabinInfoList": [{"cabin":"Y","authorized":238,"available":0}],
"totalListed": 1,
"passengers": [{
"isStaff": true,
"rawPriorityCode": "SAE",
"priorityCode": {"id":"SAE","priority":21},
"status": "STB",
"bookingClass": "H",
"ascendsToClass": "Y",
"firstName": "RAMSITO",
"lastName": "UNO",
"reservationCode": "OBLWDT",
"passengerId": "0A6612610001",
"seat": null,
"originalPosition": 2,
"newPosition": 1,
"checkInStatus": false,
"boardStatus": false,
"boardingPassFlag": false
}]
}
```
**Snapshot window** (empirical, AM0058 MEX-MTY):
- T-3 days and earlier → `NONE LISTED` (data purged)
- **T-1 day → T+0** → snapshot live, `passengers[]` populates when listed
- T+1, T+2 → `NONE LISTED` (flight known but no snapshot)
- T+3 and beyond → `FLIGHT NOT INITIALIZED`
**Failure modes** to watch for in the response body:
- `NONE LISTED` → params valid, no passengers / no snapshot yet
- `FLIGHT NOT INITIALIZED - INVALID DATE OR CITY` → flight number doesn't match a real AM operation on that date+airport, OR snapshot window not open
- The `code` query param is ignored — only `operatingCarrier` + `operatingFlightCode` + `departureAirport` + `departureDate` are discriminating
**Cabin codes:** `Y` = Economy, `C` = Clase Premier (business), `P` = Premier One (long-haul biz/first), `F` = First. Mapped in `aeromexicoCabinName(code:)`.
**AM Connect / regional flights** (e.g. AM1460 MEX-QRO) often return `FLIGHT NOT INITIALIZED` — they're not in AM's Sabre system. The integration falls back to a known-daily mainline flight (AM0058 MEX-MTY) when route-explorer surfaces a regional that the load endpoint doesn't recognise.
---
## 5c. Sun Country — WORKING (true load factor)
**What you get:** Per-flight `capacity`, **`sold` (booked passenger count)**, equipment type, and per-fare-class `availableCount`. Direct load factor calculation (sold/capacity). No standby list — SY is single-cabin Y, no upgrade program.
**Auth:** Azure APIM subscription key + a long-lived dotREZ JWT. Both static, both extracted from suncountry.com network traffic. No user session or login required.
**Anti-bot:** Imperva WAF in front of `syprod-api.suncountry.com`. Gated on `User-Agent` + `Referer: https://www.suncountry.com/` + `Origin: https://www.suncountry.com` headers. Bare curl returns 403 with an Incapsula page; full browser-shaped headers pass cleanly. No WebView needed.
**Flow:**
```
POST https://syprod-api.suncountry.com/api/nsk/v4/availability/search/simple
Headers:
Ocp-Apim-Subscription-Key: bc7f707786c44a56859c396102f6cd21
Authorization: <dotREZ JWT — eyJhbGc...>
User-Agent: Mozilla/5.0 (Macintosh; ...) Chrome/145 Safari/537.36
Referer: https://www.suncountry.com/
Origin: https://www.suncountry.com
Content-Type: application/json
Body:
{
"Origin": "MSP",
"Destination": "LAX",
"BeginDate": "2026-06-15",
"EndDate": "2026-06-15",
"Passengers": { "Types": [{"Type":"ADT","Count":1}] },
"Currency": "USD"
}
```
**Response shape (truncated to the load-relevant bits):**
```json
{
"data": {
"results": [{ "trips": [{
"journeysAvailableByMarket": {
"MSP|LAX": [{
"designator": {"origin":"MSP","destination":"LAX","departure":"...","arrival":"..."},
"segments": [{
"identifier": {"identifier":"421","carrierCode":"SY"},
"legs": [{
"legInfo": {
"capacity": 186, // total seats
"adjustedCapacity": 186,
"lid": 186,
"sold": 106, // booked passenger count
"equipmentType": "78T",
"departureTimeUtc": "...",
"arrivalTimeUtc": "..."
}
}]
}],
"fares": [{ "details": [{ "availableCount": 4 }] }]
}]
}
}]}]
}
}
```
**Why this is better than AA:** AA returns "seatsAvailable" per cabin without telling you capacity. SY gives both, so load factor = sold/capacity is exact (~57% above for SY421 MSP-LAX).
**Failure modes:**
- HTTP 403 with Incapsula HTML → User-Agent / Referer / Origin headers dropped
- HTTP 200 with empty `journeysAvailableByMarket` → flight already departed (Navitaire only returns future flights) or no SY service on that route/date
- HTTP 401 → APIM key or JWT no longer valid; re-capture from www.suncountry.com network traffic
**Re-capturing tokens:** Open suncountry.com in a browser DevTools network tab, find the `PUT /api/nsk/v1/token` request, copy the `Ocp-Apim-Subscription-Key` and `Authorization` header values. Update `sunCountryAPIMKey` and `sunCountryJWT` constants in `AirlineLoadService.swift`.
---
@@ -343,7 +480,6 @@ Same backend powers Lufthansa, SWISS, Austrian, Brussels.
|------------|--------------------|---------|
| Alaska | APIM key header | Lowest (curl works) |
| Emirates | none | Lowest (curl works) |
| Spirit | APIM key (GET only)| Low (curl works) |
| JetBlue | apikey header | Low (curl works) |
| Korean Air | `channel` header | Low (Playwright or curl) |
| JSX | Playwright → JWT | Medium |
@@ -369,7 +505,6 @@ These four are the core of any flight-load product. Alaska is the easiest to int
## Tier 2: Status only (useful, but no seat data)
- **Spirit** — status/routes, no standby (ULCC)
- **Emirates** — status, zero auth
- **Korean Air** — status; `flightSeatCount` returns 0 far out
- **JetBlue** — status + route DB; loads need PNR
@@ -386,7 +521,7 @@ These four are the core of any flight-load product. Alaska is the easiest to int
3. Layer in American for the third major US carrier.
4. JSX as a bonus — only route pairs that JSX serves (private terminals).
5. For Delta/JetBlue: show flight status only, note "seat data unavailable" unless you have a PNR.
6. Use Emirates/Korean Air/Spirit for status on international/ULCC routes.
6. Use Emirates/Korean Air for status on international routes.
## Shared integration notes
+452 -22
View File
@@ -14,7 +14,6 @@
303821C9668A44F38FFA02CA /* AirportSearchField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD303E3EDCC4BF2BCF31722 /* AirportSearchField.swift */; };
35D016EBA93C40BB873AB304 /* Airline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04AC23D8748D42C9A7115FAC /* Airline.swift */; };
4C770C55CB3643BAB7B9D622 /* AirportBrowserSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15676B4BD35745D1BD1DC947 /* AirportBrowserSheet.swift */; };
57A463AB3CFD44DC93444E59 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9934B0FCA757403A94AB963C /* ContentView.swift */; };
61F8E3DD7D434DA7854C20E2 /* FlightsApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6D822B4ABF741F890A4400C /* FlightsApp.swift */; };
6558A31ADEC740FC8C56EA22 /* FlightSchedule.swift in Sources */ = {isa = PBXBuildFile; fileRef = B913D04A4E51436595308A21 /* FlightSchedule.swift */; };
7FFD9A2D25F9421D8929C027 /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36862683C4F44A95AFE234EB /* SearchViewModel.swift */; };
@@ -41,12 +40,103 @@
BB2200002222000022220001 /* JSXWebViewFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB2200002222000022220002 /* JSXWebViewFetcher.swift */; };
RE1100001111000011110001 /* RouteExplorerModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = RE1100001111000011110002 /* RouteExplorerModels.swift */; };
RE2200002222000022220001 /* RouteExplorerClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = RE2200002222000022220002 /* RouteExplorerClient.swift */; };
FA9911119911119911119911 /* FlightAwareScheduleClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA9911119911119911119922 /* FlightAwareScheduleClient.swift */; };
BL0011110011110011110011 /* BlobRouteClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = BL0011110011110011110022 /* BlobRouteClient.swift */; };
TS0011110011110011110011 /* TurnstileDebugView.swift in Sources */ = {isa = PBXBuildFile; fileRef = TS0011110011110011110022 /* TurnstileDebugView.swift */; };
DL0011110011110011110011 /* DiagnosticLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = DL0011110011110011110022 /* DiagnosticLogger.swift */; };
LD0011110011110011110011 /* LoggingURLSessionDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = LD0011110011110011110022 /* LoggingURLSessionDelegate.swift */; };
DV0011110011110011110011 /* DiagnosticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DV0011110011110011110022 /* DiagnosticsView.swift */; };
RT0011110011110011110011 /* RouteExplorerTokenStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = RT0011110011110011110022 /* RouteExplorerTokenStore.swift */; };
RS0011110011110011110011 /* RouteExplorerSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = RS0011110011110011110022 /* RouteExplorerSetupView.swift */; };
RB0011110011110011110011 /* RouteExplorerBrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = RB0011110011110011110022 /* RouteExplorerBrowserView.swift */; };
RE3300003333000033330001 /* RoutePlannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = RE3300003333000033330002 /* RoutePlannerView.swift */; };
RE4400004444000044440001 /* WhereToGoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = RE4400004444000044440002 /* WhereToGoView.swift */; };
REGT00000000000000000001 /* RouteExplorerGateSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = REGT00000000000000000002 /* RouteExplorerGateSheet.swift */; };
RE5500005555000055550001 /* IATAAirportPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = RE5500005555000055550002 /* IATAAirportPicker.swift */; };
RE6600006666000066660001 /* ConnectionRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = RE6600006666000066660002 /* ConnectionRow.swift */; };
RE7700007777000077770001 /* ConnectionLoadDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = RE7700007777000077770002 /* ConnectionLoadDetailView.swift */; };
RE8800008888000088880001 /* SearchRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = RE8800008888000088880002 /* SearchRoute.swift */; };
T1000000000000000000001A /* AirlineLoadIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = T1000000000000000000001B /* AirlineLoadIntegrationTests.swift */; };
FATEST00FATEST00FATEST01 /* FlightAwareScheduleClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FATEST00FATEST00FATEST02 /* FlightAwareScheduleClientTests.swift */; };
LV1100001111000011110001 /* LiveAircraft.swift in Sources */ = {isa = PBXBuildFile; fileRef = LV1100001111000011110002 /* LiveAircraft.swift */; };
LV2200002222000022220001 /* OpenSkyClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = LV2200002222000022220002 /* OpenSkyClient.swift */; };
LV3300003333000033330001 /* AircraftRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = LV3300003333000033330002 /* AircraftRegistry.swift */; };
LV4400004444000044440001 /* LiveFlightsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = LV4400004444000044440002 /* LiveFlightsView.swift */; };
LV5500005555000055550001 /* LiveFlightDetailSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = LV5500005555000055550002 /* LiveFlightDetailSheet.swift */; };
LV6600006666000066660001 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = LV6600006666000066660002 /* RootView.swift */; };
LV7700007777000077770001 /* OpenSkyCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = LV7700007777000077770002 /* OpenSkyCredentials.swift */; };
LV8800008888000088880001 /* OpenSkySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = LV8800008888000088880002 /* OpenSkySettingsView.swift */; };
LV9900009999000099990001 /* airlines.json in Resources */ = {isa = PBXBuildFile; fileRef = LV9900009999000099990002 /* airlines.json */; };
LVAA000AAAA000AAAA000001 /* AircraftDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = LVAA000AAAA000AAAA000002 /* AircraftDatabase.swift */; };
LVBB000BBBB000BBBB000001 /* LiveFilterPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = LVBB000BBBB000BBBB000002 /* LiveFilterPicker.swift */; };
LVCC000CCCC000CCCC000001 /* aircraftDB.json in Resources */ = {isa = PBXBuildFile; fileRef = LVCC000CCCC000CCCC000002 /* aircraftDB.json */; };
LVDD000DDDD000DDDD000001 /* LocationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = LVDD000DDDD000DDDD000002 /* LocationService.swift */; };
LVEE000EEEE000EEEE000001 /* FR24Client.swift in Sources */ = {isa = PBXBuildFile; fileRef = LVEE000EEEE000EEEE000002 /* FR24Client.swift */; };
LVFF000FFFF000FFFF000001 /* AircraftPhotoService.swift in Sources */ = {isa = PBXBuildFile; fileRef = LVFF000FFFF000FFFF000002 /* AircraftPhotoService.swift */; };
HX0100001111000011110001 /* LoggedFlight.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX0100001111000011110002 /* LoggedFlight.swift */; };
HX0200002222000022220001 /* AirframeMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX0200002222000022220002 /* AirframeMetadata.swift */; };
HX0300003333000033330001 /* FlightHistoryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX0300003333000033330002 /* FlightHistoryStore.swift */; };
HX0500005555000055550001 /* StatsEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX0500005555000055550002 /* StatsEngine.swift */; };
HX0600006666000066660001 /* CalendarFlightImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX0600006666000066660002 /* CalendarFlightImporter.swift */; };
HX0700007777000077770001 /* WalletPassObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX0700007777000077770002 /* WalletPassObserver.swift */; };
HX0800008888000088880001 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX0800008888000088880002 /* HistoryView.swift */; };
HX0900009999000099990001 /* HistoryRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX0900009999000099990002 /* HistoryRowView.swift */; };
HX0A000AAAA000AAAA000001 /* HistoryDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX0A000AAAA000AAAA000002 /* HistoryDetailView.swift */; };
HX0B000BBBB000BBBB000001 /* AddFlightView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX0B000BBBB000BBBB000002 /* AddFlightView.swift */; };
HX0C000CCCC000CCCC000001 /* CalendarImportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX0C000CCCC000CCCC000002 /* CalendarImportView.swift */; };
HX0D000DDDD000DDDD000001 /* LifetimeStatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX0D000DDDD000DDDD000002 /* LifetimeStatsView.swift */; };
HX0E000EEEE000EEEE000001 /* HistoryRouteMapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX0E000EEEE000EEEE000002 /* HistoryRouteMapView.swift */; };
HX0F000FFFF000FFFF000001 /* YearInReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX0F000FFFF000FFFF000002 /* YearInReviewView.swift */; };
HX1000001000000010000001 /* AirframeMetadataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1000001000000010000002 /* AirframeMetadataService.swift */; };
HX1100001100000011000001 /* CSVFlightImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1100001100000011000002 /* CSVFlightImporter.swift */; };
HX1200001200000012000001 /* ImportCSVView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1200001200000012000002 /* ImportCSVView.swift */; };
HX1300001300000013000001 /* HistoryFilters.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1300001300000013000002 /* HistoryFilters.swift */; };
HX1400001400000014000001 /* HistoryFilterSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1400001400000014000002 /* HistoryFilterSheet.swift */; };
HX1500001500000015000001 /* AirportFlightsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1500001500000015000002 /* AirportFlightsView.swift */; };
HX1600001600000016000001 /* HistoryStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1600001600000016000002 /* HistoryStyle.swift */; };
HX1700001700000017000001 /* PassportComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1700001700000017000002 /* PassportComponents.swift */; };
HX1800001800000018000001 /* PassportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1800001800000018000002 /* PassportView.swift */; };
HX1900001900000019000001 /* AircraftStatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1900001900000019000002 /* AircraftStatsView.swift */; };
HX2000002000000020000001 /* EnrichAircraftTypesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX2000002000000020000002 /* EnrichAircraftTypesView.swift */; };
HX2100002100000021000001 /* FlightAwareLookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX2100002100000021000002 /* FlightAwareLookup.swift */; };
NF0100000000000000000001 /* AircraftRotationTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = NF0100000000000000000002 /* AircraftRotationTracker.swift */; };
NF0200000000000000000001 /* AirframeHistoryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = NF0200000000000000000002 /* AirframeHistoryStore.swift */; };
NF0300000000000000000001 /* BTSDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = NF0300000000000000000002 /* BTSDataStore.swift */; };
NF0500000000000000000001 /* DelayCascadePredictor.swift in Sources */ = {isa = PBXBuildFile; fileRef = NF0500000000000000000002 /* DelayCascadePredictor.swift */; };
NF0600000000000000000001 /* EquipmentSwapService.swift in Sources */ = {isa = PBXBuildFile; fileRef = NF0600000000000000000002 /* EquipmentSwapService.swift */; };
NF0700000000000000000001 /* HubLoadHeatmapService.swift in Sources */ = {isa = PBXBuildFile; fileRef = NF0700000000000000000002 /* HubLoadHeatmapService.swift */; };
NF0900000000000000000001 /* LoadFactorService.swift in Sources */ = {isa = PBXBuildFile; fileRef = NF0900000000000000000002 /* LoadFactorService.swift */; };
NF1000000000000000000001 /* OnTimePerformanceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = NF1000000000000000000002 /* OnTimePerformanceService.swift */; };
NF1100000000000000000001 /* SisterFlightService.swift in Sources */ = {isa = PBXBuildFile; fileRef = NF1100000000000000000002 /* SisterFlightService.swift */; };
NF1200000000000000000001 /* StandbyStatsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = NF1200000000000000000002 /* StandbyStatsService.swift */; };
NF1400000000000000000001 /* WeatherClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = NF1400000000000000000002 /* WeatherClient.swift */; };
NHB00000000000000000001 /* HubLoadsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = NHB00000000000000000002 /* HubLoadsView.swift */; };
NSV00000000000000000001 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = NSV00000000000000000002 /* SettingsView.swift */; };
NR0100000000000000000001 /* aircraft_seats.json in Resources */ = {isa = PBXBuildFile; fileRef = NR0100000000000000000002 /* aircraft_seats.json */; };
NR0200000000000000000001 /* bts_bundle.json in Resources */ = {isa = PBXBuildFile; fileRef = NR0200000000000000000002 /* bts_bundle.json */; };
NF1600000000000000000001 /* DataIntegrityMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = NF1600000000000000000002 /* DataIntegrityMonitor.swift */; };
NR0700000000000000000001 /* bts_bundle_meta.json in Resources */ = {isa = PBXBuildFile; fileRef = NR0700000000000000000002 /* bts_bundle_meta.json */; };
TN0100000000000000000001 /* AirframeHistoryStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TN0100000000000000000002 /* AirframeHistoryStoreTests.swift */; };
TN0300000000000000000001 /* DataIntegrityMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TN0300000000000000000002 /* DataIntegrityMonitorTests.swift */; };
TN0400000000000000000001 /* DelayCascadePredictorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TN0400000000000000000002 /* DelayCascadePredictorTests.swift */; };
TN0500000000000000000001 /* EquipmentSwapServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TN0500000000000000000002 /* EquipmentSwapServiceTests.swift */; };
TN0600000000000000000001 /* HistoryFlightModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TN0600000000000000000002 /* HistoryFlightModelTests.swift */; };
TN0800000000000000000001 /* LoadFactorServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TN0800000000000000000002 /* LoadFactorServiceTests.swift */; };
TN0900000000000000000001 /* SelftestRemovalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TN0900000000000000000002 /* SelftestRemovalTests.swift */; };
TN1000000000000000000001 /* SisterFlightServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TN1000000000000000000002 /* SisterFlightServiceTests.swift */; };
TN1100000000000000000001 /* StandbyStatsServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TN1100000000000000000002 /* StandbyStatsServiceTests.swift */; };
TN1300000000000000000001 /* WeatherClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TN1300000000000000000002 /* WeatherClientTests.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
T1000000000000000000002A /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 5418BEEAEFF644ADA7240CEA /* Project object */;
proxyType = 1;
remoteGlobalIDString = E373C48C497D48D388BF7657;
remoteInfo = Flights;
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
04AC23D8748D42C9A7115FAC /* Airline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Airline.swift; sourceTree = "<group>"; };
0CD303E3EDCC4BF2BCF31722 /* AirportSearchField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirportSearchField.swift; sourceTree = "<group>"; };
@@ -61,7 +151,6 @@
7C2EB471F011450DA7BBEFAD /* AirportMapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirportMapView.swift; sourceTree = "<group>"; };
85EC89DEE12942B49DF51984 /* Airport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Airport.swift; sourceTree = "<group>"; };
8A3CB0CCC2524542AFB0D1D2 /* Flights.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Flights.app; sourceTree = BUILT_PRODUCTS_DIR; };
9934B0FCA757403A94AB963C /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
9A58C339D6084657B0538E9C /* AirportDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirportDatabase.swift; sourceTree = "<group>"; };
9BEAC0EBABFD41569FE69B1B /* DestinationsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DestinationsViewModel.swift; sourceTree = "<group>"; };
A65682BD902141BAA686D101 /* FlightService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlightService.swift; sourceTree = "<group>"; };
@@ -83,10 +172,93 @@
BB2200002222000022220002 /* JSXWebViewFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSXWebViewFetcher.swift; sourceTree = "<group>"; };
RE1100001111000011110002 /* RouteExplorerModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteExplorerModels.swift; sourceTree = "<group>"; };
RE2200002222000022220002 /* RouteExplorerClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteExplorerClient.swift; sourceTree = "<group>"; };
FA9911119911119911119922 /* FlightAwareScheduleClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlightAwareScheduleClient.swift; sourceTree = "<group>"; };
BL0011110011110011110022 /* BlobRouteClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlobRouteClient.swift; sourceTree = "<group>"; };
DL0011110011110011110022 /* DiagnosticLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticLogger.swift; sourceTree = "<group>"; };
LD0011110011110011110022 /* LoggingURLSessionDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggingURLSessionDelegate.swift; sourceTree = "<group>"; };
DV0011110011110011110022 /* DiagnosticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticsView.swift; sourceTree = "<group>"; };
RT0011110011110011110022 /* RouteExplorerTokenStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteExplorerTokenStore.swift; sourceTree = "<group>"; };
RS0011110011110011110022 /* RouteExplorerSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteExplorerSetupView.swift; sourceTree = "<group>"; };
RB0011110011110011110022 /* RouteExplorerBrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteExplorerBrowserView.swift; sourceTree = "<group>"; };
RE3300003333000033330002 /* RoutePlannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoutePlannerView.swift; sourceTree = "<group>"; };
RE4400004444000044440002 /* WhereToGoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhereToGoView.swift; sourceTree = "<group>"; };
REGT00000000000000000002 /* RouteExplorerGateSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteExplorerGateSheet.swift; sourceTree = "<group>"; };
RE5500005555000055550002 /* IATAAirportPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IATAAirportPicker.swift; sourceTree = "<group>"; };
RE6600006666000066660002 /* ConnectionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionRow.swift; sourceTree = "<group>"; };
RE7700007777000077770002 /* ConnectionLoadDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionLoadDetailView.swift; sourceTree = "<group>"; };
RE8800008888000088880002 /* SearchRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRoute.swift; sourceTree = "<group>"; };
T1000000000000000000001B /* AirlineLoadIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirlineLoadIntegrationTests.swift; sourceTree = "<group>"; };
FATEST00FATEST00FATEST02 /* FlightAwareScheduleClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlightAwareScheduleClientTests.swift; sourceTree = "<group>"; };
T1000000000000000000003A /* FlightsTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FlightsTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
LV1100001111000011110002 /* LiveAircraft.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveAircraft.swift; sourceTree = "<group>"; };
LV2200002222000022220002 /* OpenSkyClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenSkyClient.swift; sourceTree = "<group>"; };
LV3300003333000033330002 /* AircraftRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AircraftRegistry.swift; sourceTree = "<group>"; };
LV4400004444000044440002 /* LiveFlightsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveFlightsView.swift; sourceTree = "<group>"; };
LV5500005555000055550002 /* LiveFlightDetailSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveFlightDetailSheet.swift; sourceTree = "<group>"; };
LV6600006666000066660002 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
LV7700007777000077770002 /* OpenSkyCredentials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenSkyCredentials.swift; sourceTree = "<group>"; };
LV8800008888000088880002 /* OpenSkySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenSkySettingsView.swift; sourceTree = "<group>"; };
LV9900009999000099990002 /* airlines.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = airlines.json; sourceTree = "<group>"; };
LVAA000AAAA000AAAA000002 /* AircraftDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AircraftDatabase.swift; sourceTree = "<group>"; };
LVBB000BBBB000BBBB000002 /* LiveFilterPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveFilterPicker.swift; sourceTree = "<group>"; };
LVCC000CCCC000CCCC000002 /* aircraftDB.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = aircraftDB.json; sourceTree = "<group>"; };
LVDD000DDDD000DDDD000002 /* LocationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationService.swift; sourceTree = "<group>"; };
LVEE000EEEE000EEEE000002 /* FR24Client.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FR24Client.swift; sourceTree = "<group>"; };
LVFF000FFFF000FFFF000002 /* AircraftPhotoService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AircraftPhotoService.swift; sourceTree = "<group>"; };
HX0100001111000011110002 /* LoggedFlight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggedFlight.swift; sourceTree = "<group>"; };
HX0200002222000022220002 /* AirframeMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirframeMetadata.swift; sourceTree = "<group>"; };
HX0300003333000033330002 /* FlightHistoryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlightHistoryStore.swift; sourceTree = "<group>"; };
HX0400004444000044440002 /* Flights.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Flights.entitlements; sourceTree = "<group>"; };
HX0500005555000055550002 /* StatsEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsEngine.swift; sourceTree = "<group>"; };
HX0600006666000066660002 /* CalendarFlightImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarFlightImporter.swift; sourceTree = "<group>"; };
HX0700007777000077770002 /* WalletPassObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletPassObserver.swift; sourceTree = "<group>"; };
HX0800008888000088880002 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; };
HX0900009999000099990002 /* HistoryRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryRowView.swift; sourceTree = "<group>"; };
HX0A000AAAA000AAAA000002 /* HistoryDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryDetailView.swift; sourceTree = "<group>"; };
HX0B000BBBB000BBBB000002 /* AddFlightView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddFlightView.swift; sourceTree = "<group>"; };
HX0C000CCCC000CCCC000002 /* CalendarImportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarImportView.swift; sourceTree = "<group>"; };
HX0D000DDDD000DDDD000002 /* LifetimeStatsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LifetimeStatsView.swift; sourceTree = "<group>"; };
HX0E000EEEE000EEEE000002 /* HistoryRouteMapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryRouteMapView.swift; sourceTree = "<group>"; };
HX0F000FFFF000FFFF000002 /* YearInReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YearInReviewView.swift; sourceTree = "<group>"; };
HX1000001000000010000002 /* AirframeMetadataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirframeMetadataService.swift; sourceTree = "<group>"; };
HX1100001100000011000002 /* CSVFlightImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CSVFlightImporter.swift; sourceTree = "<group>"; };
HX1200001200000012000002 /* ImportCSVView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportCSVView.swift; sourceTree = "<group>"; };
HX1300001300000013000002 /* HistoryFilters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryFilters.swift; sourceTree = "<group>"; };
HX1400001400000014000002 /* HistoryFilterSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryFilterSheet.swift; sourceTree = "<group>"; };
HX1500001500000015000002 /* AirportFlightsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirportFlightsView.swift; sourceTree = "<group>"; };
HX1600001600000016000002 /* HistoryStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryStyle.swift; sourceTree = "<group>"; };
HX1700001700000017000002 /* PassportComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PassportComponents.swift; sourceTree = "<group>"; };
HX1800001800000018000002 /* PassportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PassportView.swift; sourceTree = "<group>"; };
HX1900001900000019000002 /* AircraftStatsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AircraftStatsView.swift; sourceTree = "<group>"; };
HX2000002000000020000002 /* EnrichAircraftTypesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnrichAircraftTypesView.swift; sourceTree = "<group>"; };
HX2100002100000021000002 /* FlightAwareLookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlightAwareLookup.swift; sourceTree = "<group>"; };
NF0100000000000000000002 /* AircraftRotationTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AircraftRotationTracker.swift; sourceTree = "<group>"; };
NF0200000000000000000002 /* AirframeHistoryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirframeHistoryStore.swift; sourceTree = "<group>"; };
NF0300000000000000000002 /* BTSDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTSDataStore.swift; sourceTree = "<group>"; };
NF0500000000000000000002 /* DelayCascadePredictor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DelayCascadePredictor.swift; sourceTree = "<group>"; };
NF0600000000000000000002 /* EquipmentSwapService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EquipmentSwapService.swift; sourceTree = "<group>"; };
NF0700000000000000000002 /* HubLoadHeatmapService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HubLoadHeatmapService.swift; sourceTree = "<group>"; };
NF0900000000000000000002 /* LoadFactorService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadFactorService.swift; sourceTree = "<group>"; };
NF1000000000000000000002 /* OnTimePerformanceService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnTimePerformanceService.swift; sourceTree = "<group>"; };
NF1100000000000000000002 /* SisterFlightService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SisterFlightService.swift; sourceTree = "<group>"; };
NF1200000000000000000002 /* StandbyStatsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StandbyStatsService.swift; sourceTree = "<group>"; };
NF1400000000000000000002 /* WeatherClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeatherClient.swift; sourceTree = "<group>"; };
NHB00000000000000000002 /* HubLoadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HubLoadsView.swift; sourceTree = "<group>"; };
TS0011110011110011110022 /* TurnstileDebugView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TurnstileDebugView.swift; sourceTree = "<group>"; };
NSV00000000000000000002 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
NR0100000000000000000002 /* aircraft_seats.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = aircraft_seats.json; sourceTree = "<group>"; };
NR0200000000000000000002 /* bts_bundle.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = bts_bundle.json; sourceTree = "<group>"; };
NF1600000000000000000002 /* DataIntegrityMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataIntegrityMonitor.swift; sourceTree = "<group>"; };
NR0700000000000000000002 /* bts_bundle_meta.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = bts_bundle_meta.json; sourceTree = "<group>"; };
TN0100000000000000000002 /* AirframeHistoryStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirframeHistoryStoreTests.swift; sourceTree = "<group>"; };
TN0300000000000000000002 /* DataIntegrityMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataIntegrityMonitorTests.swift; sourceTree = "<group>"; };
TN0400000000000000000002 /* DelayCascadePredictorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DelayCascadePredictorTests.swift; sourceTree = "<group>"; };
TN0500000000000000000002 /* EquipmentSwapServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EquipmentSwapServiceTests.swift; sourceTree = "<group>"; };
TN0600000000000000000002 /* HistoryFlightModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryFlightModelTests.swift; sourceTree = "<group>"; };
TN0800000000000000000002 /* LoadFactorServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadFactorServiceTests.swift; sourceTree = "<group>"; };
TN0900000000000000000002 /* SelftestRemovalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelftestRemovalTests.swift; sourceTree = "<group>"; };
TN1000000000000000000002 /* SisterFlightServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SisterFlightServiceTests.swift; sourceTree = "<group>"; };
TN1100000000000000000002 /* StandbyStatsServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StandbyStatsServiceTests.swift; sourceTree = "<group>"; };
TN1300000000000000000002 /* WeatherClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeatherClientTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -97,13 +269,19 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
T1000000000000000000004A /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
1B20C5393D8F432A93097C2C /* Views */ = {
isa = PBXGroup;
children = (
9934B0FCA757403A94AB963C /* ContentView.swift */,
0CD303E3EDCC4BF2BCF31722 /* AirportSearchField.swift */,
300153508F8445B6A78CEC52 /* DestinationsListView.swift */,
1C1176F877BF496ABF079040 /* RouteDetailView.swift */,
@@ -113,7 +291,34 @@
15676B4BD35745D1BD1DC947 /* AirportBrowserSheet.swift */,
BB1100001111000011110006 /* FlightLoadDetailView.swift */,
RE3300003333000033330002 /* RoutePlannerView.swift */,
RE4400004444000044440002 /* WhereToGoView.swift */,
REGT00000000000000000002 /* RouteExplorerGateSheet.swift */,
RE7700007777000077770002 /* ConnectionLoadDetailView.swift */,
LV4400004444000044440002 /* LiveFlightsView.swift */,
LV5500005555000055550002 /* LiveFlightDetailSheet.swift */,
LV6600006666000066660002 /* RootView.swift */,
LV8800008888000088880002 /* OpenSkySettingsView.swift */,
LVBB000BBBB000BBBB000002 /* LiveFilterPicker.swift */,
HX0800008888000088880002 /* HistoryView.swift */,
HX0900009999000099990002 /* HistoryRowView.swift */,
HX0A000AAAA000AAAA000002 /* HistoryDetailView.swift */,
HX0B000BBBB000BBBB000002 /* AddFlightView.swift */,
HX0C000CCCC000CCCC000002 /* CalendarImportView.swift */,
HX0D000DDDD000DDDD000002 /* LifetimeStatsView.swift */,
HX0E000EEEE000EEEE000002 /* HistoryRouteMapView.swift */,
HX0F000FFFF000FFFF000002 /* YearInReviewView.swift */,
HX1200001200000012000002 /* ImportCSVView.swift */,
HX1400001400000014000002 /* HistoryFilterSheet.swift */,
HX1500001500000015000002 /* AirportFlightsView.swift */,
HX1700001700000017000002 /* PassportComponents.swift */,
HX1800001800000018000002 /* PassportView.swift */,
HX1900001900000019000002 /* AircraftStatsView.swift */,
HX2000002000000020000002 /* EnrichAircraftTypesView.swift */,
NHB00000000000000000002 /* HubLoadsView.swift */,
TS0011110011110011110022 /* TurnstileDebugView.swift */,
DV0011110011110011110022 /* DiagnosticsView.swift */,
RS0011110011110011110022 /* RouteExplorerSetupView.swift */,
RB0011110011110011110022 /* RouteExplorerBrowserView.swift */,
NSV00000000000000000002 /* SettingsView.swift */,
AA5555555555555555555555 /* Styles */,
AA6666666666666666666666 /* Components */,
);
@@ -124,6 +329,7 @@
isa = PBXGroup;
children = (
AA2222222222222222222222 /* FlightTheme.swift */,
HX1600001600000016000002 /* HistoryStyle.swift */,
);
path = Styles;
sourceTree = "<group>";
@@ -146,20 +352,53 @@
B6019ED81F39462B92BDC856 /* Services */,
6E94DB5F9EB345948E2D5E2A /* ViewModels */,
1B20C5393D8F432A93097C2C /* Views */,
NRESGROUP00000000000001 /* Resources */,
D9E26DCDE2904210ABCA7855 /* Assets.xcassets */,
53F457716F0642BDBCBA93EA /* airports.json */,
LV9900009999000099990002 /* airlines.json */,
LVCC000CCCC000CCCC000002 /* aircraftDB.json */,
);
path = Flights;
sourceTree = "<group>";
};
NRESGROUP00000000000001 /* Resources */ = {
isa = PBXGroup;
children = (
NR0100000000000000000002 /* aircraft_seats.json */,
NR0200000000000000000002 /* bts_bundle.json */,
NR0700000000000000000002 /* bts_bundle_meta.json */,
);
path = Resources;
sourceTree = "<group>";
};
517CC07B82D949359C6CD4F5 /* Products */ = {
isa = PBXGroup;
children = (
8A3CB0CCC2524542AFB0D1D2 /* Flights.app */,
T1000000000000000000003A /* FlightsTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
T1000000000000000000005A /* FlightsTests */ = {
isa = PBXGroup;
children = (
T1000000000000000000001B /* AirlineLoadIntegrationTests.swift */,
FATEST00FATEST00FATEST02 /* FlightAwareScheduleClientTests.swift */,
TN0100000000000000000002 /* AirframeHistoryStoreTests.swift */,
TN0300000000000000000002 /* DataIntegrityMonitorTests.swift */,
TN0400000000000000000002 /* DelayCascadePredictorTests.swift */,
TN0500000000000000000002 /* EquipmentSwapServiceTests.swift */,
TN0600000000000000000002 /* HistoryFlightModelTests.swift */,
TN0800000000000000000002 /* LoadFactorServiceTests.swift */,
TN0900000000000000000002 /* SelftestRemovalTests.swift */,
TN1000000000000000000002 /* SisterFlightServiceTests.swift */,
TN1100000000000000000002 /* StandbyStatsServiceTests.swift */,
TN1300000000000000000002 /* WeatherClientTests.swift */,
);
path = FlightsTests;
sourceTree = "<group>";
};
6E94DB5F9EB345948E2D5E2A /* ViewModels */ = {
isa = PBXGroup;
children = (
@@ -180,6 +419,38 @@
BB1100001111000011110004 /* AirlineLoadService.swift */,
BB2200002222000022220002 /* JSXWebViewFetcher.swift */,
RE2200002222000022220002 /* RouteExplorerClient.swift */,
FA9911119911119911119922 /* FlightAwareScheduleClient.swift */,
BL0011110011110011110022 /* BlobRouteClient.swift */,
DL0011110011110011110022 /* DiagnosticLogger.swift */,
LD0011110011110011110022 /* LoggingURLSessionDelegate.swift */,
RT0011110011110011110022 /* RouteExplorerTokenStore.swift */,
LV2200002222000022220002 /* OpenSkyClient.swift */,
LV3300003333000033330002 /* AircraftRegistry.swift */,
LV7700007777000077770002 /* OpenSkyCredentials.swift */,
LVAA000AAAA000AAAA000002 /* AircraftDatabase.swift */,
LVDD000DDDD000DDDD000002 /* LocationService.swift */,
LVEE000EEEE000EEEE000002 /* FR24Client.swift */,
LVFF000FFFF000FFFF000002 /* AircraftPhotoService.swift */,
HX0300003333000033330002 /* FlightHistoryStore.swift */,
HX0500005555000055550002 /* StatsEngine.swift */,
HX0600006666000066660002 /* CalendarFlightImporter.swift */,
HX0700007777000077770002 /* WalletPassObserver.swift */,
HX1000001000000010000002 /* AirframeMetadataService.swift */,
HX1100001100000011000002 /* CSVFlightImporter.swift */,
HX1300001300000013000002 /* HistoryFilters.swift */,
HX2100002100000021000002 /* FlightAwareLookup.swift */,
NF0100000000000000000002 /* AircraftRotationTracker.swift */,
NF0200000000000000000002 /* AirframeHistoryStore.swift */,
NF0300000000000000000002 /* BTSDataStore.swift */,
NF0500000000000000000002 /* DelayCascadePredictor.swift */,
NF0600000000000000000002 /* EquipmentSwapService.swift */,
NF0700000000000000000002 /* HubLoadHeatmapService.swift */,
NF0900000000000000000002 /* LoadFactorService.swift */,
NF1000000000000000000002 /* OnTimePerformanceService.swift */,
NF1100000000000000000002 /* SisterFlightService.swift */,
NF1200000000000000000002 /* StandbyStatsService.swift */,
NF1400000000000000000002 /* WeatherClient.swift */,
NF1600000000000000000002 /* DataIntegrityMonitor.swift */,
);
path = Services;
sourceTree = "<group>";
@@ -188,6 +459,7 @@
isa = PBXGroup;
children = (
1D5A2C06B99046F3934D2E59 /* Flights */,
T1000000000000000000005A /* FlightsTests */,
517CC07B82D949359C6CD4F5 /* Products */,
);
sourceTree = "<group>";
@@ -205,6 +477,10 @@
E7987BD4832D44F1A0851933 /* Country.swift */,
BB1100001111000011110002 /* FlightLoad.swift */,
RE1100001111000011110002 /* RouteExplorerModels.swift */,
RE8800008888000088880002 /* SearchRoute.swift */,
LV1100001111000011110002 /* LiveAircraft.swift */,
HX0100001111000011110002 /* LoggedFlight.swift */,
HX0200002222000022220002 /* AirframeMetadata.swift */,
);
path = Models;
sourceTree = "<group>";
@@ -229,6 +505,23 @@
productReference = 8A3CB0CCC2524542AFB0D1D2 /* Flights.app */;
productType = "com.apple.product-type.application";
};
T1000000000000000000006A /* FlightsTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = T1000000000000000000007A /* Build configuration list for PBXNativeTarget "FlightsTests" */;
buildPhases = (
T1000000000000000000008A /* Sources */,
T1000000000000000000004A /* Frameworks */,
);
buildRules = (
);
dependencies = (
T1000000000000000000009A /* PBXTargetDependency */,
);
name = FlightsTests;
productName = FlightsTests;
productReference = T1000000000000000000003A /* FlightsTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -253,10 +546,19 @@
projectRoot = "";
targets = (
E373C48C497D48D388BF7657 /* Flights */,
T1000000000000000000006A /* FlightsTests */,
);
};
/* End PBXProject section */
/* Begin PBXTargetDependency section */
T1000000000000000000009A /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = E373C48C497D48D388BF7657 /* Flights */;
targetProxy = T1000000000000000000002A /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXResourcesBuildPhase section */
6B9FCA84AAAA44529A95D7AC /* Resources */ = {
isa = PBXResourcesBuildPhase;
@@ -264,6 +566,11 @@
files = (
F79789179F4443FD859BDEF0 /* Assets.xcassets in Resources */,
80D2BC95002A4931B3C10B4C /* airports.json in Resources */,
LV9900009999000099990001 /* airlines.json in Resources */,
LVCC000CCCC000CCCC000001 /* aircraftDB.json in Resources */,
NR0100000000000000000001 /* aircraft_seats.json in Resources */,
NR0200000000000000000001 /* bts_bundle.json in Resources */,
NR0700000000000000000001 /* bts_bundle_meta.json in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -283,7 +590,6 @@
7FFD9A2D25F9421D8929C027 /* SearchViewModel.swift in Sources */,
C36C490556254AC88EC02C80 /* DestinationsViewModel.swift in Sources */,
9C1300E497B049FE8DA677E0 /* RouteDetailViewModel.swift in Sources */,
57A463AB3CFD44DC93444E59 /* ContentView.swift in Sources */,
303821C9668A44F38FFA02CA /* AirportSearchField.swift in Sources */,
D0EC717347974D668C77B9D2 /* DestinationsListView.swift in Sources */,
BB3E647E4A07477F9F37E607 /* RouteDetailView.swift in Sources */,
@@ -306,10 +612,93 @@
BB2200002222000022220001 /* JSXWebViewFetcher.swift in Sources */,
RE1100001111000011110001 /* RouteExplorerModels.swift in Sources */,
RE2200002222000022220001 /* RouteExplorerClient.swift in Sources */,
FA9911119911119911119911 /* FlightAwareScheduleClient.swift in Sources */,
BL0011110011110011110011 /* BlobRouteClient.swift in Sources */,
TS0011110011110011110011 /* TurnstileDebugView.swift in Sources */,
DL0011110011110011110011 /* DiagnosticLogger.swift in Sources */,
LD0011110011110011110011 /* LoggingURLSessionDelegate.swift in Sources */,
DV0011110011110011110011 /* DiagnosticsView.swift in Sources */,
RT0011110011110011110011 /* RouteExplorerTokenStore.swift in Sources */,
RS0011110011110011110011 /* RouteExplorerSetupView.swift in Sources */,
RB0011110011110011110011 /* RouteExplorerBrowserView.swift in Sources */,
RE3300003333000033330001 /* RoutePlannerView.swift in Sources */,
RE4400004444000044440001 /* WhereToGoView.swift in Sources */,
REGT00000000000000000001 /* RouteExplorerGateSheet.swift in Sources */,
RE5500005555000055550001 /* IATAAirportPicker.swift in Sources */,
RE6600006666000066660001 /* ConnectionRow.swift in Sources */,
RE7700007777000077770001 /* ConnectionLoadDetailView.swift in Sources */,
RE8800008888000088880001 /* SearchRoute.swift in Sources */,
LV1100001111000011110001 /* LiveAircraft.swift in Sources */,
LV2200002222000022220001 /* OpenSkyClient.swift in Sources */,
LV3300003333000033330001 /* AircraftRegistry.swift in Sources */,
LV4400004444000044440001 /* LiveFlightsView.swift in Sources */,
LV5500005555000055550001 /* LiveFlightDetailSheet.swift in Sources */,
LV6600006666000066660001 /* RootView.swift in Sources */,
LV7700007777000077770001 /* OpenSkyCredentials.swift in Sources */,
LV8800008888000088880001 /* OpenSkySettingsView.swift in Sources */,
LVAA000AAAA000AAAA000001 /* AircraftDatabase.swift in Sources */,
LVBB000BBBB000BBBB000001 /* LiveFilterPicker.swift in Sources */,
LVDD000DDDD000DDDD000001 /* LocationService.swift in Sources */,
LVEE000EEEE000EEEE000001 /* FR24Client.swift in Sources */,
LVFF000FFFF000FFFF000001 /* AircraftPhotoService.swift in Sources */,
HX0100001111000011110001 /* LoggedFlight.swift in Sources */,
HX0200002222000022220001 /* AirframeMetadata.swift in Sources */,
HX0300003333000033330001 /* FlightHistoryStore.swift in Sources */,
HX0500005555000055550001 /* StatsEngine.swift in Sources */,
HX0600006666000066660001 /* CalendarFlightImporter.swift in Sources */,
HX0700007777000077770001 /* WalletPassObserver.swift in Sources */,
HX0800008888000088880001 /* HistoryView.swift in Sources */,
HX0900009999000099990001 /* HistoryRowView.swift in Sources */,
HX0A000AAAA000AAAA000001 /* HistoryDetailView.swift in Sources */,
HX0B000BBBB000BBBB000001 /* AddFlightView.swift in Sources */,
HX0C000CCCC000CCCC000001 /* CalendarImportView.swift in Sources */,
HX0D000DDDD000DDDD000001 /* LifetimeStatsView.swift in Sources */,
HX0E000EEEE000EEEE000001 /* HistoryRouteMapView.swift in Sources */,
HX0F000FFFF000FFFF000001 /* YearInReviewView.swift in Sources */,
HX1000001000000010000001 /* AirframeMetadataService.swift in Sources */,
HX1100001100000011000001 /* CSVFlightImporter.swift in Sources */,
HX1200001200000012000001 /* ImportCSVView.swift in Sources */,
HX1300001300000013000001 /* HistoryFilters.swift in Sources */,
HX1400001400000014000001 /* HistoryFilterSheet.swift in Sources */,
HX1500001500000015000001 /* AirportFlightsView.swift in Sources */,
HX1600001600000016000001 /* HistoryStyle.swift in Sources */,
HX1700001700000017000001 /* PassportComponents.swift in Sources */,
HX1800001800000018000001 /* PassportView.swift in Sources */,
HX1900001900000019000001 /* AircraftStatsView.swift in Sources */,
HX2000002000000020000001 /* EnrichAircraftTypesView.swift in Sources */,
HX2100002100000021000001 /* FlightAwareLookup.swift in Sources */,
NF0100000000000000000001 /* AircraftRotationTracker.swift in Sources */,
NF0200000000000000000001 /* AirframeHistoryStore.swift in Sources */,
NF0300000000000000000001 /* BTSDataStore.swift in Sources */,
NF0500000000000000000001 /* DelayCascadePredictor.swift in Sources */,
NF0600000000000000000001 /* EquipmentSwapService.swift in Sources */,
NF0700000000000000000001 /* HubLoadHeatmapService.swift in Sources */,
NF0900000000000000000001 /* LoadFactorService.swift in Sources */,
NF1000000000000000000001 /* OnTimePerformanceService.swift in Sources */,
NF1100000000000000000001 /* SisterFlightService.swift in Sources */,
NF1200000000000000000001 /* StandbyStatsService.swift in Sources */,
NF1400000000000000000001 /* WeatherClient.swift in Sources */,
NHB00000000000000000001 /* HubLoadsView.swift in Sources */,
NSV00000000000000000001 /* SettingsView.swift in Sources */,
NF1600000000000000000001 /* DataIntegrityMonitor.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
T1000000000000000000008A /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
T1000000000000000000001A /* AirlineLoadIntegrationTests.swift in Sources */,
FATEST00FATEST00FATEST01 /* FlightAwareScheduleClientTests.swift in Sources */,
TN0100000000000000000001 /* AirframeHistoryStoreTests.swift in Sources */,
TN0300000000000000000001 /* DataIntegrityMonitorTests.swift in Sources */,
TN0400000000000000000001 /* DelayCascadePredictorTests.swift in Sources */,
TN0500000000000000000001 /* EquipmentSwapServiceTests.swift in Sources */,
TN0600000000000000000001 /* HistoryFlightModelTests.swift in Sources */,
TN0800000000000000000001 /* LoadFactorServiceTests.swift in Sources */,
TN0900000000000000000001 /* SelftestRemovalTests.swift in Sources */,
TN1000000000000000000001 /* SisterFlightServiceTests.swift in Sources */,
TN1100000000000000000001 /* StandbyStatsServiceTests.swift in Sources */,
TN1300000000000000000001 /* WeatherClientTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -321,15 +710,12 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Flights/Flights.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_TEAM = V3PF3M6B6U;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Flights/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -350,15 +736,12 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Flights/Flights.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_TEAM = V3PF3M6B6U;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Flights/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -423,6 +806,44 @@
};
name = Debug;
};
T100000000000000000000BA /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_TEAM = V3PF3M6B6U;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.flights.app.FlightsTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Flights.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Flights";
};
name = Debug;
};
T100000000000000000000CA /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_TEAM = V3PF3M6B6U;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.flights.app.FlightsTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Flights.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Flights";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -444,6 +865,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
T1000000000000000000007A /* Build configuration list for PBXNativeTarget "FlightsTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
T100000000000000000000BA /* Debug */,
T100000000000000000000CA /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 5418BEEAEFF644ADA7240CEA /* Project object */;
@@ -0,0 +1,102 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1540"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E373C48C497D48D388BF7657"
BuildableName = "Flights.app"
BlueprintName = "Flights"
ReferencedContainer = "container:Flights.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "NO"
buildForProfiling = "NO"
buildForArchiving = "NO"
buildForAnalyzing = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "T1000000000000000000006A"
BuildableName = "FlightsTests.xctest"
BlueprintName = "FlightsTests"
ReferencedContainer = "container:Flights.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "T1000000000000000000006A"
BuildableName = "FlightsTests.xctest"
BlueprintName = "FlightsTests"
ReferencedContainer = "container:Flights.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E373C48C497D48D388BF7657"
BuildableName = "Flights.app"
BlueprintName = "Flights"
ReferencedContainer = "container:Flights.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E373C48C497D48D388BF7657"
BuildableName = "Flights.app"
BlueprintName = "Flights"
ReferencedContainer = "container:Flights.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
</dict>
</plist>
+67 -9
View File
@@ -1,28 +1,86 @@
import SwiftUI
import SwiftData
@main
struct FlightsApp: App {
let service = FlightService()
let database: AirportDatabase
let favoritesManager = FavoritesManager()
let loadService: AirlineLoadService
let routeExplorer = RouteExplorerClient()
let openSky = OpenSkyClient()
let fr24 = FR24Client()
let flightAware: FlightAwareScheduleClient
/// SwiftData container for the personal flight log. Uses CloudKit
/// private DB so the log syncs across the user's devices. Falls
/// back to a local-only store if CloudKit isn't provisioned (which
/// keeps the app functional during initial dev / first deploy).
let modelContainer: ModelContainer
init() {
// Initialize the diagnostic logger eagerly so the session boot
// header (device, OS, app version, locale, UA) lands in the log
// file the instant the app launches before any user action.
// Makes shared dumps self-describing even when nothing else has
// been touched.
_ = DiagnosticLogger.shared
let db = AirportDatabase()
self.database = db
self.loadService = AirlineLoadService(airportDatabase: db)
self.flightAware = FlightAwareScheduleClient(database: db)
// Pre-load the bundled airline + aircraft databases on a background
// thread. Both are large enough (200KB and 1.5MB) to noticeably
// jank the UI if we wait until first access on the Live tab.
AircraftRegistry.shared.preload()
AircraftDatabase.shared.preload()
// SwiftData store. Local only CloudKit sync is disabled
// until the iCloud cap is provisioned for the bundle id. The
// disk store may be incompatible if a prior build wrote with
// a different schema; we fall back to in-memory in that case
// so the app still launches (history won't persist across
// restarts, but the user can fix it by deleting + reinstalling
// once a stable schema is on disk).
let schema = Schema([LoggedFlight.self, AirframeMetadata.self])
let localConfig = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: false,
cloudKitDatabase: .none
)
if let container = try? ModelContainer(for: schema, configurations: [localConfig]) {
self.modelContainer = container
} else {
let memConfig = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: true,
cloudKitDatabase: .none
)
self.modelContainer = try! ModelContainer(for: schema, configurations: [memConfig])
}
}
var body: some Scene {
WindowGroup {
ContentView(
service: service,
database: database,
loadService: loadService,
favoritesManager: favoritesManager,
routeExplorer: routeExplorer
)
// Debug shortcut: launch the app with `-TurnstileDebug` to
// skip RootView and open straight into ``TurnstileDebugView``.
// Lets the harness drive the gate-sheet investigation without
// navigating tabs. Production builds never pass this flag.
if CommandLine.arguments.contains("-TurnstileDebug") {
NavigationStack {
TurnstileDebugView()
}
} else {
RootView(
database: database,
loadService: loadService,
routeExplorer: routeExplorer,
openSky: openSky,
fr24: fr24,
flightAware: flightAware
)
}
}
.modelContainer(modelContainer)
}
}
+67
View File
@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>com.flights.app</string>
<key>CFBundleURLSchemes</key>
<array>
<string>flights</string>
</array>
</dict>
</array>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Show your current location on the live flight map so you can quickly see aircraft overhead.</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<true/>
<key>UISceneConfigurations</key>
<dict/>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchScreen</key>
<dict/>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
+27
View File
@@ -0,0 +1,27 @@
import Foundation
import SwiftData
/// Per-airframe enrichment cached locally (and synced via CloudKit so we
/// only scrape jetphotos once per airframe across all of a user's
/// devices). Keyed by registration. Currently captures first-flight /
/// delivery dates so we can render "this plane is 8 years old" in the
/// detail sheet.
@Model
final class AirframeMetadata {
var registration: String = "" // "N281WN" uppercase
var firstFlightDate: Date?
var deliveryDate: Date?
var scrapedAt: Date = Date()
init(
registration: String,
firstFlightDate: Date? = nil,
deliveryDate: Date? = nil,
scrapedAt: Date = Date()
) {
self.registration = registration.uppercased()
self.firstFlightDate = firstFlightDate
self.deliveryDate = deliveryDate
self.scrapedAt = scrapedAt
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ import Foundation
/// Flight load data from airline APIs
struct FlightLoad: Sendable {
let airlineCode: String // "UA", "AA", "KE", "NK"
let airlineCode: String // "UA", "AA", "KE", etc.
let flightNumber: String // "UA2238"
let cabins: [CabinLoad] // Full cabin data (United)
let standbyList: [StandbyPassenger]
+188
View File
@@ -0,0 +1,188 @@
import Foundation
import CoreLocation
/// One aircraft's live state vector, normalized from OpenSky's `/states/all`
/// positional array format into a typed struct.
struct LiveAircraft: Identifiable, Hashable, Sendable {
var id: String { icao24 }
/// 24-bit ICAO transponder address as hex (lowercased).
let icao24: String
/// ADS-B broadcast callsign, e.g. `DAL1234` (ICAO airline code + flight number).
/// Often padded with trailing whitespace `trimmedCallsign` strips that.
let callsign: String?
/// ICAO-registered country of the operator.
let originCountry: String
let latitude: Double
let longitude: Double
/// Barometric altitude in meters. Falls back to geometric altitude in
/// `altitudeFeet`.
let baroAltitude: Double?
let geoAltitude: Double?
/// Velocity in m/s.
let velocity: Double?
/// True track in degrees from North (0..360).
let trueTrack: Double?
/// Vertical rate in m/s; positive = climbing.
let verticalRate: Double?
let onGround: Bool
let squawk: String?
/// Aircraft category from ADS-B emitter category (07). Decodes per
/// `aircraftCategoryName`.
let category: Int?
/// When the position was last updated (server-side).
let lastContact: Date
/// Extra fields the FR24 feed provides inline (departure/arrival IATA,
/// flight number, aircraft model, tail number, airline ICAO). Always
/// nil for aircraft sourced from OpenSky.
let enrichment: Enrichment?
/// FR24-only inline data. None of these are guaranteed even when the
/// outer envelope is FR24-sourced gate aircraft often have no
/// flight number, GA aircraft no airline, etc.
struct Enrichment: Hashable, Sendable {
let modelType: String? // ICAO type designator, e.g. "B738"
let registration: String? // Tail number, e.g. "N971NN"
let flightIATA: String? // "AA2152"
let departureIATA: String? // "DFW"
let arrivalIATA: String? // "MSP"
let airlineICAO: String? // "AAL"
}
var coordinate: CLLocationCoordinate2D {
CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
}
var altitudeFeet: Int? {
guard let alt = baroAltitude ?? geoAltitude else { return nil }
return Int(alt * 3.28084)
}
var velocityKnots: Int? {
guard let v = velocity else { return nil }
return Int(v * 1.94384)
}
var heading: Int? {
guard let t = trueTrack else { return nil }
return Int(t.truncatingRemainder(dividingBy: 360))
}
var verticalState: VerticalState {
guard let vr = verticalRate else { return .level }
if vr > 1.5 { return .climbing }
if vr < -1.5 { return .descending }
return .level
}
/// ICAO aircraft type designator (e.g. "B738", "A21N"). Prefers the
/// FR24-supplied model when present (more accurate, includes
/// recent retrofits), else falls back to the bundled DB lookup.
var typeCode: String? {
if let m = enrichment?.modelType, !m.isEmpty { return m }
return AircraftDatabase.shared.typeCode(forICAO24: icao24)
}
var trimmedCallsign: String? {
let s = (callsign ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return s.isEmpty ? nil : s
}
/// 3-letter ICAO airline prefix. Prefers the FR24-supplied value when
/// available it correctly identifies SWA at "AA0013" style callsigns
/// where the prefix derivation would fail. Falls back to extracting
/// the leading letters from the callsign (works for OpenSky-style
/// "AAL2152").
var airlineICAO: String? {
if let a = enrichment?.airlineICAO, !a.isEmpty { return a }
guard let cs = trimmedCallsign else { return nil }
let letters = cs.prefix(while: { $0.isLetter })
guard letters.count == 3 else { return nil }
return String(letters)
}
/// Numeric flight number portion (everything after the airline prefix).
var flightNumber: String? {
guard let cs = trimmedCallsign else { return nil }
let s = String(cs.drop(while: { $0.isLetter }))
return s.isEmpty ? nil : s
}
}
enum VerticalState {
case climbing, descending, level
}
/// ADS-B emitter category, 17, per RTCA DO-260.
func aircraftCategoryName(_ code: Int?) -> String? {
switch code {
case 1: return "Light"
case 2: return "Small"
case 3: return "Large"
case 4: return "High vortex large"
case 5: return "Heavy"
case 6: return "High performance"
case 7: return "Rotorcraft"
default: return nil
}
}
/// In-flight position track for one aircraft, returned by OpenSky's
/// `/tracks/all?icao24=...&time=0` endpoint. Each path entry is a
/// `[time, lat, lon, baroAlt, trueTrack, onGround]` heterogeneous array,
/// which we decode into a typed `TrackPoint`.
struct AircraftTrack: Decodable, Sendable {
let icao24: String
let callsign: String?
let startTime: Int
let endTime: Int
let path: [TrackPoint]
struct TrackPoint: Decodable, Sendable, Hashable {
let time: Int
let latitude: Double
let longitude: Double
let baroAltitude: Double?
let trueTrack: Double?
let onGround: Bool
init(from decoder: Decoder) throws {
var c = try decoder.unkeyedContainer()
time = (try? c.decode(Int.self)) ?? 0
latitude = (try? c.decodeIfPresent(Double.self)) ?? 0
longitude = (try? c.decodeIfPresent(Double.self)) ?? 0
baroAltitude = try? c.decodeIfPresent(Double.self)
trueTrack = try? c.decodeIfPresent(Double.self)
onGround = (try? c.decode(Bool.self)) ?? false
}
}
}
/// Historical OpenSky flight record used to surface "where did this aircraft
/// take off from / where's it going" in the detail sheet.
struct OpenSkyFlight: Decodable, Sendable, Hashable {
let icao24: String
let firstSeen: Int
let lastSeen: Int
let estDepartureAirport: String?
let estArrivalAirport: String?
let callsign: String?
var departureDate: Date { Date(timeIntervalSince1970: TimeInterval(firstSeen)) }
var arrivalDate: Date { Date(timeIntervalSince1970: TimeInterval(lastSeen)) }
var trimmedCallsign: String? {
let s = (callsign ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return s.isEmpty ? nil : s
}
}
+113
View File
@@ -0,0 +1,113 @@
import Foundation
import SwiftData
/// A single flight the user has flown (or is flying), persisted to
/// SwiftData and synced via CloudKit private DB. Intentionally lean
/// we don't track baggage / terminal / gate / seat / cabin class /
/// delay reason. Just identity, route, aircraft, and free-text notes.
///
/// CloudKit constraints: every property must be optional or have a
/// default value, and no `@Attribute(.unique)` on synced models.
@Model
final class LoggedFlight {
var id: UUID = UUID()
var loggedAt: Date = Date()
// MARK: Identity
var flightDate: Date = Date()
var carrierICAO: String? // "SWA"
var carrierIATA: String? // "WN"
var flightNumber: String? // "7"
// MARK: Route IATA codes are the canonical key into our airport DB
var departureIATA: String = ""
var arrivalIATA: String = ""
var scheduledDeparture: Date?
var scheduledArrival: Date?
var actualDeparture: Date?
var actualArrival: Date?
// MARK: Aircraft
var aircraftType: String? // "B738"
var registration: String? // "N281WN" also keys into AirframeMetadata
/// 24-bit ICAO transponder address (e.g. "abc123"). Only populated
/// for live-tap adds; lets the detail screen pull the actual flown
/// track from OpenSky's history endpoint.
var icao24: String?
// MARK: Personal
var notes: String?
/// Origin of this record. Used for analytics / debugging only.
/// Values: "live-tap" | "manual" | "calendar" | "wallet" | "mail-share"
var source: String = "manual"
// MARK: Standby (nonrev) tracking
/// Outcome of a standby attempt for this flight.
/// Values: "confirmed" | "standby-made" | "standby-bumped" | nil
/// All optional / default nil so existing records migrate automatically.
var standbyOutcome: String?
var standbyAttemptedAt: Date?
var standbyClearedAt: Date?
var standbyClass: String?
var standbyNotes: String?
init(
id: UUID = UUID(),
loggedAt: Date = Date(),
flightDate: Date = Date(),
carrierICAO: String? = nil,
carrierIATA: String? = nil,
flightNumber: String? = nil,
departureIATA: String = "",
arrivalIATA: String = "",
scheduledDeparture: Date? = nil,
scheduledArrival: Date? = nil,
actualDeparture: Date? = nil,
actualArrival: Date? = nil,
aircraftType: String? = nil,
registration: String? = nil,
icao24: String? = nil,
notes: String? = nil,
source: String = "manual"
) {
self.id = id
self.loggedAt = loggedAt
self.flightDate = flightDate
self.carrierICAO = carrierICAO
self.carrierIATA = carrierIATA
self.flightNumber = flightNumber
self.departureIATA = departureIATA
self.arrivalIATA = arrivalIATA
self.scheduledDeparture = scheduledDeparture
self.scheduledArrival = scheduledArrival
self.actualDeparture = actualDeparture
self.actualArrival = actualArrival
self.aircraftType = aircraftType
// Normalise tail to uppercase at write time so the
// AirframeHistoryStore fast-path predicate (an exact-match
// #Predicate, which can't call .uppercased()) always hits.
// AirframeMetadata.registration is similarly uppercased.
self.registration = registration.flatMap { reg in
let trimmed = reg.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed.uppercased()
}
self.icao24 = icao24
self.notes = notes
self.source = source
}
/// IATA-style flight label, e.g. "WN7" or "SWA7" if IATA is missing.
var flightLabel: String {
let prefix = carrierIATA ?? carrierICAO ?? ""
let number = flightNumber ?? ""
if prefix.isEmpty && number.isEmpty { return "" }
return "\(prefix)\(number)"
}
/// True when this flight was attempted on standby (regardless of whether
/// it cleared or the user got bumped).
var wasStandby: Bool {
standbyOutcome == "standby-made" || standbyOutcome == "standby-bumped"
}
}
+119 -4
View File
@@ -155,21 +155,136 @@ struct RouteAppendixEquipment: Decodable, Sendable {
// MARK: - Search result
/// Response from `/schedule` flat list of operating records for one
/// (carrier, flightNumber, date). Different envelope from `/route` /
/// `/departures` which return nested `connections[]`.
struct RouteExplorerScheduleResponse: Decodable, Sendable {
let json: Body
struct Body: Decodable, Sendable {
let flights: [RouteFlight]
let appendix: RouteAppendix?
}
}
struct RouteSearchResult: Sendable {
let connections: [RouteConnection]
let appendix: RouteAppendix?
}
/// Sort options for results lists. All applied client-side after fetch
/// upstream is always told to sort by `departure_time` so we get a stable
/// base order, then we reorder in `RoutePlannerView` (or in
/// `filteredFlights` for the departures list).
enum RouteSortOption: String, CaseIterable, Sendable {
case departureTime = "departure_time"
case duration = "duration"
case departureEarliest
case departureLatest
case fewestStops
case mostStops
var label: String {
switch self {
case .departureTime: return "Departure"
case .duration: return "Duration"
case .departureEarliest: return "Departure Earliest"
case .departureLatest: return "Departure Latest"
case .fewestStops: return "Fewest Stops"
case .mostStops: return "Most Stops"
}
}
/// String value the upstream API accepts. `nil` option is purely
/// client-side; the client falls back to `departure_time`.
var apiValue: String? {
switch self {
case .departureEarliest: return "departure_time"
default: return nil
}
}
/// Sort options shown in connection mode (TO is set).
static let connectionOptions: [RouteSortOption] = [
.departureEarliest, .departureLatest, .fewestStops, .mostStops
]
/// Sort options shown in "where can I go?" mode (TO is empty). All
/// results are direct, so the stop-count options aren't meaningful
/// keep just the two time-based options.
static let departureOptions: [RouteSortOption] = [
.departureEarliest, .departureLatest
]
}
// MARK: - Client-side sort comparators
extension RouteConnection {
/// First-leg departure time, used as a stable tiebreaker so equal-stop
/// connections still come out chronologically within their group.
var firstDeparture: Date {
flights.first?.departure.dateTime ?? .distantFuture
}
}
extension Array where Element == RouteConnection {
func sorted(by option: RouteSortOption) -> [RouteConnection] {
switch option {
case .departureEarliest:
return sorted { $0.firstDeparture < $1.firstDeparture }
case .departureLatest:
return sorted { $0.firstDeparture > $1.firstDeparture }
case .fewestStops:
return sorted {
if $0.stopCount != $1.stopCount {
return $0.stopCount < $1.stopCount
}
return $0.firstDeparture < $1.firstDeparture
}
case .mostStops:
return sorted {
if $0.stopCount != $1.stopCount {
return $0.stopCount > $1.stopCount
}
return $0.firstDeparture < $1.firstDeparture
}
}
}
}
extension Array where Element == RouteFlight {
/// Apply a sort to a flat list of legs (the where-can-I-go results
/// after window filtering). Stop-count options collapse to chronological
/// since departures are always single-leg.
func sorted(by option: RouteSortOption) -> [RouteFlight] {
switch option {
case .departureEarliest, .fewestStops, .mostStops:
return sorted { $0.departure.dateTime < $1.departure.dateTime }
case .departureLatest:
return sorted { $0.departure.dateTime > $1.departure.dateTime }
}
}
}
// MARK: - Sheet payload
/// Identifiable bundle of everything FlightLoadDetailView needs from a
/// RouteFlight tap. Use this as a single `@State` so `.sheet(item:)` sees
/// schedule + origin + destination + date atomically. Separate @State
/// properties race: setting `selectedFlight` non-nil materializes the sheet
/// before the other writes settle, and the sheet captures empty strings
/// which then hit the AA endpoint as `originAirportCode=&destinationAirportCode=`
/// and bounce as HTTP 400.
struct RouteLoadDetailRequest: Identifiable {
let id = UUID()
let schedule: FlightSchedule
let departureCode: String
let arrivalCode: String
let date: Date
}
/// Identifiable wrapper for presenting a multi-leg connection as a sheet.
/// Carries the connection itself plus the appendix (so the view can resolve
/// airline / equipment names and airport metadata).
struct ConnectionLoadRequest: Identifiable {
let id = UUID()
let connection: RouteConnection
let appendix: RouteAppendix?
}
// MARK: - Bridge to existing FlightSchedule (for FlightLoadDetailView reuse)
+13
View File
@@ -0,0 +1,13 @@
import Foundation
/// Navigation destinations used by `DestinationsListView`.
///
/// Originally defined alongside `ContentView`, which is now removed.
/// `RoutePlannerView` is the home and uses sheet-based detail presentation
/// rather than `navigationDestination(for:)`, so the only remaining caller
/// is the orphan path inside DestinationsListView. The enum stays so that
/// view still compiles in case it gets re-introduced as a feature later.
enum SearchRoute: Hashable {
case destinations(Airport, Date, Bool)
case routeDetail(Airport, Airport, Date)
}
+346
View File
@@ -0,0 +1,346 @@
{
"_meta": {
"lastUpdated": "2026-05-31",
"schemaVersion": 2,
"sources": [
"https://en.wikipedia.org/wiki/Southwest_Airlines_fleet",
"https://en.wikipedia.org/wiki/American_Airlines_fleet",
"https://en.wikipedia.org/wiki/Delta_Air_Lines_fleet",
"https://en.wikipedia.org/wiki/United_Airlines_fleet",
"https://en.wikipedia.org/wiki/Alaska_Airlines_fleet",
"https://en.wikipedia.org/wiki/JetBlue_fleet",
"https://en.wikipedia.org/wiki/Hawaiian_Airlines_fleet",
"https://en.wikipedia.org/wiki/Spirit_Airlines_fleet",
"https://en.wikipedia.org/wiki/Frontier_Airlines_fleet",
"https://en.wikipedia.org/wiki/Allegiant_Air_fleet",
"https://en.wikipedia.org/wiki/Sun_Country_Airlines"
],
"notes": "Seat counts vary by carrier+aircraft variant. The 'default' field is used when carrier is unknown or carrier-specific data is unavailable. Per-carrier counts come from each airline's published fleet pages (or Wikipedia summaries of same) as of May 2026. cabins: first = recliner/lie-flat domestic first or international business, business = lie-flat international business when distinct from first, premiumEconomy = MCE/Comfort+/Premium/Even More Space, economy = standard main cabin."
},
"iata": {
"73G": {
"default": { "name": "B737-700", "seats": 137, "body": "N" },
"byCarrier": {
"WN": { "seats": 137, "cabins": { "first": 0, "business": 0, "premiumEconomy": 0, "economy": 137 }, "source": "https://en.wikipedia.org/wiki/Southwest_Airlines_fleet" },
"UA": { "seats": 126, "cabins": { "first": 12, "business": 0, "premiumEconomy": 36, "economy": 78 }, "source": "https://en.wikipedia.org/wiki/United_Airlines_fleet" },
"AS": { "seats": 124, "cabins": { "first": 12, "business": 0, "premiumEconomy": 18, "economy": 94 }, "source": "https://en.wikipedia.org/wiki/Alaska_Airlines_fleet" }
}
},
"73H": {
"default": { "name": "B737-800", "seats": 172, "body": "N" },
"byCarrier": {
"WN": { "seats": 175, "cabins": { "first": 0, "business": 0, "premiumEconomy": 0, "economy": 175 }, "source": "https://en.wikipedia.org/wiki/Southwest_Airlines_fleet" },
"AA": { "seats": 172, "cabins": { "first": 16, "business": 0, "premiumEconomy": 24, "economy": 132 }, "source": "https://en.wikipedia.org/wiki/American_Airlines_fleet" },
"DL": { "seats": 160, "cabins": { "first": 16, "business": 0, "premiumEconomy": 36, "economy": 108 }, "source": "https://en.wikipedia.org/wiki/Delta_Air_Lines_fleet" },
"UA": { "seats": 166, "cabins": { "first": 16, "business": 0, "premiumEconomy": 48, "economy": 102 }, "source": "https://en.wikipedia.org/wiki/United_Airlines_fleet" },
"AS": { "seats": 159, "cabins": { "first": 12, "business": 0, "premiumEconomy": 30, "economy": 117 }, "source": "https://en.wikipedia.org/wiki/Alaska_Airlines_fleet" },
"SY": { "seats": 186, "cabins": { "first": 27, "business": 0, "premiumEconomy": 0, "economy": 159 }, "source": "https://en.wikipedia.org/wiki/Sun_Country_Airlines" }
}
},
"73W": {
"default": { "name": "B737-700W", "seats": 143, "body": "N" },
"byCarrier": {
"WN": { "seats": 137, "cabins": { "first": 0, "business": 0, "premiumEconomy": 0, "economy": 137 }, "source": "https://en.wikipedia.org/wiki/Southwest_Airlines_fleet" },
"UA": { "seats": 126, "cabins": { "first": 12, "business": 0, "premiumEconomy": 36, "economy": 78 }, "source": "https://en.wikipedia.org/wiki/United_Airlines_fleet" },
"AS": { "seats": 124, "cabins": { "first": 12, "business": 0, "premiumEconomy": 18, "economy": 94 }, "source": "https://en.wikipedia.org/wiki/Alaska_Airlines_fleet" }
}
},
"7S7": {
"default": { "name": "B737-700 (Southwest)", "seats": 137, "body": "N" },
"byCarrier": {
"WN": { "seats": 137, "cabins": { "first": 0, "business": 0, "premiumEconomy": 0, "economy": 137 }, "source": "https://en.wikipedia.org/wiki/Southwest_Airlines_fleet" }
}
},
"739": {
"default": { "name": "B737-900", "seats": 179, "body": "N" },
"byCarrier": {
"UA": { "seats": 179, "cabins": { "first": 20, "business": 0, "premiumEconomy": 45, "economy": 114 }, "source": "https://en.wikipedia.org/wiki/United_Airlines_fleet" },
"AS": { "seats": 178, "cabins": { "first": 16, "business": 0, "premiumEconomy": 30, "economy": 132 }, "source": "https://en.wikipedia.org/wiki/Alaska_Airlines_fleet" },
"DL": { "seats": 180, "cabins": { "first": 20, "business": 0, "premiumEconomy": 21, "economy": 139 }, "source": "https://en.wikipedia.org/wiki/Delta_Air_Lines_fleet" }
}
},
"7M8": {
"default": { "name": "B737-MAX 8", "seats": 172, "body": "N" },
"byCarrier": {
"WN": { "seats": 175, "cabins": { "first": 0, "business": 0, "premiumEconomy": 0, "economy": 175 }, "source": "https://en.wikipedia.org/wiki/Southwest_Airlines_fleet" },
"AA": { "seats": 172, "cabins": { "first": 16, "business": 0, "premiumEconomy": 24, "economy": 132 }, "source": "https://en.wikipedia.org/wiki/American_Airlines_fleet" },
"UA": { "seats": 166, "cabins": { "first": 16, "business": 0, "premiumEconomy": 54, "economy": 96 }, "source": "https://en.wikipedia.org/wiki/United_Airlines_fleet" },
"AS": { "seats": 159, "cabins": { "first": 12, "business": 0, "premiumEconomy": 30, "economy": 117 }, "source": "https://en.wikipedia.org/wiki/Alaska_Airlines_fleet" },
"G4": { "seats": 190, "cabins": { "first": 0, "business": 0, "premiumEconomy": 21, "economy": 169 }, "source": "https://en.wikipedia.org/wiki/Allegiant_Air_fleet" }
}
},
"7M9": {
"default": { "name": "B737-MAX 9", "seats": 179, "body": "N" },
"byCarrier": {
"UA": { "seats": 179, "cabins": { "first": 20, "business": 0, "premiumEconomy": 45, "economy": 114 }, "source": "https://en.wikipedia.org/wiki/United_Airlines_fleet" },
"AS": { "seats": 178, "cabins": { "first": 16, "business": 0, "premiumEconomy": 30, "economy": 132 }, "source": "https://en.wikipedia.org/wiki/Alaska_Airlines_fleet" },
"DL": { "seats": 172, "cabins": { "first": 20, "business": 0, "premiumEconomy": 30, "economy": 122 }, "source": "https://en.wikipedia.org/wiki/Delta_Air_Lines_fleet" }
}
},
"7MA": {
"default": { "name": "B737-MAX 10", "seats": 188, "body": "N" },
"byCarrier": {
"UA": { "seats": 189, "cabins": { "first": 20, "business": 0, "premiumEconomy": 64, "economy": 105 }, "source": "https://en.wikipedia.org/wiki/United_Airlines_fleet" },
"DL": { "seats": 182, "cabins": { "first": 20, "business": 0, "premiumEconomy": 33, "economy": 129 }, "source": "https://en.wikipedia.org/wiki/Delta_Air_Lines_fleet" }
}
},
"319": {
"default": { "name": "A319", "seats": 128, "body": "N" },
"byCarrier": {
"AA": { "seats": 128, "cabins": { "first": 8, "business": 0, "premiumEconomy": 24, "economy": 96 }, "source": "https://en.wikipedia.org/wiki/American_Airlines_fleet" },
"DL": { "seats": 132, "cabins": { "first": 12, "business": 0, "premiumEconomy": 18, "economy": 102 }, "source": "https://en.wikipedia.org/wiki/Delta_Air_Lines_fleet" },
"UA": { "seats": 126, "cabins": { "first": 12, "business": 0, "premiumEconomy": 36, "economy": 78 }, "source": "https://en.wikipedia.org/wiki/United_Airlines_fleet" },
"F9": { "seats": 150, "cabins": { "first": 0, "business": 0, "premiumEconomy": 18, "economy": 132 }, "source": "https://en.wikipedia.org/wiki/Frontier_Airlines_fleet" },
"G4": { "seats": 156, "cabins": { "first": 0, "business": 0, "premiumEconomy": 18, "economy": 138 }, "source": "https://en.wikipedia.org/wiki/Allegiant_Air_fleet" }
}
},
"320": {
"default": { "name": "A320", "seats": 150, "body": "N" },
"byCarrier": {
"AA": { "seats": 150, "cabins": { "first": 12, "business": 0, "premiumEconomy": 18, "economy": 120 }, "source": "https://en.wikipedia.org/wiki/American_Airlines_fleet" },
"DL": { "seats": 157, "cabins": { "first": 16, "business": 0, "premiumEconomy": 18, "economy": 123 }, "source": "https://en.wikipedia.org/wiki/Delta_Air_Lines_fleet" },
"UA": { "seats": 150, "cabins": { "first": 12, "business": 0, "premiumEconomy": 42, "economy": 96 }, "source": "https://en.wikipedia.org/wiki/United_Airlines_fleet" },
"B6": { "seats": 150, "cabins": { "first": 0, "business": 0, "premiumEconomy": 42, "economy": 108 }, "source": "https://en.wikipedia.org/wiki/JetBlue_fleet" },
"NK": { "seats": 176, "cabins": { "first": 8, "business": 0, "premiumEconomy": 0, "economy": 168 }, "source": "https://en.wikipedia.org/wiki/Spirit_Airlines_fleet" },
"F9": { "seats": 180, "cabins": { "first": 0, "business": 0, "premiumEconomy": 18, "economy": 162 }, "source": "https://en.wikipedia.org/wiki/Frontier_Airlines_fleet" },
"G4": { "seats": 177, "cabins": { "first": 0, "business": 0, "premiumEconomy": 18, "economy": 159 }, "source": "https://en.wikipedia.org/wiki/Allegiant_Air_fleet" }
}
},
"32N": {
"default": { "name": "A320neo", "seats": 180, "body": "N" },
"byCarrier": {
"NK": { "seats": 176, "cabins": { "first": 8, "business": 0, "premiumEconomy": 0, "economy": 168 }, "source": "https://en.wikipedia.org/wiki/Spirit_Airlines_fleet" },
"F9": { "seats": 186, "cabins": { "first": 0, "business": 0, "premiumEconomy": 18, "economy": 168 }, "source": "https://en.wikipedia.org/wiki/Frontier_Airlines_fleet" }
}
},
"321": {
"default": { "name": "A321", "seats": 190, "body": "N" },
"byCarrier": {
"AA": { "seats": 190, "cabins": { "first": 20, "business": 0, "premiumEconomy": 35, "economy": 135 }, "source": "https://en.wikipedia.org/wiki/American_Airlines_fleet" },
"DL": { "seats": 191, "cabins": { "first": 20, "business": 0, "premiumEconomy": 29, "economy": 142 }, "source": "https://en.wikipedia.org/wiki/Delta_Air_Lines_fleet" },
"B6": { "seats": 200, "cabins": { "first": 0, "business": 0, "premiumEconomy": 42, "economy": 158 }, "source": "https://en.wikipedia.org/wiki/JetBlue_fleet" },
"NK": { "seats": 229, "cabins": { "first": 8, "business": 0, "premiumEconomy": 0, "economy": 221 }, "source": "https://en.wikipedia.org/wiki/Spirit_Airlines_fleet" },
"F9": { "seats": 230, "cabins": { "first": 0, "business": 0, "premiumEconomy": 18, "economy": 212 }, "source": "https://en.wikipedia.org/wiki/Frontier_Airlines_fleet" }
}
},
"21N": {
"default": { "name": "A321neo", "seats": 196, "body": "N" },
"byCarrier": {
"AA": { "seats": 196, "cabins": { "first": 20, "business": 0, "premiumEconomy": 35, "economy": 141 }, "source": "https://en.wikipedia.org/wiki/American_Airlines_fleet" },
"DL": { "seats": 194, "cabins": { "first": 20, "business": 0, "premiumEconomy": 54, "economy": 120 }, "source": "https://en.wikipedia.org/wiki/Delta_Air_Lines_fleet" },
"UA": { "seats": 200, "cabins": { "first": 20, "business": 0, "premiumEconomy": 57, "economy": 123 }, "source": "https://en.wikipedia.org/wiki/United_Airlines_fleet" },
"B6": { "seats": 200, "cabins": { "first": 0, "business": 0, "premiumEconomy": 42, "economy": 158 }, "source": "https://en.wikipedia.org/wiki/JetBlue_fleet" },
"HA": { "seats": 189, "cabins": { "first": 16, "business": 0, "premiumEconomy": 44, "economy": 129 }, "source": "https://en.wikipedia.org/wiki/Hawaiian_Airlines_fleet" },
"NK": { "seats": 229, "cabins": { "first": 8, "business": 0, "premiumEconomy": 0, "economy": 221 }, "source": "https://en.wikipedia.org/wiki/Spirit_Airlines_fleet" },
"F9": { "seats": 240, "cabins": { "first": 0, "business": 0, "premiumEconomy": 18, "economy": 222 }, "source": "https://en.wikipedia.org/wiki/Frontier_Airlines_fleet" }
}
},
"21X": {
"default": { "name": "A321XLR", "seats": 155, "body": "N" },
"byCarrier": {
"AA": { "seats": 155, "cabins": { "first": 20, "business": 0, "premiumEconomy": 24, "economy": 111 }, "source": "https://en.wikipedia.org/wiki/American_Airlines_fleet" }
}
},
"221": {
"default": { "name": "A220-100", "seats": 109, "body": "N" },
"byCarrier": {
"DL": { "seats": 109, "cabins": { "first": 12, "business": 0, "premiumEconomy": 15, "economy": 82 }, "source": "https://en.wikipedia.org/wiki/Delta_Air_Lines_fleet" }
}
},
"223": {
"default": { "name": "A220-300", "seats": 140, "body": "N" },
"byCarrier": {
"DL": { "seats": 130, "cabins": { "first": 12, "business": 0, "premiumEconomy": 30, "economy": 88 }, "source": "https://en.wikipedia.org/wiki/Delta_Air_Lines_fleet" },
"B6": { "seats": 140, "cabins": { "first": 0, "business": 0, "premiumEconomy": 30, "economy": 110 }, "source": "https://en.wikipedia.org/wiki/JetBlue_fleet" }
}
},
"752": {
"default": { "name": "B757-200", "seats": 176, "body": "N" },
"byCarrier": {
"DL": { "seats": 168, "cabins": { "first": 16, "business": 0, "premiumEconomy": 44, "economy": 108 }, "source": "https://en.wikipedia.org/wiki/Delta_Air_Lines_fleet" },
"UA": { "seats": 176, "cabins": { "first": 16, "business": 0, "premiumEconomy": 42, "economy": 118 }, "source": "https://en.wikipedia.org/wiki/United_Airlines_fleet" }
}
},
"753": {
"default": { "name": "B757-300", "seats": 234, "body": "N" },
"byCarrier": {
"DL": { "seats": 234, "cabins": { "first": 24, "business": 0, "premiumEconomy": 32, "economy": 178 }, "source": "https://en.wikipedia.org/wiki/Delta_Air_Lines_fleet" },
"UA": { "seats": 234, "cabins": { "first": 24, "business": 0, "premiumEconomy": 54, "economy": 156 }, "source": "https://en.wikipedia.org/wiki/United_Airlines_fleet" }
}
},
"763": {
"default": { "name": "B767-300", "seats": 211, "body": "W" },
"byCarrier": {
"DL": { "seats": 211, "cabins": { "first": 36, "business": 0, "premiumEconomy": 32, "economy": 143 }, "source": "https://en.wikipedia.org/wiki/Delta_Air_Lines_fleet" },
"UA": { "seats": 199, "cabins": { "first": 30, "business": 0, "premiumEconomy": 56, "economy": 113 }, "source": "https://en.wikipedia.org/wiki/United_Airlines_fleet" }
}
},
"764": {
"default": { "name": "B767-400", "seats": 238, "body": "W" },
"byCarrier": {
"DL": { "seats": 238, "cabins": { "first": 34, "business": 0, "premiumEconomy": 20, "economy": 184 }, "source": "https://en.wikipedia.org/wiki/Delta_Air_Lines_fleet" },
"UA": { "seats": 231, "cabins": { "first": 34, "business": 0, "premiumEconomy": 72, "economy": 125 }, "source": "https://en.wikipedia.org/wiki/United_Airlines_fleet" }
}
},
"772": {
"default": { "name": "B777-200", "seats": 364, "body": "W" },
"byCarrier": {
"AA": { "seats": 273, "cabins": { "first": 0, "business": 37, "premiumEconomy": 90, "economy": 146 }, "source": "https://en.wikipedia.org/wiki/American_Airlines_fleet" },
"UA": { "seats": 364, "cabins": { "first": 0, "business": 28, "premiumEconomy": 102, "economy": 234 }, "source": "https://en.wikipedia.org/wiki/United_Airlines_fleet" }
}
},
"773": {
"default": { "name": "B777-300", "seats": 350, "body": "W" },
"byCarrier": {
"AA": { "seats": 304, "cabins": { "first": 0, "business": 60, "premiumEconomy": 28, "economy": 216 }, "source": "https://en.wikipedia.org/wiki/American_Airlines_fleet" }
}
},
"77W": {
"default": { "name": "B777-300ER", "seats": 350, "body": "W" },
"byCarrier": {
"AA": { "seats": 304, "cabins": { "first": 0, "business": 60, "premiumEconomy": 28, "economy": 216 }, "source": "https://en.wikipedia.org/wiki/American_Airlines_fleet" },
"UA": { "seats": 350, "cabins": { "first": 0, "business": 60, "premiumEconomy": 86, "economy": 204 }, "source": "https://en.wikipedia.org/wiki/United_Airlines_fleet" }
}
},
"788": {
"default": { "name": "B787-8", "seats": 234, "body": "W" },
"byCarrier": {
"AA": { "seats": 234, "cabins": { "first": 0, "business": 20, "premiumEconomy": 76, "economy": 138 }, "source": "https://en.wikipedia.org/wiki/American_Airlines_fleet" },
"UA": { "seats": 243, "cabins": { "first": 0, "business": 28, "premiumEconomy": 57, "economy": 158 }, "source": "https://en.wikipedia.org/wiki/United_Airlines_fleet" }
}
},
"789": {
"default": { "name": "B787-9", "seats": 285, "body": "W" },
"byCarrier": {
"AA": { "seats": 285, "cabins": { "first": 0, "business": 30, "premiumEconomy": 48, "economy": 207 }, "source": "https://en.wikipedia.org/wiki/American_Airlines_fleet" },
"UA": { "seats": 257, "cabins": { "first": 0, "business": 48, "premiumEconomy": 60, "economy": 149 }, "source": "https://en.wikipedia.org/wiki/United_Airlines_fleet" },
"AS": { "seats": 300, "cabins": { "first": 0, "business": 34, "premiumEconomy": 79, "economy": 187 }, "source": "https://en.wikipedia.org/wiki/Alaska_Airlines_fleet" }
}
},
"78J": {
"default": { "name": "B787-10", "seats": 318, "body": "W" },
"byCarrier": {
"UA": { "seats": 318, "cabins": { "first": 0, "business": 56, "premiumEconomy": 75, "economy": 187 }, "source": "https://en.wikipedia.org/wiki/United_Airlines_fleet" }
}
},
"781": {
"default": { "name": "B787-9 Long-haul", "seats": 257, "body": "W" },
"byCarrier": {
"UA": { "seats": 257, "cabins": { "first": 0, "business": 48, "premiumEconomy": 60, "economy": 149 }, "source": "https://en.wikipedia.org/wiki/United_Airlines_fleet" }
}
},
"717": {
"default": { "name": "B717-200", "seats": 110, "body": "N" },
"byCarrier": {
"DL": { "seats": 110, "cabins": { "first": 12, "business": 0, "premiumEconomy": 20, "economy": 78 }, "source": "https://en.wikipedia.org/wiki/Delta_Air_Lines_fleet" },
"HA": { "seats": 128, "cabins": { "first": 8, "business": 0, "premiumEconomy": 0, "economy": 120 }, "source": "https://en.wikipedia.org/wiki/Hawaiian_Airlines_fleet" }
}
},
"332": {
"default": { "name": "A330-200", "seats": 250, "body": "W" },
"byCarrier": {
"DL": { "seats": 223, "cabins": { "first": 0, "business": 34, "premiumEconomy": 45, "economy": 144 }, "source": "https://en.wikipedia.org/wiki/Delta_Air_Lines_fleet" },
"HA": { "seats": 278, "cabins": { "first": 0, "business": 18, "premiumEconomy": 68, "economy": 192 }, "source": "https://en.wikipedia.org/wiki/Hawaiian_Airlines_fleet" }
}
},
"333": {
"default": { "name": "A330-300", "seats": 282, "body": "W" },
"byCarrier": {
"DL": { "seats": 282, "cabins": { "first": 0, "business": 34, "premiumEconomy": 45, "economy": 203 }, "source": "https://en.wikipedia.org/wiki/Delta_Air_Lines_fleet" }
}
},
"339": {
"default": { "name": "A330-900neo", "seats": 281, "body": "W" },
"byCarrier": {
"DL": { "seats": 281, "cabins": { "first": 0, "business": 29, "premiumEconomy": 84, "economy": 168 }, "source": "https://en.wikipedia.org/wiki/Delta_Air_Lines_fleet" }
}
},
"359": {
"default": { "name": "A350-900", "seats": 275, "body": "W" },
"byCarrier": {
"DL": { "seats": 275, "cabins": { "first": 0, "business": 40, "premiumEconomy": 76, "economy": 159 }, "source": "https://en.wikipedia.org/wiki/Delta_Air_Lines_fleet" }
}
},
"E70": { "default": { "name": "Embraer 170", "seats": 76, "body": "N" }, "byCarrier": {} },
"E75": {
"default": { "name": "Embraer 175", "seats": 76, "body": "N" },
"byCarrier": {
"AS": { "seats": 76, "cabins": { "first": 12, "business": 0, "premiumEconomy": 16, "economy": 48 }, "source": "https://en.wikipedia.org/wiki/Alaska_Airlines_fleet" },
"AA": { "seats": 76, "cabins": { "first": 12, "business": 0, "premiumEconomy": 20, "economy": 44 }, "source": "https://en.wikipedia.org/wiki/American_Airlines_fleet" },
"DL": { "seats": 76, "cabins": { "first": 12, "business": 0, "premiumEconomy": 20, "economy": 44 }, "source": "https://en.wikipedia.org/wiki/Delta_Air_Lines_fleet" },
"UA": { "seats": 76, "cabins": { "first": 12, "business": 0, "premiumEconomy": 16, "economy": 48 }, "source": "https://en.wikipedia.org/wiki/United_Airlines_fleet" }
}
},
"E90": { "default": { "name": "Embraer 190", "seats": 100, "body": "N" }, "byCarrier": {} },
"E95": { "default": { "name": "Embraer 195", "seats": 116, "body": "N" }, "byCarrier": {} },
"CR7": {
"default": { "name": "CRJ-700", "seats": 70, "body": "N" },
"byCarrier": {
"AA": { "seats": 65, "cabins": { "first": 9, "business": 0, "premiumEconomy": 16, "economy": 40 }, "source": "https://en.wikipedia.org/wiki/American_Airlines_fleet" },
"DL": { "seats": 65, "cabins": { "first": 9, "business": 0, "premiumEconomy": 20, "economy": 36 }, "source": "https://en.wikipedia.org/wiki/Delta_Air_Lines_fleet" },
"UA": { "seats": 70, "cabins": { "first": 6, "business": 0, "premiumEconomy": 16, "economy": 48 }, "source": "https://en.wikipedia.org/wiki/United_Airlines_fleet" }
}
},
"CR9": {
"default": { "name": "CRJ-900", "seats": 76, "body": "N" },
"byCarrier": {
"AA": { "seats": 76, "cabins": { "first": 12, "business": 0, "premiumEconomy": 20, "economy": 44 }, "source": "https://en.wikipedia.org/wiki/American_Airlines_fleet" },
"DL": { "seats": 76, "cabins": { "first": 12, "business": 0, "premiumEconomy": 20, "economy": 44 }, "source": "https://en.wikipedia.org/wiki/Delta_Air_Lines_fleet" }
}
},
"CR5": {
"default": { "name": "CRJ-550", "seats": 50, "body": "N" },
"byCarrier": {
"UA": { "seats": 50, "cabins": { "first": 10, "business": 0, "premiumEconomy": 20, "economy": 20 }, "source": "https://en.wikipedia.org/wiki/United_Airlines_fleet" }
}
},
"CRJ": { "default": { "name": "CRJ-200", "seats": 50, "body": "N" }, "byCarrier": {} },
"DH4": { "default": { "name": "Dash-8 Q400", "seats": 78, "body": "N" }, "byCarrier": {} },
"AT7": { "default": { "name": "ATR-72", "seats": 70, "body": "N" }, "byCarrier": {} },
"MD8": { "default": { "name": "MD-80", "seats": 140, "body": "N" }, "byCarrier": {} }
},
"icao": {
"B738": "73H",
"B737": "73G",
"B739": "739",
"B38M": "7M8",
"B39M": "7M9",
"B3XM": "7MA",
"B712": "717",
"A319": "319",
"A320": "320",
"A20N": "32N",
"A321": "321",
"A21N": "21N",
"A21X": "21X",
"BCS1": "221",
"BCS3": "223",
"A221": "221",
"A223": "223",
"B752": "752",
"B753": "753",
"B763": "763",
"B764": "764",
"B77W": "77W",
"B772": "772",
"B773": "773",
"B788": "788",
"B789": "789",
"B78J": "78J",
"B78X": "78J",
"A332": "332",
"A333": "333",
"A339": "339",
"A359": "359",
"E170": "E70",
"E175": "E75",
"E190": "E90",
"E195": "E95",
"CRJ7": "CR7",
"CRJ9": "CR9",
"CRJ5": "CR5",
"CRJ2": "CRJ",
"DH8D": "DH4",
"AT76": "AT7"
}
}
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"carriers": [
"AA",
"AS",
"B6",
"DL",
"F9",
"NK",
"UA",
"WN"
],
"downloadedAt": "2026-06-01T01:34:32Z",
"minFlightsFilter": 20,
"notes": "OnTime: 'on time' = arrival delay <= 15 min (BTS standard). avgDelayMin = mean of positive-delay arrivals only. Cancellation rate = cancelled / scheduled. T-100: avgLoadFactor = sum(PASSENGERS)/sum(SEATS), avgSeats = sum(SEATS)/sum(DEPARTURES_PERFORMED). Rows with fewer than 20 operated flights dropped.",
"recordCount": 8047,
"schemaVersion": 2,
"sourcePeriod": "2026-02",
"sourceURLs": [
"https://transtats.bts.gov/PREZIP/On_Time_Reporting_Carrier_On_Time_Performance_1987_present_2026_2.zip",
"https://transtats.bts.gov/DL_SelectFields.aspx?gnoyr_VQ=FIM&QO_fu146_anzr=Nv4%20Pn44vr45 [POST with cboYear=2026, cboPeriod=2]"
]
}
+300
View File
@@ -0,0 +1,300 @@
import Foundation
/// Look up the ICAO aircraft type designator (e.g. "B738", "A21N") for a
/// given 24-bit ICAO transponder address.
///
/// Backed by `aircraftDB.json` slimmed copy of OpenSky's aircraft
/// metadata, ~100k commercial-class entries, ~1.5MB on disk. Loads on
/// first access and stays in memory for the rest of the session.
///
/// Used to power the "Aircraft type" filter on the live map. The ADS-B
/// emitter category that OpenSky's anonymous `/states/all` returns is
/// almost always null, so the type designator from the DB is what we
/// surface to the user.
final class AircraftDatabase: @unchecked Sendable {
static let shared = AircraftDatabase()
/// Storage. Empty until `preload()` has filled it. Reads before preload
/// return nil the type-code filter will simply look empty until the
/// DB has loaded, instead of blocking the main thread for ~100-200ms
/// while parsing 1.5MB of JSON.
private let lock = NSLock()
private var byICAO24: [String: String] = [:]
private var isLoaded: Bool = false
private init() {}
/// Kick off the JSON parse on a background thread. Safe to call
/// multiple times subsequent calls are no-ops. Call this once at
/// app launch (FlightsApp.init) so the data is ready by the time
/// the user opens the Live tab.
func preload() {
lock.lock()
if isLoaded {
lock.unlock()
return
}
isLoaded = true
lock.unlock()
Task.detached(priority: .utility) { [weak self] in
guard let url = Bundle.main.url(forResource: "aircraftDB", withExtension: "json"),
let data = try? Data(contentsOf: url),
let decoded = try? JSONDecoder().decode([String: String].self, from: data)
else { return }
self?.lock.withLock {
self?.byICAO24 = decoded
}
}
}
/// Returns the ICAO aircraft type designator for the given 24-bit
/// ICAO transponder address, or nil if the airframe isn't in the DB
/// (or the DB hasn't finished loading yet).
func typeCode(forICAO24 icao24: String) -> String? {
let key = icao24.lowercased()
lock.lock()
defer { lock.unlock() }
return byICAO24[key]
}
/// Friendly display name for an ICAO type designator, e.g.
/// "B738" "Boeing 737-800". Falls back to the raw code when not in
/// the table.
func displayName(forTypeCode code: String) -> String {
Self.typeNames[code.uppercased()] ?? code
}
/// Normalize either an IATA aircraft code (e.g. "73H") or an ICAO
/// type designator (e.g. "B738") to the ICAO form the rest of the
/// app expects. Schedule feeds (route-explorer) hand out IATA;
/// FR24's live feed and FlightAware both hand out ICAO. We want
/// one canonical form on disk.
func normalizedICAO(forCode code: String) -> String {
let upper = code.uppercased()
if Self.typeNames[upper] != nil { return upper } // already ICAO
return Self.iataToICAO[upper] ?? upper
}
/// Common IATA ICAO mappings for aircraft codes we'll actually
/// see from schedule data. Not exhaustive covers the bulk of
/// commercial fleet types. Anything missing falls through as-is.
private static let iataToICAO: [String: String] = [
// Airbus narrowbody
"319": "A319", "31N": "A19N",
"320": "A320", "32A": "A320", "32B": "A320", "32N": "A20N", "32S": "A320",
"321": "A321", "32Q": "A21N",
"318": "A318",
// Airbus widebody
"330": "A332", "332": "A332", "333": "A333", "338": "A338", "339": "A339",
"340": "A343", "343": "A343", "346": "A346",
"350": "A359", "359": "A359", "35K": "A35K", "351": "A359", "358": "A359",
"380": "A388", "388": "A388",
// A220
"221": "BCS1", "223": "BCS3",
// Boeing 737 family
"73G": "B737", "73R": "B737",
"73H": "B738", "73W": "B738", "738": "B738",
"73J": "B739", "739": "B739", "73Y": "B739",
"732": "B732", "733": "B733", "734": "B734", "735": "B735", "736": "B736",
"7M7": "B37M", "7M8": "B38M", "7M9": "B39M", "7MJ": "B3XM",
// Boeing 747/767/777/787
"744": "B744", "748": "B748",
"762": "B762", "763": "B763", "764": "B764",
"772": "B772", "773": "B773", "77L": "B77L", "77W": "B77W", "77F": "B77F",
"778": "B778", "779": "B779",
"788": "B788", "789": "B789", "78X": "B78X", "78J": "B78X",
// 757
"752": "B752", "753": "B753",
// Embraer regional
"E70": "E170", "E75": "E175", "E7W": "E175",
"E90": "E190", "E95": "E195", "295": "E295",
// Bombardier / CRJ
"CR2": "CRJ2", "CR7": "CRJ7", "CR9": "CRJ9",
// Dash 8
"DH4": "DH8D", "DH3": "DH8C",
// ATR
"AT5": "AT45", "AT7": "AT72", "ATR": "AT72",
// MD-80 family
"M80": "MD80", "M81": "MD81", "M82": "MD82", "M83": "MD83", "M87": "MD87", "M88": "MD88", "M90": "MD90",
]
/// Friendly names for the ~150 most common commercial type designators
/// we'd see on the map. Anything else displays as the raw 34 letter
/// code (still useful for filtering). This is by ICAO Doc 8643.
private static let typeNames: [String: String] = [
// Airbus narrowbody
"A318": "Airbus A318",
"A319": "Airbus A319",
"A320": "Airbus A320",
"A321": "Airbus A321",
"A19N": "Airbus A319neo",
"A20N": "Airbus A320neo",
"A21N": "Airbus A321neo",
// Airbus widebody
"A30B": "Airbus A300",
"A306": "Airbus A300-600",
"A310": "Airbus A310",
"A332": "Airbus A330-200",
"A333": "Airbus A330-300",
"A337": "Airbus A330-700 BelugaXL",
"A338": "Airbus A330-800neo",
"A339": "Airbus A330-900neo",
"A342": "Airbus A340-200",
"A343": "Airbus A340-300",
"A345": "Airbus A340-500",
"A346": "Airbus A340-600",
"A359": "Airbus A350-900",
"A35K": "Airbus A350-1000",
"A388": "Airbus A380",
// A220 / CSeries
"BCS1": "Airbus A220-100",
"BCS3": "Airbus A220-300",
// Boeing narrowbody
"B712": "Boeing 717",
"B721": "Boeing 727",
"B722": "Boeing 727-200",
"B731": "Boeing 737-100",
"B732": "Boeing 737-200",
"B733": "Boeing 737-300",
"B734": "Boeing 737-400",
"B735": "Boeing 737-500",
"B736": "Boeing 737-600",
"B737": "Boeing 737-700",
"B738": "Boeing 737-800",
"B739": "Boeing 737-900",
"B37M": "Boeing 737 MAX 7",
"B38M": "Boeing 737 MAX 8",
"B39M": "Boeing 737 MAX 9",
"B3XM": "Boeing 737 MAX 10",
"B752": "Boeing 757-200",
"B753": "Boeing 757-300",
// Boeing widebody
"B741": "Boeing 747-100",
"B742": "Boeing 747-200",
"B743": "Boeing 747-300",
"B744": "Boeing 747-400",
"B748": "Boeing 747-8",
"B74F": "Boeing 747 Freighter",
"B762": "Boeing 767-200",
"B763": "Boeing 767-300",
"B764": "Boeing 767-400",
"B772": "Boeing 777-200",
"B77L": "Boeing 777-200LR",
"B773": "Boeing 777-300",
"B77W": "Boeing 777-300ER",
"B77F": "Boeing 777F",
"B778": "Boeing 777-8",
"B779": "Boeing 777-9",
"B788": "Boeing 787-8",
"B789": "Boeing 787-9",
"B78X": "Boeing 787-10",
// Embraer regional
"E135": "Embraer ERJ-135",
"E140": "Embraer ERJ-140",
"E145": "Embraer ERJ-145",
"E170": "Embraer E170",
"E175": "Embraer E175",
"E190": "Embraer E190",
"E195": "Embraer E195",
"E290": "Embraer E190-E2",
"E295": "Embraer E195-E2",
// Bombardier / Mitsubishi regional
"CRJ1": "CRJ-100",
"CRJ2": "CRJ-200",
"CRJ7": "CRJ-700",
"CRJ9": "CRJ-900",
"CRJX": "CRJ-1000",
"MRJ": "Mitsubishi SpaceJet",
// De Havilland / Dash
"DH8A": "Dash 8-100",
"DH8B": "Dash 8-200",
"DH8C": "Dash 8-300",
"DH8D": "Dash 8 Q400",
// ATR
"AT43": "ATR 42-300",
"AT45": "ATR 42-500",
"AT46": "ATR 42-600",
"AT72": "ATR 72-200",
"AT75": "ATR 72-500",
"AT76": "ATR 72-600",
// Business jets
"BE20": "Beechcraft King Air 200",
"BE40": "Beechjet 400",
"BE9L": "King Air 90",
"B190": "Beechcraft 1900",
"B350": "King Air 350",
"CL30": "Bombardier Challenger 300",
"CL60": "Bombardier Challenger 600",
"CL65": "Bombardier Challenger 650",
"GLEX": "Bombardier Global Express",
"GL5T": "Bombardier Global 5000",
"GLF4": "Gulfstream IV",
"GLF5": "Gulfstream V",
"GLF6": "Gulfstream G650",
"G280": "Gulfstream G280",
"FA10": "Dassault Falcon 10",
"FA20": "Dassault Falcon 20",
"FA50": "Dassault Falcon 50",
"FA7X": "Dassault Falcon 7X",
"FA8X": "Dassault Falcon 8X",
"C25A": "Cessna Citation CJ2",
"C25B": "Cessna Citation CJ3",
"C25C": "Cessna Citation CJ4",
"C56X": "Cessna Citation Excel",
"C680": "Cessna Citation Sovereign",
"C68A": "Cessna Citation Latitude",
"C700": "Cessna Citation Longitude",
"PC12": "Pilatus PC-12",
"PC24": "Pilatus PC-24",
"PRM1": "Hawker Premier",
// Helicopters
"AS32": "Eurocopter Super Puma",
"AS50": "Eurocopter Squirrel",
"AS65": "Eurocopter Dauphin",
"EC20": "Eurocopter EC120",
"EC25": "Eurocopter EC225",
"EC30": "Eurocopter EC130",
"EC35": "Eurocopter EC135",
"EC45": "Eurocopter EC145",
"EC55": "Eurocopter EC155",
"EC75": "Eurocopter EC175",
"H125": "Airbus H125",
"H135": "Airbus H135",
"H145": "Airbus H145",
"H160": "Airbus H160",
"H225": "Airbus H225",
"B06": "Bell 206",
"B407": "Bell 407",
"B412": "Bell 412",
"B429": "Bell 429",
"S70": "Sikorsky S-70",
"S76": "Sikorsky S-76",
"S92": "Sikorsky S-92",
// Misc / cargo classic
"MD80": "MD-80",
"MD81": "MD-81",
"MD82": "MD-82",
"MD83": "MD-83",
"MD87": "MD-87",
"MD88": "MD-88",
"MD90": "MD-90",
"MD11": "MD-11",
"DC10": "DC-10",
"DC9": "DC-9",
"B717": "Boeing 717",
"F70": "Fokker 70",
"F100": "Fokker 100"
]
}
+117
View File
@@ -0,0 +1,117 @@
import Foundation
/// Aircraft photo lookup, backed by planespotters.net's public API.
///
/// Two lookup paths, tried in order:
/// 1. By registration / tail number (e.g. "N971NN") preferred, since
/// FR24's feed gives us this inline.
/// 2. By 24-bit ICAO transponder hex (e.g. "ad8895") fallback when
/// we don't have a registration (most GA / military aircraft).
///
/// Planespotters serves the most recent photo of that airframe which
/// naturally surfaces special liveries, since photographers prioritize
/// catching one-off paint schemes the moment they appear.
///
/// Their TOS requires:
/// - A User-Agent with a contact URL.
/// - Photographer attribution wherever the photo is shown.
/// Both are honored here.
actor AircraftPhotoService {
static let shared = AircraftPhotoService()
struct Photo: Hashable, Sendable {
let thumbnailURL: URL // ~200×112
let largeURL: URL // ~497×280
let detailLink: URL? // planespotters page (attribution requires linking)
let photographer: String?
}
private var cache: [String: Photo?] = [:]
private let session: URLSession
private init(session: URLSession = .shared) {
self.session = session
}
/// Returns the most recent photo for an airframe, or nil if none.
/// Results (hits AND misses) are cached for the lifetime of the app
/// so we never re-hit planespotters for the same airframe twice.
func photo(registration: String?, icao24: String) async -> Photo? {
let key = (registration?.uppercased())
?? icao24.uppercased()
if let cached = cache[key] { return cached }
// 1) Try registration. Planespotters indexes by tail number and
// most operators are accurately mapped.
if let reg = registration?.uppercased(), !reg.isEmpty {
if let p = await fetch(path: "reg/\(reg)") {
cache[key] = p
return p
}
}
// 2) Fall back to ICAO24 hex slower index but catches some
// airframes that don't have a current registration on file.
let hexKey = icao24.uppercased()
if let p = await fetch(path: "hex/\(hexKey)") {
cache[key] = p
return p
}
// Cache the miss so we don't re-query.
cache[key] = .some(nil)
return nil
}
private func fetch(path: String) async -> Photo? {
guard let url = URL(string: "https://api.planespotters.net/pub/photos/\(path)") else {
return nil
}
var req = URLRequest(url: url)
req.timeoutInterval = 8
// Planespotters' TOS requires a contact URL in the User-Agent
// string. Without it the API returns an error blob.
req.setValue(
"Flights/1.0 (+https://github.com/admin/Flights)",
forHTTPHeaderField: "User-Agent"
)
req.setValue("application/json", forHTTPHeaderField: "Accept")
do {
let (data, resp) = try await session.data(for: req)
guard let http = resp as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
return nil
}
guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let photos = root["photos"] as? [[String: Any]],
let first = photos.first
else { return nil }
return Self.parse(first)
} catch {
return nil
}
}
private static func parse(_ row: [String: Any]) -> Photo? {
guard let thumbObj = row["thumbnail_large"] as? [String: Any] ?? row["thumbnail"] as? [String: Any],
let thumbSrc = thumbObj["src"] as? String,
let thumbURL = URL(string: thumbSrc)
else { return nil }
let large: URL = {
if let lg = row["thumbnail_large"] as? [String: Any],
let s = lg["src"] as? String,
let u = URL(string: s) { return u }
return thumbURL
}()
let link = (row["link"] as? String).flatMap(URL.init(string:))
let photographer = (row["photographer"] as? String)
.flatMap { $0.isEmpty ? nil : $0 }
return Photo(
thumbnailURL: thumbURL,
largeURL: large,
detailLink: link,
photographer: photographer
)
}
}
+128
View File
@@ -0,0 +1,128 @@
import Foundation
/// Look up airline display info by ICAO callsign prefix.
///
/// Backed by `airlines.json` (bundled, ~2,700 entries sourced from
/// flightradar24.com's public `/mobile/airlines` feed) and falls back to
/// a small hardcoded map of the most common carriers if the bundle is
/// missing for any reason.
///
/// Lookup is by ICAO 3-letter code (e.g. "DAL" Delta). IATA-only
/// lookups also work (e.g. "DL"). Anything that doesn't match either
/// returns the raw code as the name so the UI never blanks out.
final class AircraftRegistry: @unchecked Sendable {
static let shared = AircraftRegistry()
struct Entry: Sendable {
let icao: String? // 3-letter ICAO
let iata: String? // 2-3 char IATA
let name: String // display name
let logoURL: URL? // FR24 CDN URL, may be nil
}
private let lock = NSLock()
private var byICAO: [String: Entry] = [:]
private var byIATA: [String: Entry] = [:]
private var isLoaded = false
private init() {
// Bootstrap with the hardcoded fallback so reads never come back
// empty even before preload finishes.
var icao: [String: Entry] = [:]
var iata: [String: Entry] = [:]
for (code, info) in Self.builtIn {
let e = Entry(icao: code, iata: info.0, name: info.1, logoURL: nil)
icao[code] = e
iata[info.0] = e
}
byICAO = icao
byIATA = iata
}
/// Kick off the airlines JSON parse on a background thread. Safe to
/// call multiple times. Call at app launch so we never hit the parse
/// cost when the user opens the Live tab.
func preload() {
lock.lock()
if isLoaded {
lock.unlock()
return
}
isLoaded = true
lock.unlock()
Task.detached(priority: .utility) { [weak self] in
guard let url = Bundle.main.url(forResource: "airlines", withExtension: "json"),
let data = try? Data(contentsOf: url),
let raw = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]]
else { return }
var icaoMap: [String: Entry] = [:]
var iataMap: [String: Entry] = [:]
for row in raw {
let icao = (row["i"] as? String).map { $0.isEmpty ? nil : $0 } ?? nil
let iata = (row["a"] as? String).map { $0.isEmpty ? nil : $0 } ?? nil
let name = row["n"] as? String ?? ""
let logo = (row["l"] as? String).flatMap(URL.init(string:))
guard !name.isEmpty else { continue }
let entry = Entry(icao: icao, iata: iata, name: name, logoURL: logo)
if let icao { icaoMap[icao.uppercased()] = entry }
if let iata { iataMap[iata.uppercased()] = entry }
}
self?.lock.withLock {
self?.byICAO = icaoMap
self?.byIATA = iataMap
}
}
}
/// Look up by ICAO 3-letter prefix (e.g. "DAL").
func lookup(icao: String?) -> Entry? {
guard let icao = icao?.uppercased(), !icao.isEmpty else { return nil }
return byICAO[icao]
}
/// Look up by IATA 2-3 char code (e.g. "DL").
func lookup(iata: String?) -> Entry? {
guard let iata = iata?.uppercased(), !iata.isEmpty else { return nil }
return byIATA[iata]
}
/// Convenience that returns a non-nil display name, falling back to
/// the raw ICAO code or "Unknown".
func displayName(icao: String?) -> String {
if let e = lookup(icao: icao) { return e.name }
return icao?.uppercased() ?? "Unknown"
}
/// Static fallback used when the bundled JSON isn't available.
private static let builtIn: [String: (String, String)] = [
"AAL": ("AA", "American Airlines"),
"DAL": ("DL", "Delta Air Lines"),
"UAL": ("UA", "United Airlines"),
"SWA": ("WN", "Southwest Airlines"),
"ASA": ("AS", "Alaska Airlines"),
"JBU": ("B6", "JetBlue"),
"SCX": ("SY", "Sun Country Airlines"),
"HAL": ("HA", "Hawaiian Airlines"),
"FFT": ("F9", "Frontier Airlines"),
"AAY": ("G4", "Allegiant Air"),
"AMX": ("AM", "Aeromexico"),
"ACA": ("AC", "Air Canada"),
"WJA": ("WS", "WestJet"),
"UAE": ("EK", "Emirates"),
"QTR": ("QR", "Qatar Airways"),
"BAW": ("BA", "British Airways"),
"DLH": ("LH", "Lufthansa"),
"AFR": ("AF", "Air France"),
"KLM": ("KL", "KLM"),
"JAL": ("JL", "Japan Airlines"),
"ANA": ("NH", "All Nippon Airways"),
"KAL": ("KE", "Korean Air"),
"CPA": ("CX", "Cathay Pacific"),
"SIA": ("SQ", "Singapore Airlines"),
"QFA": ("QF", "Qantas"),
"FDX": ("FX", "FedEx"),
"UPS": ("5X", "UPS Airlines")
]
}
@@ -0,0 +1,192 @@
import Foundation
import CoreLocation
/// Reconstructs an aircraft's recent rotation (sequence of flights) from
/// OpenSky data so we can reason about how upstream delays will cascade
/// into a downstream segment.
///
/// We prefer OpenSky's `/flights/aircraft` history endpoint it already
/// segments by takeoff/landing and tags each leg with the operating
/// airport ICAO. When that endpoint returns nothing usable (common for
/// recent activity inside the last hour or two), we fall back to the
/// `/tracks/all` path and synthesize segments by walking the
/// `onGround` flag in the track points.
actor AircraftRotationTracker {
/// Shared instance so per-tap detail sheets reuse the same OpenSky
/// client (which has its own rate-limit accounting) and the actor's
/// own cache instead of paying for a fresh AirportDatabase load on
/// every aircraft tap.
static let shared = AircraftRotationTracker()
struct RotationSegment: Sendable, Identifiable {
let id: String
let departureICAO: String?
let arrivalICAO: String?
let departureTime: Date
let arrivalTime: Date
let estimatedDelayMin: Int?
}
private let client: OpenSkyClient
private let airports: AirportDatabase
init(client: OpenSkyClient = OpenSkyClient(),
airports: AirportDatabase = AirportDatabase()) {
self.client = client
self.airports = airports
}
/// Returns the aircraft's recent flight segments, ordered oldest
/// newest. Empty if OpenSky has no usable data for the lookback
/// window.
func rotation(forICAO24 icao24: String, lookbackHours: Int = 18) async -> [RotationSegment] {
let trimmed = icao24.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !trimmed.isEmpty else {
print("[RotationTracker] empty icao24")
return []
}
let now = Date()
let cutoff = now.addingTimeInterval(-Double(lookbackHours) * 3600)
// Strategy 1: OpenSky's flights/aircraft endpoint. It needs a day
// window request enough days to cover lookbackHours. The
// endpoint caps each call at 30 days; we never need more than 2.
let daysBack = max(1, Int(ceil(Double(lookbackHours) / 24.0)))
let flights = await client.recentFlights(icao24: trimmed, daysBack: daysBack)
let usable = flights
.filter { $0.arrivalDate >= cutoff }
.sorted { $0.firstSeen < $1.firstSeen }
if !usable.isEmpty {
print("[RotationTracker] icao24=\(trimmed) lookback=\(lookbackHours)h → \(usable.count) flight(s) from recentFlights")
return usable.map { Self.segment(from: $0) }
}
// Strategy 2: fall back to the live track and walk the
// onGround flag. This catches very-recent activity that
// hasn't yet been written to OpenSky's flights index.
if let track = await client.track(icao24: trimmed) {
let synthesized = Self.segments(from: track, airports: airports, since: cutoff)
print("[RotationTracker] icao24=\(trimmed) lookback=\(lookbackHours)h → \(synthesized.count) synthesized segment(s) from track")
return synthesized
}
print("[RotationTracker] icao24=\(trimmed) lookback=\(lookbackHours)h → no data")
return []
}
// MARK: - Helpers
private static func segment(from flight: OpenSkyFlight) -> RotationSegment {
// OpenSky doesn't supply a scheduled time, so we leave estimated
// delay nil here; the cascade predictor compares actual arrival
// against the next leg's scheduled departure instead.
let dep = flight.estDepartureAirport?.uppercased()
let arr = flight.estArrivalAirport?.uppercased()
let id = "\(flight.icao24)-\(flight.firstSeen)"
return RotationSegment(
id: id,
departureICAO: (dep?.isEmpty == false) ? dep : nil,
arrivalICAO: (arr?.isEmpty == false) ? arr : nil,
departureTime: flight.departureDate,
arrivalTime: flight.arrivalDate,
estimatedDelayMin: nil
)
}
/// Walks the track's path entries and groups contiguous airborne
/// runs into segments. A segment is bounded by:
/// - takeoff: transition from onGround=true onGround=false
/// - landing: transition from onGround=false onGround=true
/// The endpoints' lat/lon are mapped to the nearest airport (within
/// a generous radius taxiways can be a few miles from the field
/// center) for the ICAO field; we only have IATA in the bundled DB,
/// so the stored string is the IATA code when sourced from track.
private static func segments(from track: AircraftTrack,
airports: AirportDatabase,
since cutoff: Date) -> [RotationSegment] {
guard !track.path.isEmpty else { return [] }
// Path entries are time-ordered ascending per OpenSky's contract.
let path = track.path
var segments: [RotationSegment] = []
var airborneStart: AircraftTrack.TrackPoint?
var lastAirborne: AircraftTrack.TrackPoint?
// Track the ground point immediately preceding the current
// airborne run so we can read the departure fix from it (more
// accurate than the first airborne sample, which is already
// a few seconds airborne).
var lastGround: AircraftTrack.TrackPoint?
for point in path {
if point.onGround {
if let start = airborneStart, let end = lastAirborne ?? lastGround {
// We just landed; close the segment.
let depPoint = lastGround ?? start
let seg = makeSegment(
icao24: track.icao24,
depPoint: depPoint,
arrPoint: point,
airborneStart: start,
airborneEnd: end,
airports: airports
)
if seg.arrivalTime >= cutoff {
segments.append(seg)
}
airborneStart = nil
lastAirborne = nil
}
lastGround = point
} else {
if airborneStart == nil {
airborneStart = point
}
lastAirborne = point
}
}
// If the aircraft is still airborne at the end of the track,
// emit a partial segment so callers can see where it's coming
// from. arrivalTime is the last position fix.
if let start = airborneStart, let end = lastAirborne {
let depPoint = lastGround ?? start
let seg = makeSegment(
icao24: track.icao24,
depPoint: depPoint,
arrPoint: end,
airborneStart: start,
airborneEnd: end,
airports: airports
)
if seg.arrivalTime >= cutoff {
segments.append(seg)
}
}
return segments
}
private static func makeSegment(icao24: String,
depPoint: AircraftTrack.TrackPoint,
arrPoint: AircraftTrack.TrackPoint,
airborneStart: AircraftTrack.TrackPoint,
airborneEnd: AircraftTrack.TrackPoint,
airports: AirportDatabase) -> RotationSegment {
let depCoord = CLLocationCoordinate2D(latitude: depPoint.latitude, longitude: depPoint.longitude)
let arrCoord = CLLocationCoordinate2D(latitude: arrPoint.latitude, longitude: arrPoint.longitude)
let depAirport = airports.nearestAirport(to: depCoord, maxMiles: 10)
let arrAirport = airports.nearestAirport(to: arrCoord, maxMiles: 10)
return RotationSegment(
id: "\(icao24)-\(airborneStart.time)",
departureICAO: depAirport?.iata,
arrivalICAO: arrAirport?.iata,
departureTime: Date(timeIntervalSince1970: TimeInterval(airborneStart.time)),
arrivalTime: Date(timeIntervalSince1970: TimeInterval(airborneEnd.time)),
estimatedDelayMin: nil
)
}
}
+137
View File
@@ -0,0 +1,137 @@
import Foundation
import SwiftData
/// Aggregates the user's personal flight history on a specific tail
/// number. Given a registration like "N281WN", returns how many times
/// the user has flown that airframe, the routes flown on it, the
/// first/last time it appeared in history, and the most common route.
///
/// This is read-only and stateless the store doesn't cache; every
/// call fires a fresh FetchDescriptor against ModelContext. Cheap
/// because the predicate hits the registration field directly and
/// most users will have a handful of flights per tail at most.
///
/// @MainActor because ModelContext is main-thread-only in SwiftData.
@MainActor
final class AirframeHistoryStore {
// MARK: - Public types
struct AirframeStats: Sendable {
let totalFlights: Int
/// Unique routes flown on this airframe, formatted "DALHOU".
let routes: [String]
let firstSeen: Date?
let lastSeen: Date?
/// The route the user has flown most often on this airframe,
/// formatted "DALHOU (5 of 7)". Nil when no flights exist.
let mostCommonRoute: String?
static let empty = AirframeStats(
totalFlights: 0,
routes: [],
firstSeen: nil,
lastSeen: nil,
mostCommonRoute: nil
)
}
init() {}
// MARK: - Lookup
/// Returns aggregated stats for the given tail in the user's
/// LoggedFlight history. Tail matching is case-insensitive we
/// normalize to uppercase before comparing.
func stats(forTail registration: String, context: ModelContext) -> AirframeStats {
let normalizedTail = registration
.trimmingCharacters(in: .whitespacesAndNewlines)
.uppercased()
guard !normalizedTail.isEmpty else {
print("[AirframeHistory] empty tail, returning empty stats")
return .empty
}
// SwiftData #Predicate can't call uppercased(), so we fetch by
// exact case first, then fall back to a broader scan if empty.
// In practice records are stored uppercased (importers normalize),
// so the fast path hits.
let predicate = #Predicate<LoggedFlight> { flight in
flight.registration == normalizedTail
}
let descriptor = FetchDescriptor<LoggedFlight>(predicate: predicate)
var matches: [LoggedFlight] = (try? context.fetch(descriptor)) ?? []
if matches.isEmpty {
// Fallback: scan all flights and compare uppercased. Slow
// path, but covers legacy records that weren't normalized.
let allDescriptor = FetchDescriptor<LoggedFlight>()
let all = (try? context.fetch(allDescriptor)) ?? []
matches = all.filter { flight in
guard let reg = flight.registration else { return false }
return reg.uppercased() == normalizedTail
}
}
guard !matches.isEmpty else {
print("[AirframeHistory] no flights found for \(normalizedTail)")
return .empty
}
// Aggregate.
let total = matches.count
// Route strings in encounter order, deduped while preserving order.
var seen = Set<String>()
var orderedRoutes: [String] = []
var routeCounts: [String: Int] = [:]
for flight in matches {
let route = Self.formatRoute(
departure: flight.departureIATA,
arrival: flight.arrivalIATA
)
routeCounts[route, default: 0] += 1
if seen.insert(route).inserted {
orderedRoutes.append(route)
}
}
let dates = matches.map { $0.flightDate }
let firstSeen = dates.min()
let lastSeen = dates.max()
// Most common route break ties by alphabetical route for
// deterministic output.
let mostCommonRoute: String? = {
guard let top = routeCounts
.max(by: { lhs, rhs in
if lhs.value != rhs.value { return lhs.value < rhs.value }
return lhs.key > rhs.key
})
else { return nil }
return "\(top.key) (\(top.value) of \(total))"
}()
print("[AirframeHistory] \(normalizedTail): \(total) flights across \(orderedRoutes.count) routes")
return AirframeStats(
totalFlights: total,
routes: orderedRoutes,
firstSeen: firstSeen,
lastSeen: lastSeen,
mostCommonRoute: mostCommonRoute
)
}
// MARK: - Formatting
/// "DALHOU" style route string. Falls back to "?" when an
/// endpoint is missing so we never produce "HOU" or "DAL".
private static func formatRoute(departure: String, arrival: String) -> String {
let dep = departure.isEmpty ? "?" : departure.uppercased()
let arr = arrival.isEmpty ? "?" : arrival.uppercased()
return "\(dep)\(arr)"
}
}
@@ -0,0 +1,84 @@
import Foundation
/// Pulls airframe metadata (manufacturer build date, first-flight date)
/// from OpenSky's `/api/metadata/aircraft/icao/{icao24}` endpoint and
/// caches the result in `AirframeMetadata`. Cleaner than scraping
/// jetphotos / planespotters airframe pages both of those sit behind
/// Cloudflare's bot gate and aren't reliably fetchable from a mobile
/// client.
///
/// Caveat: OpenSky's metadata is community-contributed and often null
/// for newer airframes. We degrade gracefully no date means we just
/// don't show an age in the detail view.
actor AirframeMetadataService {
static let shared = AirframeMetadataService()
struct Metadata: Hashable, Sendable {
let registration: String
let built: Date?
let firstFlightDate: Date?
}
private let session: URLSession
private var inflight: [String: Task<Metadata?, Never>] = [:]
init(session: URLSession = .shared) {
self.session = session
}
/// Look up metadata for an aircraft by ICAO24 hex. Coalesces
/// concurrent requests for the same icao24 so we never fire twice.
/// Returns nil on network error / no record.
func metadata(forICAO24 icao24: String) async -> Metadata? {
let key = icao24.lowercased()
if let inflight = inflight[key] {
return await inflight.value
}
let task = Task<Metadata?, Never> { [weak self] in
guard let self else { return nil }
return await self.fetch(icao24: key)
}
inflight[key] = task
let result = await task.value
inflight.removeValue(forKey: key)
return result
}
private func fetch(icao24: String) async -> Metadata? {
guard let url = URL(string: "https://opensky-network.org/api/metadata/aircraft/icao/\(icao24)") else {
return nil
}
var req = URLRequest(url: url)
req.timeoutInterval = 12
req.setValue("application/json", forHTTPHeaderField: "Accept")
do {
let (data, resp) = try await session.data(for: req)
guard let http = resp as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
return nil
}
guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return nil
}
let registration = root["registration"] as? String ?? ""
let built = parseDate(root["built"] as? String)
let firstFlight = parseDate(root["firstFlightDate"] as? String)
return Metadata(
registration: registration,
built: built,
firstFlightDate: firstFlight
)
} catch {
return nil
}
}
/// OpenSky returns dates as "YYYY-MM-DD" strings.
private func parseDate(_ s: String?) -> Date? {
guard let s, !s.isEmpty else { return nil }
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
f.timeZone = TimeZone(identifier: "UTC")
return f.date(from: s)
}
}
+407 -60
View File
@@ -42,11 +42,12 @@ actor AirlineLoadService {
switch code {
case "UA": return await fetchUnitedLoad(flightNumber: flightNumber, date: date, origin: origin)
case "AA": return await fetchAmericanLoad(flightNumber: flightNumber, date: date, origin: origin, destination: destination)
case "NK": return await fetchSpiritStatus(origin: origin, destination: destination, date: date)
case "KE": return await fetchKoreanAirLoad(flightNumber: flightNumber, date: date, origin: origin, destination: destination)
case "B6": return await fetchJetBlueStatus(flightNumber: flightNumber, date: date, origin: origin)
case "AS": return await fetchAlaskaLoad(flightNumber: flightNumber, date: date, origin: origin, destination: destination)
case "EK": return await fetchEmiratesStatus(flightNumber: flightNumber, date: date, origin: origin)
case "AM": return await fetchAeromexicoLoad(flightNumber: flightNumber, date: date, origin: origin, destination: destination)
case "SY": return await fetchSunCountryLoad(flightNumber: flightNumber, date: date, origin: origin, destination: destination)
case "XE": return await fetchJSXLoad(flightNumber: flightNumber, date: date, origin: origin, destination: destination, departureTime: departureTime)
default:
print("[LoadService] Unsupported airline: \(code)")
@@ -284,6 +285,11 @@ actor AirlineLoadService {
// MARK: - American Airlines
/// AA gates the waitlist API on User-Agent version. Bump this when
/// `airlines/com.aa.android_*.apkm` is refreshed stale versions get
/// HTTP 403 with `{"alert":{"message":"Please update your version..."}}`.
private static let aaAppVersion = "2026.14"
private func fetchAmericanLoad(
flightNumber: String,
date: Date,
@@ -302,11 +308,17 @@ actor AirlineLoadService {
URLQueryItem(name: "destinationAirportCode", value: destination.uppercased())
]
guard let url = components?.url else { return nil }
guard let url = components?.url else {
print("[AA] Invalid URL components")
return nil
}
print("[AA] GET \(url.absoluteString)")
do {
var request = URLRequest(url: url)
request.setValue("Android/2025.31 Pixel 7|14|1080|2400|1.0|AmericanAirlines", forHTTPHeaderField: "User-Agent")
request.setValue("Android/\(Self.aaAppVersion) Pixel 7|14|1080|2400|1.0|AmericanAirlines",
forHTTPHeaderField: "User-Agent")
request.setValue("MOBILE", forHTTPHeaderField: "x-clientid")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
@@ -314,13 +326,37 @@ actor AirlineLoadService {
request.setValue("fs", forHTTPHeaderField: "x-referrer")
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
let http = response as? HTTPURLResponse
let status = http?.statusCode ?? -1
print("[AA] HTTP status: \(status), \(data.count) bytes")
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let waitListArray = json["waitList"] as? [[String: Any]] else {
if status != 200 {
if let bodyStr = String(data: data, encoding: .utf8) {
print("[AA] body (first 500): \(bodyStr.prefix(500))")
// Server hints when the UA version has aged out surface it.
if status == 403, bodyStr.contains("update your version") {
print("[AA] ⚠️ User-Agent version (\(Self.aaAppVersion)) is rejected — bump aaAppVersion to match the latest APK in airlines/")
}
}
return nil
}
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
print("[AA] JSON parse failed; body (first 500): \(String(data: data, encoding: .utf8)?.prefix(500) ?? "")")
return nil
}
print("[AA] top-level keys: \(json.keys.sorted())")
guard let waitListArray = json["waitList"] as? [[String: Any]] else {
// 200 OK but no `waitList` typical for AA Eagle 4-digit
// regional flights (marketed as AA but the mobile waitlist
// endpoint doesn't track them), or for flights whose waitlist
// hasn't opened yet (usually opens T-24h before departure).
print("[AA] No 'waitList' array in response — likely no waitlist open yet for this flight")
return nil
}
print("[AA] waitList entries: \(waitListArray.count)")
var seatAvailability: [SeatAvailability] = []
var standbyList: [StandbyPassenger] = []
var upgradeList: [StandbyPassenger] = []
@@ -363,6 +399,7 @@ actor AirlineLoadService {
}
}
print("[AA] parsed seatAvailability=\(seatAvailability.count) standby=\(standbyList.count) upgrade=\(upgradeList.count)")
return FlightLoad(
airlineCode: "AA",
flightNumber: "AA\(num)",
@@ -372,59 +409,7 @@ actor AirlineLoadService {
seatAvailability: seatAvailability
)
} catch {
return nil
}
}
// MARK: - Spirit Airlines
private func fetchSpiritStatus(origin: String, destination: String, date: Date) async -> FlightLoad? {
guard let url = URL(string: "https://api.spirit.com/customermobileprod/2.8.0/v3/GetFlightInfoBI") else {
print("[NK] Invalid URL")
return nil
}
let dateStr = dayString(from: date, originIATA: origin)
let body: [String: String] = [
"departureStation": origin.uppercased(),
"arrivalStation": destination.uppercased(),
"departureDate": dateStr
]
print("[NK] POST \(url) body: \(body)")
do {
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("c6567af50d544dfbb3bc5dd99c6bb177", forHTTPHeaderField: "Ocp-Apim-Subscription-Key")
request.setValue("Android", forHTTPHeaderField: "Platform")
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await session.data(for: request)
let http = response as? HTTPURLResponse
print("[NK] HTTP status: \(http?.statusCode ?? -1)")
if let bodyStr = String(data: data, encoding: .utf8) {
print("[NK] Response body: \(bodyStr.prefix(500))")
}
guard http?.statusCode == 200 else {
print("[NK] Non-200 response")
return nil
}
// Spirit is a ULCC with no standby program.
return FlightLoad(
airlineCode: "NK",
flightNumber: "NK",
cabins: [],
standbyList: [],
upgradeList: [],
seatAvailability: []
)
} catch {
print("[NK] Error: \(error)")
print("[AA] error: \(error)")
return nil
}
}
@@ -867,6 +852,368 @@ actor AirlineLoadService {
}
}
// MARK: - Aeromexico
/// Aeromexico exposes a Sabre `GetPassengerListRQ` proxy on a public AWS
/// API Gateway used by their consumer app's flight-status widget. The
/// endpoint requires no API key just a `channel: web` / `flow: CHECKIN`
/// header pair (constants extracted from the AM Android APK).
///
/// Response includes:
/// - `cabinInfoList[].authorized` (capacity) + `.available` (open seats)
/// - `passengers[]` with full PII, priority, `isStaff` flag, positions
///
/// Snapshot persists at least T-1d through T+2d; outside that the gateway
/// answers `FLIGHT NOT INITIALIZED` or `NONE LISTED`.
private func fetchAeromexicoLoad(
flightNumber: String,
date: Date,
origin: String,
destination: String
) async -> FlightLoad? {
let num = stripAirlinePrefix(flightNumber)
// Endpoint validates flight code against ^[0-9]{4}$.
let padded = String(format: "%04d", Int(num) ?? 0)
let dateStr = dayString(from: date, originIATA: origin)
async let standbyResp = fetchAeromexicoList(
endpoint: "passengerliststandby",
flightCode: padded,
origin: origin,
departureDate: dateStr
)
async let upgradeResp = fetchAeromexicoList(
endpoint: "passengerlistupgrade",
flightCode: padded,
origin: origin,
departureDate: dateStr
)
let sb = await standbyResp
let up = await upgradeResp
// If both calls failed entirely we have nothing.
guard sb != nil || up != nil else {
print("[AM] Both standby and upgrade calls returned nil")
return nil
}
// Cabin info: same per leg, either response carries it.
var cabins: [CabinLoad] = []
if let cabinList = sb?["cabinInfoList"] as? [[String: Any]] {
cabins = Self.parseAeromexicoCabins(cabinList)
} else if let cabinList = up?["cabinInfoList"] as? [[String: Any]] {
cabins = Self.parseAeromexicoCabins(cabinList)
}
let standbyList = Self.parseAeromexicoPassengers(
sb?["passengers"] as? [[String: Any]] ?? [],
listName: "Standby"
)
let upgradeList = Self.parseAeromexicoPassengers(
up?["passengers"] as? [[String: Any]] ?? [],
listName: "Upgrade"
)
let sbTotal = sb?["totalListed"] as? Int ?? 0
let upTotal = up?["totalListed"] as? Int ?? 0
print("[AM] parsed cabins=\(cabins.count) standby=\(standbyList.count)/\(sbTotal) upgrade=\(upgradeList.count)/\(upTotal)")
// Surface "no data yet" cleanly: if every response was NONE LISTED and
// we got nothing back, return nil so the detail view shows the
// "Load data not available" state rather than an empty card.
if cabins.isEmpty && standbyList.isEmpty && upgradeList.isEmpty {
print("[AM] No usable data in response (snapshot likely outside T-1d / T+2d window)")
return nil
}
return FlightLoad(
airlineCode: "AM",
flightNumber: "AM\(num)",
cabins: cabins,
standbyList: standbyList,
upgradeList: upgradeList,
seatAvailability: []
)
}
/// Single GET against the AM passenger-list gateway. Returns the parsed
/// JSON dict on success (with or without populated lists), or nil if the
/// request failed at the transport layer.
private func fetchAeromexicoList(
endpoint: String,
flightCode: String,
origin: String,
departureDate: String
) async -> [String: Any]? {
var components = URLComponents(string: "https://lw18yj0mhb.execute-api.us-east-1.amazonaws.com/rb/\(endpoint)")
components?.queryItems = [
URLQueryItem(name: "departureAirport", value: origin.uppercased()),
URLQueryItem(name: "code", value: flightCode),
URLQueryItem(name: "departureDate", value: departureDate),
URLQueryItem(name: "operatingCarrier", value: "AM"),
URLQueryItem(name: "operatingFlightCode", value: flightCode)
]
guard let url = components?.url else {
print("[AM] Invalid URL for \(endpoint)")
return nil
}
print("[AM] GET \(url.absoluteString)")
do {
var request = URLRequest(url: url)
request.setValue("web", forHTTPHeaderField: "channel")
request.setValue("CHECKIN", forHTTPHeaderField: "flow")
request.setValue(UUID().uuidString, forHTTPHeaderField: "x-transaction-id")
request.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, response) = try await session.data(for: request)
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
print("[AM] \(endpoint) HTTP \(status), \(data.count) bytes")
guard status == 200 else {
if let body = String(data: data, encoding: .utf8) {
print("[AM] \(endpoint) non-200 body: \(body.prefix(300))")
}
return nil
}
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
print("[AM] \(endpoint) JSON parse failed")
return nil
}
// Log the warning surface so future failures are diagnosable.
if let warnings = json["warnings"] as? [[String: Any]], !warnings.isEmpty {
let msgs = warnings.compactMap { $0["errorMessage"] as? String }.joined(separator: " | ")
print("[AM] \(endpoint) warnings: \(msgs)")
}
return json
} catch {
print("[AM] \(endpoint) error: \(error)")
return nil
}
}
private static func parseAeromexicoCabins(_ raw: [[String: Any]]) -> [CabinLoad] {
raw.compactMap { entry in
let cabinCode = entry["cabin"] as? String ?? "?"
let authorized = entry["authorized"] as? Int ?? 0
let available = entry["available"] as? Int ?? 0
// Skip placeholder rows with no capacity at all.
guard authorized > 0 || available > 0 else { return nil }
let booked = max(0, authorized - available)
return CabinLoad(
name: aeromexicoCabinName(code: cabinCode),
capacity: authorized,
booked: booked,
revenueStandby: 0,
nonRevStandby: 0
)
}
}
/// Map AM's single-letter cabin codes to user-readable names. AM uses
/// `Y` for economy, `C` (Clase Premier) for business, and `P` for
/// Premier One (their first/long-haul biz). Anything unknown falls
/// through with the raw code.
private static func aeromexicoCabinName(code: String) -> String {
switch code.uppercased() {
case "Y": return "Economy"
case "C": return "Clase Premier"
case "P": return "Premier One"
case "F": return "First"
default: return code
}
}
private static func parseAeromexicoPassengers(
_ raw: [[String: Any]],
listName: String
) -> [StandbyPassenger] {
raw.enumerated().compactMap { (index, entry) in
let first = entry["firstName"] as? String ?? ""
let last = entry["lastName"] as? String ?? ""
// AM lists names in full, redact for display the way AA does
// (last name + first initial). If either piece is missing,
// fall back gracefully.
let display: String
if !last.isEmpty, !first.isEmpty {
display = "\(last), \(first.prefix(1))"
} else {
display = (last.isEmpty ? first : last)
}
let position = (entry["newPosition"] as? Int)
?? (entry["originalPosition"] as? Int)
?? (index + 1)
let cleared = (entry["boardingPassFlag"] as? Bool ?? false)
|| (entry["boardStatus"] as? Bool ?? false)
|| ((entry["seat"] as? String).map { !$0.isEmpty } ?? false)
return StandbyPassenger(
order: position,
displayName: display,
cleared: cleared,
seat: entry["seat"] as? String,
listName: listName
)
}
}
// MARK: - Sun Country
/// Sun Country runs on Navitaire (same PSS as JSX). Their public booking
/// availability search returns full per-flight inventory data including
/// `sold` (booked passenger count) and `capacity` per leg better than
/// AA, which only gives seat-availability counts. No standby program
/// exposed via this endpoint (SY is single-class), so we return cabin
/// load only.
///
/// Imperva WAF in front of `syprod-api.suncountry.com` blocks bare
/// curl. Gated on User-Agent / Referer / Origin headers (same pattern
/// as American). Browser-shaped headers pass cleanly.
///
/// Endpoint: `POST /api/nsk/v4/availability/search/simple`
/// Auth: Azure APIM key + a long-lived dotREZ JWT (both extracted from
/// network traffic of suncountry.com; neither is a user session token).
private static let sunCountryAPIMKey = "bc7f707786c44a56859c396102f6cd21"
/// dotREZ JWT used as the `Authorization` header. Issued by "dotREZ
/// API" with `sub: DOTREZ` a static API client identity, not a user
/// token. If this stops working, capture a fresh one from
/// suncountry.com's PUT /api/nsk/v1/token request.
private static let sunCountryJWT =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJET1RSRVoiLCJqdGkiOiI0ZWQyNjQ0Ny0zOTU4LWQ1YjQtZTkxNi0xZDM4YWFiNTQ0ZTMiLCJpc3MiOiJkb3RSRVogQVBJIn0.W_zpG_6nZbD37S7hsWgahYG9Dc1gwgG_8s0KA3V72Qg"
private func fetchSunCountryLoad(
flightNumber: String,
date: Date,
origin: String,
destination: String
) async -> FlightLoad? {
let num = stripAirlinePrefix(flightNumber)
let dateStr = dayString(from: date, originIATA: origin)
guard let url = URL(string: "https://syprod-api.suncountry.com/api/nsk/v4/availability/search/simple") else {
print("[SY] Invalid URL")
return nil
}
let body: [String: Any] = [
"Origin": origin.uppercased(),
"Destination": destination.uppercased(),
"BeginDate": dateStr,
"EndDate": dateStr,
"Passengers": ["Types": [["Type": "ADT", "Count": 1]]],
"Currency": "USD"
]
print("[SY] POST \(url.absoluteString) for SY\(num) \(origin)\(destination) on \(dateStr)")
do {
var request = URLRequest(url: url)
request.httpMethod = "POST"
Self.applySunCountryBrowserHeaders(to: &request)
request.setValue(Self.sunCountryAPIMKey, forHTTPHeaderField: "Ocp-Apim-Subscription-Key")
request.setValue(Self.sunCountryJWT, forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await session.data(for: request)
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
print("[SY] HTTP status: \(status), \(data.count) bytes")
if status != 200 {
if let bodyStr = String(data: data, encoding: .utf8) {
print("[SY] body (first 500): \(bodyStr.prefix(500))")
if status == 403, bodyStr.contains("Incapsula") || bodyStr.contains("Imperva") {
print("[SY] ⚠️ Imperva WAF rejection — check the User-Agent / Referer / Origin headers in applySunCountryBrowserHeaders")
}
}
return nil
}
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let dataObj = json["data"] as? [String: Any],
let results = dataObj["results"] as? [[String: Any]] else {
print("[SY] Response shape unexpected — top-level keys: \((try? JSONSerialization.jsonObject(with: data) as? [String: Any])?.keys.sorted() ?? [])")
return nil
}
// Walk results trips journeysAvailableByMarket match by
// flight number, then read sold/capacity from the leg.
let key = "\(origin.uppercased())|\(destination.uppercased())"
var match: [String: Any]?
outer: for result in results {
for trip in (result["trips"] as? [[String: Any]]) ?? [] {
let market = trip["journeysAvailableByMarket"] as? [String: Any]
for journey in (market?[key] as? [[String: Any]]) ?? [] {
let segments = journey["segments"] as? [[String: Any]] ?? []
let first = segments.first
let flightId = ((first?["identifier"] as? [String: Any])?["identifier"] as? String) ?? ""
if flightId == num {
match = journey
break outer
}
}
}
}
guard let journey = match,
let segments = journey["segments"] as? [[String: Any]],
let legs = (segments.first?["legs"] as? [[String: Any]]),
let legInfo = legs.first?["legInfo"] as? [String: Any] else {
print("[SY] No matching flight \(num) in response for \(origin)-\(destination)")
return nil
}
let capacity = (legInfo["capacity"] as? Int) ?? (legInfo["adjustedCapacity"] as? Int) ?? 0
let sold = legInfo["sold"] as? Int ?? 0
let equipment = legInfo["equipmentType"] as? String ?? ""
print("[SY] Found SY\(num): capacity=\(capacity) sold=\(sold) equipment=\(equipment) load=\(capacity > 0 ? Double(sold)/Double(capacity) : 0)")
if capacity <= 0 {
print("[SY] Capacity was 0; treating as no data")
return nil
}
let cabin = CabinLoad(
name: "Economy",
capacity: capacity,
booked: sold,
revenueStandby: 0,
nonRevStandby: 0
)
return FlightLoad(
airlineCode: "SY",
flightNumber: "SY\(num)",
cabins: [cabin],
standbyList: [],
upgradeList: [],
seatAvailability: []
)
} catch {
print("[SY] error: \(error)")
return nil
}
}
/// Browser-shaped headers so Imperva lets the request through. The
/// API host is gated on User-Agent + Referer + Origin; bare curl
/// (or default URLSession) gets 403 with an Incapsula page.
private static func applySunCountryBrowserHeaders(to request: inout URLRequest) {
request.setValue(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
+ "(KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36",
forHTTPHeaderField: "User-Agent"
)
request.setValue("https://www.suncountry.com/", forHTTPHeaderField: "Referer")
request.setValue("https://www.suncountry.com", forHTTPHeaderField: "Origin")
request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language")
}
// MARK: - JSX (JetSuiteX)
private func fetchJSXLoad(
@@ -1032,7 +1379,7 @@ actor AirlineLoadService {
return result.isEmpty ? trimmed : result
}
/// "yyyy-MM-dd" formatter for United, American, Spirit.
/// "yyyy-MM-dd" formatter for United and American.
/// NOTE: this is UTC-pinned and will cross the day boundary for users in
/// non-UTC timezones. Prefer `dayString(from:originIATA:)` which resolves
/// the departure airport's approximate local timezone.
+305
View File
@@ -79,6 +79,311 @@ final class AirportDatabase: Sendable {
airports.first { $0.iata == code }
}
/// Resolve a 4-letter ICAO code (e.g. "KDFW", "EGLL") to its IATA
/// equivalent. Returns nil when the ICAO doesn't map to an airport we
/// know about callers should NOT pretend an unknown ICAO is a valid
/// IATA (silent fallthrough downstream looks up against an empty
/// table and surfaces nothing in the UI).
///
/// Strategy:
/// 1. Prefix-drop heuristic for the regions where it's deterministic:
/// US "Kxxx" "xxx", Canada "CYxx" "Yxx", Mexico "MMxx" "Mxx".
/// Verify the result against the bundled airport list so an
/// accidental KFOO doesn't silently masquerade as "FOO".
/// 2. Otherwise consult the curated ``icaoToIATA`` table below
/// (major intl hubs that the BTS bundle / live tab can surface).
func iata(forICAO icao: String) -> String? {
let raw = icao.uppercased()
guard raw.count == 4 else { return nil }
// Regional prefix-drop (US / CA / MX) must round-trip through
// the airport list to count as a valid mapping.
var candidate: String?
if raw.hasPrefix("K") {
candidate = String(raw.dropFirst())
} else if raw.hasPrefix("CY") {
candidate = String(raw.dropFirst())
} else if raw.hasPrefix("MM") {
candidate = String(raw.dropFirst())
}
if let c = candidate, airport(byIATA: c) != nil { return c }
if let mapped = Self.icaoToIATA[raw] { return mapped }
return nil
}
/// Look up an airport directly by its 4-letter ICAO code.
/// Returns nil when the mapping can't be resolved.
func airport(byICAO code: String) -> MapAirport? {
guard let iata = iata(forICAO: code) else { return nil }
return airport(byIATA: iata)
}
/// Resolve a 3-letter IATA code to its 4-letter ICAO code. Reverse of
/// ``iata(forICAO:)``. Used by FlightAware-based lookups, whose URLs
/// take ICAO airport codes (`KDFW`, `EHAM`).
///
/// Strategy:
/// 1. Check the inverted curated table covers international hubs
/// and Alaska/Hawaii/territory ICAOs that don't follow the
/// simple prefix rule (e.g. ANCPANC, HNLPHNL, MEXMMMX).
/// 2. Deterministic prefix for US 48 states and Canada, gated by
/// the bundled airport list's `region` so we don't synthesize a
/// bogus ICAO for an IATA that isn't actually a US/CA airport.
func icao(forIATA iata: String) -> String? {
let upper = iata.uppercased()
guard upper.count == 3 else { return nil }
if let mapped = Self.iataToICAO[upper] { return mapped }
guard let airport = airport(byIATA: upper) else { return nil }
let region = airport.region
if region.hasPrefix("US-") { return "K" + upper }
if region.hasPrefix("CA-") { return "C" + upper }
return nil
}
/// Inverted ``icaoToIATA`` so ``icao(forIATA:)`` is O(1). Computed once
/// at first access.
private static let iataToICAO: [String: String] = {
var inverse: [String: String] = [:]
for (icao, iata) in icaoToIATA {
inverse[iata] = icao
}
return inverse
}()
/// Curated ICAO IATA mappings for major hubs outside the
/// deterministic-prefix regions. Sourced from publicly published
/// airport directories (OurAirports, IATA airport directory) and
/// limited to airports a flight surfaced by FR24/OpenSky on the Live
/// tab is likely to reference.
private static let icaoToIATA: [String: String] = [
// United Kingdom & Ireland
"EGLL": "LHR", "EGKK": "LGW", "EGSS": "STN", "EGGW": "LTN",
"EGCC": "MAN", "EGPH": "EDI", "EGPF": "GLA", "EGBB": "BHX",
"EGNT": "NCL", "EGNX": "EMA", "EGAA": "BFS", "EGAC": "BHD",
"EIDW": "DUB", "EICK": "ORK", "EINN": "SNN",
// France
"LFPG": "CDG", "LFPO": "ORY", "LFBO": "TLS", "LFLL": "LYS",
"LFMN": "NCE", "LFML": "MRS", "LFRS": "NTE", "LFBD": "BOD",
"LFSB": "BSL",
// Germany
"EDDF": "FRA", "EDDM": "MUC", "EDDB": "BER", "EDDH": "HAM",
"EDDL": "DUS", "EDDK": "CGN", "EDDS": "STR", "EDDN": "NUE",
// Netherlands / Belgium / Luxembourg
"EHAM": "AMS", "EHRD": "RTM", "EHEH": "EIN",
"EBBR": "BRU", "EBCI": "CRL", "EBLG": "LGG", "EBAW": "ANR",
"ELLX": "LUX",
// Switzerland / Austria
"LSZH": "ZRH", "LSGG": "GVA", "LSZB": "BRN",
"LOWW": "VIE", "LOWS": "SZG", "LOWI": "INN",
// Spain / Portugal
"LEMD": "MAD", "LEBL": "BCN", "LEMG": "AGP", "LEPA": "PMI",
"LEVC": "VLC", "LEAL": "ALC", "LEBB": "BIO", "LEZL": "SVQ",
"LEST": "SCQ", "GCLP": "LPA", "GCTS": "TFS", "GCXO": "TFN",
"LPPT": "LIS", "LPPR": "OPO", "LPFR": "FAO", "LPMA": "FNC",
// Italy / Greece / Malta / Turkey
"LIRF": "FCO", "LIMC": "MXP", "LIML": "LIN", "LIPZ": "VCE",
"LIRA": "CIA", "LIRN": "NAP", "LIPE": "BLQ", "LIME": "BGY",
"LICC": "CTA", "LICJ": "PMO", "LIEO": "OLB",
"LGAV": "ATH", "LGTS": "SKG", "LGIR": "HER", "LGRP": "RHO",
"LMML": "MLA",
"LTBA": "ISL", "LTFM": "IST", "LTAC": "ESB", "LTAI": "AYT",
// Nordics
"ESSA": "ARN", "ESGG": "GOT", "ESMS": "MMX",
"EKCH": "CPH", "EKBI": "BLL", "EKAH": "AAR",
"ENGM": "OSL", "ENBR": "BGO", "ENZV": "SVG", "ENTC": "TOS",
"EFHK": "HEL", "EFRO": "RVN", "EFKU": "KUO",
"BIKF": "KEF",
// Eastern Europe / Russia
"EPWA": "WAW", "EPKK": "KRK", "EPGD": "GDN", "EPPO": "POZ",
"LKPR": "PRG", "LZIB": "BTS", "LHBP": "BUD",
"LROP": "OTP", "LBSF": "SOF", "LWSK": "SKP",
"EYVI": "VNO", "EVRA": "RIX", "EETN": "TLL",
"UUEE": "SVO", "UUDD": "DME", "UUWW": "VKO",
"ULLI": "LED",
// Middle East
"OMDB": "DXB", "OMAA": "AUH", "OMSJ": "SHJ",
"OTHH": "DOH", "OOMS": "MCT", "OBBI": "BAH",
"OKBK": "KWI", "OERK": "RUH", "OEJN": "JED",
"LLBG": "TLV", "OJAI": "AMM",
// Africa
"HECA": "CAI", "GMMN": "CMN", "DAAG": "ALG", "DTTA": "TUN",
"HAAB": "ADD", "HKJK": "NBO", "DNMM": "LOS", "DGAA": "ACC",
"FAOR": "JNB", "FACT": "CPT", "FADN": "DUR",
// South Africa / Indian Ocean
"FIMP": "MRU", "FMEE": "RUN",
// South Asia
"VABB": "BOM", "VIDP": "DEL", "VECC": "CCU", "VOMM": "MAA",
"VOBL": "BLR", "VOHS": "HYD", "VOCI": "COK", "VOTV": "TRV",
"VAAH": "AMD", "VOTR": "TIR",
"VCBI": "CMB",
"VGHS": "DAC",
"OPKC": "KHI", "OPLA": "LHE", "OPIS": "ISB",
// SE Asia / Pacific
"WSSS": "SIN",
"WMKK": "KUL", "WMSA": "SZB",
"VTBS": "BKK", "VTBD": "DMK", "VTSP": "HKT", "VTCC": "CNX",
"VVNB": "HAN", "VVTS": "SGN", "VVDN": "DAD",
"WIII": "CGK", "WADD": "DPS", "WICC": "BDO", "WARR": "SUB",
"WAJJ": "DJJ",
"RPLL": "MNL", "RPVM": "CEB", "RPVI": "ILO",
"VLVT": "VTE",
"VYYY": "RGN",
"VDPP": "PNH", "VDSR": "REP",
// North Asia
"ZBAA": "PEK", "ZBAD": "PKX", "ZSPD": "PVG", "ZSSS": "SHA",
"ZGGG": "CAN", "ZGSZ": "SZX", "ZUUU": "CTU", "ZGOW": "SWA",
"ZBTJ": "TSN", "ZSHC": "HGH", "ZSAM": "XMN", "ZGHA": "CSX",
"ZGKL": "KWL", "ZHHH": "WUH", "ZWWW": "URC",
"VHHH": "HKG", "VMMC": "MFM",
"RCTP": "TPE", "RCSS": "TSA", "RCKH": "KHH",
"RKSI": "ICN", "RKSS": "GMP", "RKPK": "PUS", "RKPC": "CJU",
"RJTT": "HND", "RJAA": "NRT", "RJBB": "KIX", "RJOO": "ITM",
"RJCC": "CTS", "RJFF": "FUK", "RJOA": "HIJ", "RJGG": "NGO",
"RJOM": "MYJ", "RJSS": "SDJ",
"RJNA": "NGO",
// Australia / Oceania
"YSSY": "SYD", "YMML": "MEL", "YBBN": "BNE", "YPPH": "PER",
"YPAD": "ADL", "YBCG": "OOL", "YBCS": "CNS", "YPDN": "DRW",
"YPJT": "JT0",
"NZAA": "AKL", "NZCH": "CHC", "NZWN": "WLG", "NZQN": "ZQN",
"NFFN": "NAN", "NFTF": "TBU", "NTAA": "PPT",
"FAOL": "OOL",
// Latin America
"MMMX": "MEX", "MMUN": "CUN", "MMGL": "GDL", "MMMY": "MTY",
"MMTJ": "TIJ",
"MROC": "SJO", "MGGT": "GUA", "MSLP": "SAL", "MNMG": "MGA",
"MPTO": "PTY", "MUHA": "HAV", "MDPC": "PUJ", "MDSD": "SDQ",
"TJSJ": "SJU",
"SBGR": "GRU", "SBSP": "GRU", "SBKP": "VCP", "SBGL": "GIG",
"SBSV": "SSA", "SBRF": "REC", "SBFZ": "FOR", "SBBR": "BSB",
"SBPA": "POA", "SBCT": "CWB", "SBFL": "FLN", "SBBE": "BEL",
"SBMN": "MAO",
"SAEZ": "EZE", "SABE": "AEP", "SCEL": "SCL", "SPJC": "LIM",
"SUMU": "MVD", "SKBO": "BOG", "SKCL": "CLO", "SKRG": "MDE",
"SEQM": "UIO", "SVMI": "CCS",
]
/// Return the IANA timezone for an airport's IATA code, or nil if we
/// don't have a confident mapping. Used by ``LoadFactorService`` so
/// weekday + month adjustments resolve in airport-local time rather
/// than UTC (otherwise late-evening west-coast departures roll past
/// midnight UTC and lose the weekend bump).
///
/// The table is curated to major US carrier airports plus a handful
/// of common Canadian and international hubs enough to cover every
/// airport the bundled BTS data references. Anything we don't know
/// returns nil so callers can fall back to UTC explicitly.
func timeZone(forIATA code: String) -> TimeZone? {
guard let id = Self.iataTimeZoneMap[code.uppercased()] else {
return nil
}
return TimeZone(identifier: id)
}
/// Curated IATA IANA timezone identifier table. Sourced from
/// publicly published airport timezone references (OurAirports,
/// IATA airport directory). Only includes airports referenced by
/// the bundled BTS data or common nonrev itineraries.
private static let iataTimeZoneMap: [String: String] = [
// Pacific
"SEA": "America/Los_Angeles", "PDX": "America/Los_Angeles",
"SFO": "America/Los_Angeles", "OAK": "America/Los_Angeles",
"SJC": "America/Los_Angeles", "LAX": "America/Los_Angeles",
"BUR": "America/Los_Angeles", "ONT": "America/Los_Angeles",
"SAN": "America/Los_Angeles", "SNA": "America/Los_Angeles",
"LGB": "America/Los_Angeles", "PSP": "America/Los_Angeles",
"FAT": "America/Los_Angeles", "SMF": "America/Los_Angeles",
"RNO": "America/Los_Angeles", "LAS": "America/Los_Angeles",
// Mountain
"PHX": "America/Phoenix", "TUS": "America/Phoenix",
"DEN": "America/Denver", "COS": "America/Denver",
"ABQ": "America/Denver", "SLC": "America/Denver",
"BOI": "America/Boise", "BIL": "America/Denver",
"MSO": "America/Denver", "ELP": "America/Denver",
// Central
"DFW": "America/Chicago", "DAL": "America/Chicago",
"IAH": "America/Chicago", "HOU": "America/Chicago",
"AUS": "America/Chicago", "SAT": "America/Chicago",
"MSY": "America/Chicago", "MEM": "America/Chicago",
"BNA": "America/Chicago", "STL": "America/Chicago",
"MCI": "America/Chicago", "MSP": "America/Chicago",
"ORD": "America/Chicago", "MDW": "America/Chicago",
"MKE": "America/Chicago", "OMA": "America/Chicago",
"OKC": "America/Chicago", "TUL": "America/Chicago",
"LIT": "America/Chicago", "JAN": "America/Chicago",
"BHM": "America/Chicago", "HSV": "America/Chicago",
"MOB": "America/Chicago", "SHV": "America/Chicago",
"LRD": "America/Chicago", "BRO": "America/Chicago",
"MFE": "America/Chicago", "CRP": "America/Chicago",
"LBB": "America/Chicago", "AMA": "America/Chicago",
"MAF": "America/Chicago", "ICT": "America/Chicago",
// Eastern
"ATL": "America/New_York", "CLT": "America/New_York",
"RDU": "America/New_York", "DCA": "America/New_York",
"IAD": "America/New_York", "BWI": "America/New_York",
"PHL": "America/New_York", "EWR": "America/New_York",
"JFK": "America/New_York", "LGA": "America/New_York",
"BOS": "America/New_York", "PVD": "America/New_York",
"MHT": "America/New_York", "PWM": "America/New_York",
"BGR": "America/New_York", "BTV": "America/New_York",
"BUF": "America/New_York", "ROC": "America/New_York",
"SYR": "America/New_York", "ALB": "America/New_York",
"PIT": "America/New_York", "CLE": "America/New_York",
"CMH": "America/New_York", "CVG": "America/New_York",
"DTW": "America/New_York", "IND": "America/New_York",
"SDF": "America/New_York", "LEX": "America/New_York",
"RIC": "America/New_York", "ORF": "America/New_York",
"ROA": "America/New_York", "GSO": "America/New_York",
"CHS": "America/New_York", "CAE": "America/New_York",
"GSP": "America/New_York", "AVL": "America/New_York",
"MYR": "America/New_York", "ILM": "America/New_York",
"SAV": "America/New_York", "JAX": "America/New_York",
"TLH": "America/New_York", "MCO": "America/New_York",
"TPA": "America/New_York", "PIE": "America/New_York",
"RSW": "America/New_York", "MIA": "America/New_York",
"FLL": "America/New_York", "PBI": "America/New_York",
"EYW": "America/New_York", "PNS": "America/New_York",
"VPS": "America/New_York", "ECP": "America/New_York",
// Alaska / Hawaii
"ANC": "America/Anchorage", "FAI": "America/Anchorage",
"JNU": "America/Juneau", "KTN": "America/Sitka",
"HNL": "Pacific/Honolulu", "OGG": "Pacific/Honolulu",
"KOA": "Pacific/Honolulu", "LIH": "Pacific/Honolulu",
"ITO": "Pacific/Honolulu",
// Caribbean / Territories
"SJU": "America/Puerto_Rico", "BQN": "America/Puerto_Rico",
"PSE": "America/Puerto_Rico", "STT": "America/Puerto_Rico",
"STX": "America/Puerto_Rico",
// Canada (most common cross-border)
"YYZ": "America/Toronto", "YOW": "America/Toronto",
"YUL": "America/Toronto", "YHZ": "America/Halifax",
"YYC": "America/Edmonton", "YEG": "America/Edmonton",
"YVR": "America/Vancouver", "YWG": "America/Winnipeg",
]
/// Return the airport closest to a given coordinate, optionally
/// within a max distance. Linear scan O(n) with ~3,900 airports,
/// fast enough on the main thread for tap-then-lookup flows.
func nearestAirport(to coordinate: CLLocationCoordinate2D, maxMiles: Double = 25) -> MapAirport? {
guard !airports.isEmpty else { return nil }
var bestAirport: MapAirport?
var bestDistSq: Double = .greatestFiniteMagnitude
// Convert max miles to (degrees lat)² in a rough planar sense good
// enough for "nearest airport" filtering. ~1 degree lat 69 miles.
let cutoffSq = (maxMiles / 69.0) * (maxMiles / 69.0)
for ap in airports {
let dLat = ap.lat - coordinate.latitude
let dLng = ap.lng - coordinate.longitude
let dSq = dLat * dLat + dLng * dLng
if dSq < bestDistSq {
bestDistSq = dSq
bestAirport = ap
}
}
return bestDistSq <= cutoffSq ? bestAirport : nil
}
private static func buildRegionNames() -> [String: String] {
// US states + territories
var names: [String: String] = [
+187
View File
@@ -0,0 +1,187 @@
import Foundation
/// Bundled DOT/BTS historical-stats lookup.
///
/// Data is generated by ``scripts/generate_bts_bundle.py`` and shipped as
/// ``Resources/bts_bundle.json`` inside the app bundle. The JSON is a flat
/// dictionary keyed by ``CARRIER_FLIGHTNUM_ORIGIN_DEST`` (e.g.
/// ``"WN_61_DAL_HOU"``); each value is a ``BTSFlightRecord``.
///
/// The actor loads + decodes the bundle exactly once, on first access, and
/// caches it for the rest of the process lifetime. The on-disk bundle is
/// ~1-2 MB (8K records, real Reporting Carrier + T-100 aggregates for one
/// recent month), so decode is sub-second and the in-memory dict is cheap.
///
/// Companion file: ``Resources/bts_bundle_meta.json`` citation metadata
/// surfaced via ``metadata()`` so the UI can label the data source.
actor BTSDataStore {
// MARK: Singleton
static let shared = BTSDataStore()
// MARK: State
private var loaded: [String: BTSFlightRecord]?
private var loadAttempted = false
private var meta: BTSMetadata?
private var metaAttempted = false
// MARK: Public API
/// Look up the historical record for a specific carrier + flight number
/// + origin/dest pair. Returns nil if the bundle has no entry callers
/// should treat that as "no data" rather than an error.
func record(
carrier: String,
flightNumber: Int,
origin: String,
dest: String
) async -> BTSFlightRecord? {
let key = Self.makeKey(
carrier: carrier,
flightNumber: flightNumber,
origin: origin,
dest: dest
)
return await allRecordsKeyed()[key]
}
/// Return the full keyed bundle. Useful for batch tools (e.g. building
/// the upcoming "your route stats" history view).
func allRecordsKeyed() async -> [String: BTSFlightRecord] {
if let loaded { return loaded }
if loadAttempted { return [:] }
loadAttempted = true
let parsed = Self.loadFromBundle()
loaded = parsed
return parsed
}
/// Return the citation/source metadata for the currently-bundled data.
/// Drives the in-app "Based on DOT BTS data: <month>, <N> records" label
/// so users can see exactly what powers the on-time / load-factor numbers.
func metadata() async -> BTSMetadata? {
if let meta { return meta }
if metaAttempted { return nil }
metaAttempted = true
let parsed = Self.loadMetadataFromBundle()
meta = parsed
return parsed
}
// MARK: Helpers
/// Canonical key format. Centralised so callers can't drift from the
/// generator's format.
static func makeKey(
carrier: String,
flightNumber: Int,
origin: String,
dest: String
) -> String {
"\(carrier.uppercased())_\(flightNumber)_\(origin.uppercased())_\(dest.uppercased())"
}
private static func loadFromBundle() -> [String: BTSFlightRecord] {
guard let url = Bundle.main.url(forResource: "bts_bundle", withExtension: "json") else {
print("[BTSDataStore] bts_bundle.json not found in main bundle")
return [:]
}
do {
let data = try Data(contentsOf: url)
let decoder = JSONDecoder()
let parsed = try decoder.decode([String: BTSFlightRecord].self, from: data)
print("[BTSDataStore] loaded \(parsed.count) records from bts_bundle.json")
return parsed
} catch {
print("[BTSDataStore] failed to decode bts_bundle.json: \(error)")
Task { @MainActor in
DataIntegrityMonitor.shared.report("bts_bundle.json", error: error)
}
return [:]
}
}
private static func loadMetadataFromBundle() -> BTSMetadata? {
guard let url = Bundle.main.url(forResource: "bts_bundle_meta", withExtension: "json") else {
print("[BTSDataStore] bts_bundle_meta.json not found in main bundle")
return nil
}
do {
let data = try Data(contentsOf: url)
let parsed = try JSONDecoder().decode(BTSMetadata.self, from: data)
print("[BTSDataStore] loaded metadata: \(parsed.sourcePeriod), \(parsed.recordCount) records")
return parsed
} catch {
print("[BTSDataStore] failed to decode bts_bundle_meta.json: \(error)")
return nil
}
}
}
// MARK: - Record type
/// A single historical-performance record for one carrier + flight number
/// + origin/dest pair. All fields are aggregated over ``samplePeriod`` (e.g.
/// "2026-02"). See ``bts_bundle_meta.json`` for the source URLs + methodology.
struct BTSFlightRecord: Sendable, Codable {
/// Total number of operated flights observed in the sample period.
let totalFlights: Int
/// Fraction of those flights that arrived on time, BTS definition:
/// arrival delay <= 15 minutes (0...1).
let onTimePct: Double
/// Mean arrival delay in minutes, averaged across delayed arrivals
/// (negative = early arrivals are excluded; matches BTS convention).
let avgDelayMin: Double
/// Fraction of scheduled flights cancelled (0...1).
let cancelledPct: Double
/// Average load factor fraction of seats sold, from T-100 Domestic
/// Segment data (sum PASSENGERS / sum SEATS at the carrier+route
/// level not per flight number, since T-100 does not split by
/// flight number).
let avgLoadFactor: Double
/// Average seat count per departure on this carrier+route, from T-100.
/// Used to scale predictions when live equipment differs.
let avgSeats: Int
/// ISO-like tag describing the sample period (e.g. "2026-02").
let samplePeriod: String
}
// MARK: - Metadata type
/// Citation block for the bundled data. Read at runtime from
/// ``bts_bundle_meta.json`` so the UI can show users exactly which BTS
/// month + source URLs power the on-time + load-factor numbers.
struct BTSMetadata: Sendable, Codable {
/// Calendar month covered (e.g. "2026-02").
let sourcePeriod: String
/// When this bundle was generated by ``scripts/generate_bts_bundle.py``.
let downloadedAt: String
/// Direct URLs to the BTS tables we pulled.
let sourceURLs: [String]
/// Number of (carrier, flight#, origin, dest) records in ``bts_bundle.json``.
let recordCount: Int
/// Carriers represented in the bundle.
let carriers: [String]
/// Minimum operated-flights filter applied during aggregation. Records
/// below this volume are dropped to reduce statistical noise.
let minFlightsFilter: Int
/// Methodology notes surfaced verbatim in the in-app "Data source" sheet.
let notes: String
/// Bumped whenever the on-disk shape changes so old caches can be invalidated.
let schemaVersion: Int
}
+228
View File
@@ -0,0 +1,228 @@
import Foundation
/// Reads the public Vercel blob route catalogs that route-explorer's
/// website also reads. No auth, no Turnstile just plain GETs against
/// a public CDN.
///
/// Endpoints used:
/// * `/data/routes/<IATA>.json` per-origin catalog: every destination
/// the airport serves, with carriers, equipment, weekly frequency,
/// distance, average duration, and effective-date windows. No
/// per-flight departure times.
/// * `/data/airports-with-routes.json` airport metadata (name, city,
/// country, lat/lng, ICAO, timezone) keyed by IATA. Used to enrich
/// row labels in the "Where can I go?" list.
///
/// Cache strategy: per-origin in-memory dictionary backed by an on-disk
/// JSON cache (Caches directory) with a 24-hour TTL. Catalog data only
/// changes weekly when route-explorer regenerates it, so 24h is generous
/// without ever serving genuinely stale info.
actor BlobRouteClient {
// MARK: - Errors
enum ClientError: Error, LocalizedError {
case fetchFailed(status: Int)
case decodingFailed(underlying: Error)
case unknownOrigin(iata: String)
var errorDescription: String? {
switch self {
case .fetchFailed(let status):
return "Route catalog fetch failed (HTTP \(status))."
case .decodingFailed(let error):
return "Could not parse route catalog: \(error.localizedDescription)"
case .unknownOrigin(let iata):
return "No route catalog available for \(iata)."
}
}
}
// MARK: - Properties
private let session: URLSession
private var inMemoryCache: [String: BlobRouteCatalog] = [:]
private static let blobBase = URL(string:
"https://g80l6xxwjkrjoai7.public.blob.vercel-storage.com")!
private static let cacheTTL: TimeInterval = 24 * 60 * 60
// MARK: - Init
init() {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 30
// Catalogs are ~50KB-6MB each let URLSession deflate on the
// wire even though we don't read the Content-Encoding header.
config.httpAdditionalHeaders = [
"Accept": "application/json",
"Accept-Encoding": "gzip, deflate, br",
]
session = URLSession(configuration: config)
}
// MARK: - Public API
/// Return the full route catalog for `origin`, using the in-memory
/// cache first, then the on-disk cache (if not expired), then the
/// network as a last resort.
func catalog(for origin: String) async throws -> BlobRouteCatalog {
let key = origin.uppercased()
if let cached = inMemoryCache[key] { return cached }
if let disk = loadDiskCache(origin: key) {
inMemoryCache[key] = disk
return disk
}
let fresh = try await fetchCatalog(origin: key)
inMemoryCache[key] = fresh
saveDiskCache(origin: key, catalog: fresh)
return fresh
}
/// List of IATAs that `origin` serves as destinations, sorted by
/// weekly frequency (busiest first). Used by the "Where can I go?"
/// view to populate the destination list.
func destinations(from origin: String) async throws -> [BlobRoute] {
let catalog = try await catalog(for: origin)
return catalog.routes.sorted { $0.freq > $1.freq }
}
/// `true` if `origin` directly serves `destination` per the catalog.
/// Used by the connection finder to validate the second leg of a
/// candidate (`origin` `via` `destination`).
func serves(origin: String, destination: String) async -> Bool {
do {
let catalog = try await catalog(for: origin)
return catalog.routes.contains { $0.dest == destination.uppercased() }
} catch {
return false
}
}
// MARK: - Network
private func fetchCatalog(origin: String) async throws -> BlobRouteCatalog {
let url = Self.blobBase
.appendingPathComponent("data")
.appendingPathComponent("routes")
.appendingPathComponent("\(origin).json")
let (data, response) = try await session.data(from: url)
guard let http = response as? HTTPURLResponse else {
throw ClientError.fetchFailed(status: -1)
}
// 404 means no per-origin catalog small airport not in the
// bundle. Surface as a clean "unknown" rather than a generic error.
if http.statusCode == 404 {
throw ClientError.unknownOrigin(iata: origin)
}
guard (200..<300).contains(http.statusCode) else {
throw ClientError.fetchFailed(status: http.statusCode)
}
do {
return try JSONDecoder().decode(BlobRouteCatalog.self, from: data)
} catch {
throw ClientError.decodingFailed(underlying: error)
}
}
// MARK: - Disk cache
/// Cache file path: `<Caches>/BlobRouteCatalog/<IATA>.json`.
private func diskCacheURL(origin: String) -> URL? {
guard let cacheDir = FileManager.default.urls(
for: .cachesDirectory, in: .userDomainMask
).first else { return nil }
let dir = cacheDir.appendingPathComponent("BlobRouteCatalog", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir.appendingPathComponent("\(origin).json")
}
private func loadDiskCache(origin: String) -> BlobRouteCatalog? {
guard let url = diskCacheURL(origin: origin) else { return nil }
guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path),
let modDate = attrs[.modificationDate] as? Date,
Date().timeIntervalSince(modDate) < Self.cacheTTL,
let data = try? Data(contentsOf: url)
else { return nil }
return try? JSONDecoder().decode(BlobRouteCatalog.self, from: data)
}
private func saveDiskCache(origin: String, catalog: BlobRouteCatalog) {
guard let url = diskCacheURL(origin: origin) else { return }
if let data = try? JSONEncoder().encode(catalog) {
try? data.write(to: url, options: .atomic)
}
}
}
// MARK: - Models (mirrors Vercel blob shape)
struct BlobRouteCatalog: Codable, Sendable {
let airport: String
let updated: String?
let stats: BlobRouteStats
let routes: [BlobRoute]
}
struct BlobRouteStats: Codable, Sendable {
let destinations: Int
let airlines: Int
let countries: Int
let totalWeeklyFlights: Int
let totalWeeklySeats: Int?
let avgDistance: Int?
let seasonalRoutes: Int?
}
struct BlobRoute: Codable, Sendable, Identifiable {
let dest: String
let airlines: [String]
let freq: Int
let dist: Int
let totalSeats: Int?
let avgDuration: Int?
let equipment: [String]?
let bodyTypes: [String]?
let isSeasonal: Bool?
let mealService: String?
let effectiveDates: [BlobEffectiveDate]?
let daysOfWeek: String?
var id: String { dest }
/// Returns `true` when the requested date falls inside any of the
/// effective-date windows AND the day-of-week is in `daysOfWeek`.
/// Used to gate seasonal routes off the "Where can I go?" list and
/// out of connection candidates on dates the route isn't operating.
func isOperating(on date: Date) -> Bool {
// Day-of-week check: catalog uses ISO-8601 (1=Mon 7=Sun).
// `Calendar.component(.weekday, ...)` returns 1=Sun 7=Sat map
// Sun7, others shift by -1.
let calendar = Calendar(identifier: .gregorian)
let weekdaySun1 = calendar.component(.weekday, from: date)
let iso = weekdaySun1 == 1 ? 7 : weekdaySun1 - 1
if let dow = daysOfWeek, !dow.contains(String(iso)) { return false }
guard let windows = effectiveDates, !windows.isEmpty else {
// No window list provided assume year-round.
return true
}
let df = DateFormatter()
df.dateFormat = "yyyyMMdd"
df.calendar = Calendar(identifier: .gregorian)
df.timeZone = TimeZone(identifier: "UTC")
for window in windows {
guard let from = df.date(from: window.from),
let to = df.date(from: window.to) else { continue }
// Inclusive on both ends the catalog's intent.
let endOfTo = Calendar(identifier: .gregorian)
.date(byAdding: .day, value: 1, to: to) ?? to
if date >= from && date < endOfTo { return true }
}
return false
}
}
struct BlobEffectiveDate: Codable, Sendable, Hashable {
let from: String
let to: String
}
+184
View File
@@ -0,0 +1,184 @@
import Foundation
/// Parses a flight-history CSV export into LoggedFlight candidates.
/// Today the only format we detect is Southwest's PNR-level export
/// (the one with columns like `Flt No`, `ORG`, `DST`, `Dep Date`,
/// `OPNG Flt`), which is what the user's existing log is in. Adding
/// another format is mechanically just another `Format` case + a
/// `parseSouthwest`-style mapper.
struct CSVFlightImporter {
enum Format: String, CaseIterable {
case southwest // SWA PNR export
case unknown
}
struct ParsedFlight {
let flightDate: Date
let scheduledDeparture: Date?
let carrierIATA: String?
let carrierICAO: String?
let flightNumber: String?
let departureIATA: String
let arrivalIATA: String
let pnr: String?
}
enum ImportError: LocalizedError {
case unsupportedFormat
case empty
case parseFailed(String)
var errorDescription: String? {
switch self {
case .unsupportedFormat: return "This CSV's column layout isn't one we know yet."
case .empty: return "The file is empty."
case .parseFailed(let s): return "Couldn't parse the file: \(s)"
}
}
}
// MARK: - Entry
static func parse(_ data: Data) throws -> [ParsedFlight] {
guard let text = String(data: data, encoding: .utf8)
?? String(data: data, encoding: .isoLatin1) else {
throw ImportError.parseFailed("not text")
}
let rows = parseRows(text)
guard let header = rows.first, rows.count > 1 else {
throw ImportError.empty
}
switch detect(header: header) {
case .southwest:
return try parseSouthwest(rows: Array(rows.dropFirst()), header: header)
case .unknown:
throw ImportError.unsupportedFormat
}
}
static func detect(header: [String]) -> Format {
let normalized = Set(header.map { $0.trimmingCharacters(in: .whitespaces).lowercased() })
let swKeys: Set<String> = ["flt no", "org", "dst", "dep date", "opng flt"]
if swKeys.isSubset(of: normalized) {
return .southwest
}
return .unknown
}
// MARK: - Southwest mapper
private static func parseSouthwest(rows: [[String]], header: [String]) throws -> [ParsedFlight] {
let index = Dictionary(uniqueKeysWithValues: header.enumerated().map {
($1.trimmingCharacters(in: .whitespaces).lowercased(), $0)
})
func col(_ row: [String], _ key: String) -> String? {
guard let i = index[key], i < row.count else { return nil }
let v = row[i].trimmingCharacters(in: .whitespaces)
return v.isEmpty ? nil : v
}
let depFmt = DateFormatter()
depFmt.dateFormat = "MM/dd/yyyy h:mm a"
depFmt.locale = Locale(identifier: "en_US_POSIX")
depFmt.timeZone = TimeZone(identifier: "UTC") // No tz in the file store as UTC so daily aggregation is stable.
var out: [ParsedFlight] = []
out.reserveCapacity(rows.count)
for row in rows {
guard let depRaw = col(row, "dep date"),
let scheduledDep = depFmt.date(from: depRaw),
let org = col(row, "org"),
let dst = col(row, "dst")
else { continue }
let flightNum = col(row, "flt no")
let pnr = col(row, "pnr no")
// OPNG Flt is "WN1484"; the leading 2 letters are the
// marketing carrier. Default to WN since every row in the
// SW export is Southwest.
let opng = col(row, "opng flt") ?? ""
let carrierIATA = String(opng.prefix(while: { $0.isLetter }))
.uppercased()
.nonEmpty() ?? "WN"
let carrierICAO: String = {
switch carrierIATA {
case "WN": return "SWA"
default: return AircraftRegistry.shared.lookup(iata: carrierIATA)?.icao ?? carrierIATA
}
}()
// Strip the day-of so we can dedupe across the user's
// existing manual entries without worrying about UTC roll.
let day = Calendar(identifier: .gregorian).startOfDay(for: scheduledDep)
out.append(ParsedFlight(
flightDate: day,
scheduledDeparture: scheduledDep,
carrierIATA: carrierIATA,
carrierICAO: carrierICAO,
flightNumber: flightNum,
departureIATA: org.uppercased(),
arrivalIATA: dst.uppercased(),
pnr: pnr
))
}
return out
}
// MARK: - CSV parser
//
// Simple state machine that handles RFC 4180 basics: quoted
// fields, embedded commas inside quotes, and "" " escaping.
// Mac-style and Windows line endings both work because we strip
// CR before splitting on LF.
private static func parseRows(_ text: String) -> [[String]] {
let normalized = text.replacingOccurrences(of: "\r\n", with: "\n")
.replacingOccurrences(of: "\r", with: "\n")
var rows: [[String]] = []
var field = ""
var row: [String] = []
var inQuotes = false
var i = normalized.startIndex
while i < normalized.endIndex {
let c = normalized[i]
if inQuotes {
if c == "\"" {
let next = normalized.index(after: i)
if next < normalized.endIndex && normalized[next] == "\"" {
field.append("\"")
i = next
} else {
inQuotes = false
}
} else {
field.append(c)
}
} else {
switch c {
case "\"":
inQuotes = true
case ",":
row.append(field)
field = ""
case "\n":
row.append(field)
rows.append(row)
row = []
field = ""
default:
field.append(c)
}
}
i = normalized.index(after: i)
}
// Last row (file may not end in newline)
if !field.isEmpty || !row.isEmpty {
row.append(field)
rows.append(row)
}
return rows
}
}
private extension String {
func nonEmpty() -> String? { isEmpty ? nil : self }
}
@@ -0,0 +1,119 @@
import Foundation
import EventKit
/// Scans the user's iOS calendars for events that look like flights and
/// returns parsed candidates. The user confirms each one in
/// `CalendarImportView` before anything lands in the log.
///
/// Detection is pattern-based on the event title we look for any
/// `[A-Z]{2,3}\s*\d{1,4}` substring like "WN 7" / "SWA7" / "AA2178".
/// We also try to pull a route hint ("DFW HOU") if the title or
/// notes carry one.
@MainActor
final class CalendarFlightImporter {
let store: EKEventStore
init(store: EKEventStore = EKEventStore()) {
self.store = store
}
struct Candidate: Identifiable {
let id = UUID()
let event: EKEvent
let carrierIATA: String?
let flightNumber: String?
let departureIATA: String?
let arrivalIATA: String?
var flightDate: Date { event.startDate }
var flightLabel: String { "\(carrierIATA ?? "?")\(flightNumber ?? "?")" }
}
/// Request calendar access via the modern API.
func requestAccess() async -> Bool {
if #available(iOS 17.0, *) {
do {
return try await store.requestFullAccessToEvents()
} catch {
return false
}
} else {
return await withCheckedContinuation { cont in
store.requestAccess(to: .event) { granted, _ in
cont.resume(returning: granted)
}
}
}
}
/// Scan all calendars between `from` and `to` for flight-shaped events.
/// Default range: last 5 years through next 30 days, which is enough
/// to catch most users' existing history without going overboard.
func scan(
from: Date = Calendar.current.date(byAdding: .year, value: -5, to: Date()) ?? Date(),
to: Date = Calendar.current.date(byAdding: .day, value: 30, to: Date()) ?? Date()
) -> [Candidate] {
// EventKit caps the search window break it into yearly chunks
// so we cover the full lookback even when the user has 5+ years
// of calendar history.
var out: [Candidate] = []
var cursor = from
let chunk: TimeInterval = 365 * 24 * 60 * 60
while cursor < to {
let end = min(cursor.addingTimeInterval(chunk), to)
let predicate = store.predicateForEvents(withStart: cursor, end: end, calendars: nil)
let events = store.events(matching: predicate)
for e in events {
if let c = parse(e) { out.append(c) }
}
cursor = end
}
return out
}
private func parse(_ event: EKEvent) -> Candidate? {
let haystack = [event.title, event.notes, event.location]
.compactMap { $0 }
.joined(separator: " ")
guard let match = matchFlightCode(in: haystack) else { return nil }
let route = matchRoute(in: haystack)
return Candidate(
event: event,
carrierIATA: match.carrier,
flightNumber: match.number,
departureIATA: route?.from,
arrivalIATA: route?.to
)
}
/// Find the first flight-code-shaped substring. Allows a single
/// space between letters and digits (e.g. "WN 7", "AA 2178").
private func matchFlightCode(in s: String) -> (carrier: String, number: String)? {
let pattern = "([A-Z]{2,3})\\s*([0-9]{1,4})"
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
let range = NSRange(s.startIndex..., in: s)
for m in regex.matches(in: s, range: range) where m.numberOfRanges == 3 {
guard let cRange = Range(m.range(at: 1), in: s),
let nRange = Range(m.range(at: 2), in: s)
else { continue }
let carrier = String(s[cRange])
// Filter false positives: skip common 2-letter codes that
// aren't airlines but show up a lot in event titles.
let denylist: Set<String> = ["AM", "PM", "ET", "PT", "CT", "MT", "US", "UK", "AS"]
if denylist.contains(carrier) { continue }
return (carrier, String(s[nRange]))
}
return nil
}
/// Find a "XXX YYY" or "XXX-YYY" route hint.
private func matchRoute(in s: String) -> (from: String, to: String)? {
let pattern = "([A-Z]{3})\\s*(?:[-→>]|to)\\s*([A-Z]{3})"
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
let range = NSRange(s.startIndex..., in: s)
guard let m = regex.firstMatch(in: s, range: range), m.numberOfRanges == 3,
let fRange = Range(m.range(at: 1), in: s),
let tRange = Range(m.range(at: 2), in: s)
else { return nil }
return (String(s[fRange]), String(s[tRange]))
}
}
@@ -0,0 +1,74 @@
import Foundation
import SwiftUI
/// Process-wide collector for bundled-resource decode failures.
///
/// The app ships a handful of reference JSON blobs (BTS aggregates, jumpseat
/// rules, crewbases, aircraft equipment catalog, TSA wait baselines). Each
/// loader has a `catch` block that prints the error and falls back to empty
/// data which means the UI silently shows "no data" when something is
/// actually broken (file missing from the bundle, schema drift, corrupt JSON).
///
/// `DataIntegrityMonitor` is the central place those loaders report into.
/// Failures are surfaced as a dismissible banner in `RootView` so the user
/// at least knows something didn't load instead of being told "no data" with
/// no context.
///
/// Lifetime is process-scoped: clearing the banner just hides it for the
/// remainder of the session; the next launch re-runs all loaders and the
/// banner can re-appear if anything still fails.
@MainActor
final class DataIntegrityMonitor: ObservableObject {
static let shared = DataIntegrityMonitor()
/// Human-readable list of bundled-resource decode failures. One entry
/// per reported failure in the form `"<resource>: <error>"`.
@Published var failures: [String] = []
/// Human-readable list of SwiftData save failures. Tracked separately
/// from decode failures because the user can act on these (their edits
/// didn't persist) and the visual treatment is different (red banner,
/// not yellow).
@Published var saveFailures: [String] = []
/// True when at least one decode failure has been reported this
/// session (banner uncleared).
var hasFailures: Bool { !failures.isEmpty }
/// True when at least one save failure has been reported this session.
var hasSaveFailures: Bool { !saveFailures.isEmpty }
private init() {}
/// Append a decode failure for `resource` (a basename like
/// `bts_bundle.json`). Also prints to stdout so the failure shows up
/// in the Xcode console exactly like the existing per-loader logs.
func report(_ resource: String, error: Error) {
let entry = "\(resource): \(error.localizedDescription)"
failures.append(entry)
print("[DataIntegrityMonitor] \(entry)")
}
/// Append a save failure for `operation` (a short verb like "save flight"
/// or "delete flight"). The user-facing banner uses these to warn that
/// their last edit didn't persist.
func reportSaveFailure(_ operation: String, error: Error) {
let entry = "\(operation): \(error.localizedDescription)"
saveFailures.append(entry)
print("[DataIntegrityMonitor] SAVE FAILED — \(entry)")
}
/// Hide the decode-failure banner for the rest of the session. Does
/// not persist failures may re-surface on the next launch if loaders
/// still fail.
func clear() {
failures.removeAll()
}
/// Clear the save-failure list. Call after a successful retry, or
/// when the user acknowledges the banner.
func clearSaveFailures() {
saveFailures.removeAll()
}
}
@@ -0,0 +1,178 @@
import Foundation
/// The slice of `AircraftRotationTracker` the cascade predictor consumes.
/// Lets the Phase-1 cascade tests inject a deterministic rotation history
/// without standing up a real OpenSky client.
protocol AircraftRotationProvider: Sendable {
func rotation(forICAO24 icao24: String, lookbackHours: Int) async -> [AircraftRotationTracker.RotationSegment]
}
extension AircraftRotationTracker: AircraftRotationProvider {}
/// Predicts downstream delay propagation for a scheduled flight by looking
/// at the operating aircraft's most recent rotation segment. The model is
/// intentionally simple narrowbody turns absorb ~45 minutes of upstream
/// late-arrival before they push the downstream block time.
actor DelayCascadePredictor {
static let shared = DelayCascadePredictor()
struct CascadePrediction: Sendable {
let confidence: Double
let predictedDelayMin: Int
let basis: String
let upstreamSegment: AircraftRotationTracker.RotationSegment?
}
private let tracker: AircraftRotationProvider
/// Minimum turn time we credit a narrowbody (737/A320 family) with.
/// Anything less than this on the upstream delay is absorbed by the
/// scheduled ground time and won't cascade.
private static let narrowbodyTurnMinutes = 45
/// We only report a propagated delay if the upstream segment landed
/// at least this many minutes after the downstream's scheduled
/// departure (or close to it). Below this threshold a quick turn
/// is realistic.
private static let upstreamLateThresholdMinutes = 15
init(tracker: AircraftRotationProvider = AircraftRotationTracker()) {
self.tracker = tracker
}
/// Predict downstream delay. Returns nil when we can't make a
/// meaningful prediction no aircraft, no rotation data, or the
/// aircraft isn't actually positioned to operate this flight.
func predict(carrier: String,
flightNumber: Int,
scheduledDeparture: Date,
departureICAO: String,
operatingICAO24: String?) async -> CascadePrediction? {
guard let icao24 = operatingICAO24?.trimmingCharacters(in: .whitespacesAndNewlines),
!icao24.isEmpty else {
print("[DelayCascade] no aircraft assigned — skipping prediction")
return nil
}
let rotation = await tracker.rotation(forICAO24: icao24, lookbackHours: 18)
guard let lastSegment = rotation.last else {
print("[DelayCascade] no rotation history for icao24=\(icao24)")
return nil
}
let normalizedScheduledStation = Self.normalizeStation(departureICAO)
let normalizedSegmentArrival = Self.normalizeStation(lastSegment.arrivalICAO ?? "")
// If the aircraft's last leg didn't land at our departure
// station, this rotation isn't relevant. (Either we have the
// wrong tail or the aircraft is still mid-rotation.)
// Comparison is form-agnostic: a 3-letter IATA on one side and
// a 4-letter ICAO for the same airport on the other compare
// equal see `stationsMatch` for the matrix.
guard !normalizedSegmentArrival.isEmpty,
Self.stationsMatch(normalizedScheduledStation, normalizedSegmentArrival) else {
print("[DelayCascade] last segment arrived at \(normalizedSegmentArrival.isEmpty ? "?" : normalizedSegmentArrival), need \(normalizedScheduledStation) — no prediction")
return nil
}
// Compute upstream lateness against the downstream's scheduled
// departure. If the aircraft arrived early or on time relative
// to scheduled departure, the turn will absorb everything.
let lateMinutes = Int((lastSegment.arrivalTime.timeIntervalSince(scheduledDeparture) / 60.0).rounded())
let upstreamDelay = max(0, lateMinutes + Self.narrowbodyTurnMinutes)
// upstreamDelay here is "how late after touchdown the aircraft
// must depart": touchdown + 45min minimum turn. If
// scheduledDeparture is later than that, no cascade.
_ = upstreamDelay
let earliestPushback = lastSegment.arrivalTime.addingTimeInterval(Double(Self.narrowbodyTurnMinutes) * 60)
let propagatedMinutes = Int((earliestPushback.timeIntervalSince(scheduledDeparture) / 60.0).rounded())
guard propagatedMinutes > 0 else {
print("[DelayCascade] turn absorbs upstream — earliest pushback \(earliestPushback) vs scheduled \(scheduledDeparture)")
return nil
}
// Also gate on the raw upstream lateness; a 5-minute late
// arrival isn't worth surfacing as a cascade.
guard lateMinutes >= Self.upstreamLateThresholdMinutes ||
propagatedMinutes >= Self.upstreamLateThresholdMinutes else {
print("[DelayCascade] upstream only \(lateMinutes)min late — below threshold")
return nil
}
let confidence = Self.confidence(propagatedMinutes: propagatedMinutes, lateMinutes: lateMinutes)
let basis = Self.basisString(
icao24: icao24,
lateMinutes: max(lateMinutes, propagatedMinutes),
upstreamFromICAO: lastSegment.departureICAO
)
print("[DelayCascade] \(carrier)\(flightNumber) at \(normalizedScheduledStation): +\(propagatedMinutes)min cascade (\(basis))")
return CascadePrediction(
confidence: confidence,
predictedDelayMin: propagatedMinutes,
basis: basis,
upstreamSegment: lastSegment
)
}
// MARK: - Helpers
private static func basisString(icao24: String, lateMinutes: Int, upstreamFromICAO: String?) -> String {
let tail = icao24.uppercased()
if let from = upstreamFromICAO?.uppercased(), !from.isEmpty {
return "Aircraft \(tail) landed \(lateMinutes)min late from \(from)"
}
return "Aircraft \(tail) landed \(lateMinutes)min late"
}
/// Trim + uppercase. Returned form is whatever the caller passed
/// us we don't try to map IATAICAO here, `stationsMatch` does that.
private static func normalizeStation(_ raw: String) -> String {
raw.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
}
/// Compare two airport codes that may be in different forms (IATA
/// 3-letter vs ICAO 4-letter). Returns true when both forms refer to
/// the same airport.
///
/// We accept the following equivalences for US/Canada/Mexico (where
/// IATAICAO is a deterministic prefix transform):
/// - "KDFW" == "DFW" (US: drop leading K)
/// - "CYYZ" == "YYZ" (Canada: drop leading C)
/// - "MMMX" == "MMX" (Mexico: drop leading M) rare but covered.
/// Outside those regions we fall back to exact equality on the
/// uppercased form, which is the right answer for ICAOICAO and
/// IATAIATA comparisons.
private static func stationsMatch(_ a: String, _ b: String) -> Bool {
if a == b { return true }
let aShort = shortFormICAO(a)
let bShort = shortFormICAO(b)
return aShort == bShort
}
/// Map a 4-letter ICAO to its 3-letter IATA equivalent for the
/// regions where the mapping is a simple prefix drop. Returns the
/// input unchanged otherwise.
private static func shortFormICAO(_ code: String) -> String {
guard code.count == 4 else { return code }
if code.hasPrefix("K") { return String(code.dropFirst()) }
if code.hasPrefix("CY") { return String(code.dropFirst()) }
if code.hasPrefix("MM") { return String(code.dropFirst()) }
return code
}
/// Confidence rises with both the upstream lateness and the
/// propagated delay magnitude a 60-minute late upstream
/// arrival is a strong signal, a borderline 16-minute one less so.
private static func confidence(propagatedMinutes: Int, lateMinutes: Int) -> Double {
let signal = Double(max(propagatedMinutes, lateMinutes))
// Map 15min 0.5, 60min 0.9, 120min+ 0.95
let normalized = min(1.0, signal / 120.0)
let scaled = 0.4 + 0.55 * normalized
return (scaled * 100).rounded() / 100
}
}
+174
View File
@@ -0,0 +1,174 @@
import Foundation
import UIKit
/// File-backed, category-tagged logger any client (URLSession,
/// WKWebView delegate, gate-sheet polling loop) can append to. The
/// goal is forensic: when something fails opaquely on a real device
/// (Turnstile won't pass, /api/token always 403, FlightAware schema
/// drift), the user can hit Settings Tools Diagnostics, run the
/// failing scenario, share the resulting log file, and we have the
/// exact request/response/cookie/JS-console trail to reason from.
///
/// Format: each line is `<ISO8601>\t[CATEGORY]\tEVENT\tk=v\tk=v...`
/// TSV-shaped so a quick `grep` / `awk` / `cut` slices any field
/// without fragile regex.
///
/// One log file per app session: `Documents/Diagnostics/diag-<ts>.log`.
/// Documents is iCloud-backed and exposed via the Files app, so the
/// user can AirDrop it without needing a custom share button.
/// (We add one anyway in ``DiagnosticsView``.)
///
/// Writes go through a serial dispatch queue so callers can log from
/// any thread / actor without races.
final class DiagnosticLogger: @unchecked Sendable {
static let shared = DiagnosticLogger()
private let queue = DispatchQueue(label: "com.flights.diagnostic.logger", qos: .utility)
private var fileHandle: FileHandle?
let sessionID: String
let logFileURL: URL?
/// Master enable. Off by default in production so we don't spew
/// to disk during normal use; the user flips it on from the
/// Diagnostics screen before running a failing scenario.
private(set) var isEnabled: Bool = true
private init() {
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd-HHmmss"
formatter.timeZone = TimeZone(identifier: "UTC")
formatter.locale = Locale(identifier: "en_US_POSIX")
self.sessionID = formatter.string(from: Date())
guard let docs = FileManager.default.urls(
for: .documentDirectory, in: .userDomainMask
).first else {
self.logFileURL = nil
return
}
let dir = docs.appendingPathComponent("Diagnostics", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let url = dir.appendingPathComponent("diag-\(sessionID).log")
if !FileManager.default.fileExists(atPath: url.path) {
FileManager.default.createFile(atPath: url.path, contents: nil)
}
self.logFileURL = url
self.fileHandle = try? FileHandle(forWritingTo: url)
try? self.fileHandle?.seekToEnd()
writeBootHeader()
}
// MARK: - Public API
func setEnabled(_ enabled: Bool) {
queue.async { [weak self] in
self?.isEnabled = enabled
}
}
/// Append a single event. `fields` becomes tab-separated `k=v`
/// pairs. Values are flattened to `String(describing:)` then
/// have tabs / newlines escaped so the line stays parseable.
func log(_ category: String, _ event: String, _ fields: [String: Any] = [:]) {
guard isEnabled else { return }
let ts = Self.timestamp()
var line = "\(ts)\t[\(category)]\t\(event)"
// Sorted keys deterministic order in the file.
for k in fields.keys.sorted() {
guard let v = fields[k] else { continue }
line += "\t\(k)=\(Self.escape("\(v)"))"
}
line += "\n"
guard let data = line.data(using: .utf8) else { return }
queue.async { [weak self] in
self?.fileHandle?.write(data)
}
}
/// Flush and return all log files (newest first) so the
/// diagnostics screen can list them.
func allLogFiles() -> [URL] {
guard let dir = logFileURL?.deletingLastPathComponent() else { return [] }
guard let contents = try? FileManager.default.contentsOfDirectory(
at: dir, includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey]
) else { return [] }
return contents
.filter { $0.pathExtension == "log" }
.sorted { (a, b) in
let aDate = (try? a.resourceValues(forKeys: [.contentModificationDateKey])
.contentModificationDate) ?? .distantPast
let bDate = (try? b.resourceValues(forKeys: [.contentModificationDateKey])
.contentModificationDate) ?? .distantPast
return aDate > bDate
}
}
/// Wipe all log files. Currently-open session continues into a
/// fresh file at the same path. Used by the "Clear all" button.
func clearAll() {
let openURL = logFileURL
for url in allLogFiles() where url != openURL {
try? FileManager.default.removeItem(at: url)
}
// Truncate the current session's file too.
if let url = openURL {
queue.async { [weak self] in
try? self?.fileHandle?.close()
try? "".write(to: url, atomically: true, encoding: .utf8)
self?.fileHandle = try? FileHandle(forWritingTo: url)
try? self?.fileHandle?.seekToEnd()
self?.writeBootHeader()
}
}
}
// MARK: - Boot header (device fingerprint)
/// Writes a structured header with device + app context so the
/// log is self-describing when shared.
private func writeBootHeader() {
let info = Bundle.main.infoDictionary ?? [:]
let appVersion = info["CFBundleShortVersionString"] as? String ?? "?"
let appBuild = info["CFBundleVersion"] as? String ?? "?"
let device = UIDevice.current
let screen = UIScreen.main
let locale = Locale.current
let tz = TimeZone.current.identifier
let isSim: Bool = {
#if targetEnvironment(simulator)
return true
#else
return false
#endif
}()
log("BOOT", "session", [
"sessionID": sessionID,
"isSimulator": isSim,
"appVersion": appVersion,
"appBuild": appBuild,
"deviceModel": device.model,
"systemName": device.systemName,
"systemVersion": device.systemVersion,
"name": device.name,
"screen": "\(screen.bounds.width)x\(screen.bounds.height)@\(screen.scale)",
"locale": locale.identifier,
"tz": tz,
"preferredLanguages": Locale.preferredLanguages.prefix(3).joined(separator: ","),
])
}
// MARK: - Helpers
private static func timestamp() -> String {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return f.string(from: Date())
}
/// Escape tabs/newlines so a value can't break the TSV shape.
private static func escape(_ s: String) -> String {
s.replacingOccurrences(of: "\t", with: " ")
.replacingOccurrences(of: "\n", with: "\\n")
.replacingOccurrences(of: "\r", with: "\\r")
}
}
+255
View File
@@ -0,0 +1,255 @@
import Foundation
/// Compares the scheduled aircraft type for a flight against what's actually flying
/// it today. Surfaces equipment swaps that matter for standby travelers a smaller
/// bird means fewer open seats, a bigger one means better odds.
actor EquipmentSwapService {
static let shared = EquipmentSwapService()
// MARK: - Public types
enum SwapSeverity: Sendable {
case none
case minor
case significant
}
struct EquipmentSwapResult: Sendable {
let scheduledName: String
let scheduledSeats: Int
let liveName: String?
let liveSeats: Int?
let seatDelta: Int?
let severity: SwapSeverity
let summary: String
}
// MARK: - JSON model
//
// Phase-2 schema (schemaVersion 2) nests seat counts per carrier:
// iata.<code>.default generic fallback (name + seats + body)
// iata.<code>.byCarrier.<C> per-carrier override (seats + cabins + source)
//
// We keep back-compat with the original flat schema (where each
// IATA mapped directly to {name, seats, body}) by trying that decode
// path if the nested form isn't present.
private struct CarrierSeats: Decodable {
let seats: Int
}
private struct DefaultSeats: Decodable {
let name: String
let seats: Int
let body: String?
}
/// One IATA entry. Either nested (default + byCarrier) or flat (name/seats/body
/// at the top level). We decode both shapes.
private struct IATAEntry: Decodable {
let `default`: DefaultSeats?
let byCarrier: [String: CarrierSeats]?
// Flat back-compat fields.
let flatName: String?
let flatSeats: Int?
let flatBody: String?
private enum CodingKeys: String, CodingKey {
case `default`
case byCarrier
case name
case seats
case body
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.default = try c.decodeIfPresent(DefaultSeats.self, forKey: .default)
self.byCarrier = try c.decodeIfPresent([String: CarrierSeats].self, forKey: .byCarrier)
self.flatName = try c.decodeIfPresent(String.self, forKey: .name)
self.flatSeats = try c.decodeIfPresent(Int.self, forKey: .seats)
self.flatBody = try c.decodeIfPresent(String.self, forKey: .body)
}
/// Display name pulled from whichever shape decoded.
var displayName: String? {
self.default?.name ?? flatName
}
/// Body code if present (kept for symmetry; not currently surfaced).
var bodyType: String? {
self.default?.body ?? flatBody
}
/// Default seat count used when no carrier match.
var defaultSeats: Int? {
self.default?.seats ?? flatSeats
}
/// Look up carrier-specific seats with fallback to default.
func seats(forCarrier carrier: String?) -> Int? {
if let carrier = carrier?.uppercased(),
let perCarrier = byCarrier?[carrier]?.seats {
return perCarrier
}
return defaultSeats
}
}
private struct Catalog: Decodable {
let iata: [String: IATAEntry]
let icao: [String: String]
}
// MARK: - State
private var catalog: Catalog?
private var didAttemptLoad = false
// MARK: - Public API
/// Compares scheduled vs live equipment. Returns `nil` only when both inputs are nil.
/// If the scheduled type can't be resolved against the catalog the call also returns nil
/// (we have nothing meaningful to say without a baseline).
///
/// `carrier` is the operating airline's IATA code; when supplied we prefer
/// `byCarrier[carrier].seats` over the generic default seat count.
///
/// `btsBaselineSeats` is an optional fallback for the **scheduled** seat
/// count: when the caller doesn't have an explicit scheduled-equipment
/// IATA (e.g. FR24-sourced live flights where only the operating ICAO
/// type is known) but does have a BTS historical seat-count for the
/// route, the comparison is still meaningful live aircraft vs.
/// the route's typical aircraft size. The card surfaces with a
/// "Typical equipment" label instead of a specific scheduled type.
func check(
scheduledEquipmentIATA: String?,
liveEquipmentICAO: String?,
carrier: String? = nil,
btsBaselineSeats: Int? = nil
) async -> EquipmentSwapResult? {
if scheduledEquipmentIATA == nil && liveEquipmentICAO == nil {
return nil
}
loadIfNeeded()
guard let catalog else {
print("[EquipmentSwap] catalog unavailable — bailing")
return nil
}
let scheduledKey = scheduledEquipmentIATA?.uppercased()
let liveIATAKey = Self.iataKey(forICAO: liveEquipmentICAO, catalog: catalog)
// Resolve the scheduled baseline. Three sources, in priority order:
// 1. An explicit scheduled IATA that resolves against the catalog.
// 2. A BTS-derived typical seat count for the route used when
// we only have an FR24 flight number / route and no real
// scheduled equipment, so the comparison becomes "today's
// aircraft vs the route's historical typical".
// 3. Nothing bail.
let scheduledName: String
let scheduledSeats: Int
if let scheduledKey,
let scheduledEntry = catalog.iata[scheduledKey],
let entryName = scheduledEntry.displayName,
let entrySeats = scheduledEntry.seats(forCarrier: carrier) {
scheduledName = entryName
scheduledSeats = entrySeats
} else if let baseline = btsBaselineSeats, baseline > 0 {
scheduledName = "Typical equipment for this route"
scheduledSeats = baseline
} else {
print("[EquipmentSwap] no scheduled baseline (catalog miss + no BTS) for \(scheduledEquipmentIATA ?? "nil")")
return nil
}
let liveEntry: IATAEntry? = liveIATAKey.flatMap { catalog.iata[$0] }
let liveName = liveEntry?.displayName
let liveSeats = liveEntry?.seats(forCarrier: carrier)
let seatDelta: Int? = liveSeats.map { $0 - scheduledSeats }
let severity: SwapSeverity = {
guard let seatDelta else { return .none }
let magnitude = abs(seatDelta)
if magnitude == 0 { return .none }
if magnitude > 15 { return .significant }
return .minor
}()
let summary = Self.summary(
scheduledKey: scheduledKey ?? "typical",
scheduledSeats: scheduledSeats,
liveKey: liveIATAKey,
liveSeats: liveSeats,
seatDelta: seatDelta
)
print("[EquipmentSwap] \(summary)")
return EquipmentSwapResult(
scheduledName: scheduledName,
scheduledSeats: scheduledSeats,
liveName: liveName,
liveSeats: liveSeats,
seatDelta: seatDelta,
severity: severity,
summary: summary
)
}
// MARK: - Loading
private func loadIfNeeded() {
if didAttemptLoad { return }
didAttemptLoad = true
guard let url = Bundle.main.url(forResource: "aircraft_seats", withExtension: "json") else {
print("[EquipmentSwap] aircraft_seats.json not found in bundle")
return
}
do {
let data = try Data(contentsOf: url)
catalog = try JSONDecoder().decode(Catalog.self, from: data)
print("[EquipmentSwap] loaded catalog: \(catalog?.iata.count ?? 0) IATA / \(catalog?.icao.count ?? 0) ICAO entries")
} catch {
print("[EquipmentSwap] decode failed: \(error)")
Task { @MainActor in
DataIntegrityMonitor.shared.report("aircraft_seats.json", error: error)
}
}
}
// MARK: - Helpers
private static func iataKey(forICAO icao: String?, catalog: Catalog) -> String? {
guard let raw = icao?.uppercased(), !raw.isEmpty else { return nil }
if let mapped = catalog.icao[raw] { return mapped }
// ICAO occasionally matches an IATA verbatim (rare). Allow that fallback.
if catalog.iata[raw] != nil { return raw }
return nil
}
private static func summary(
scheduledKey: String,
scheduledSeats: Int,
liveKey: String?,
liveSeats: Int?,
seatDelta: Int?
) -> String {
guard let liveKey, let liveSeats, let seatDelta else {
return "Scheduled: \(scheduledKey) (\(scheduledSeats) seats) — live equipment unknown"
}
if seatDelta == 0 {
return "Same equipment today: \(scheduledKey) (\(scheduledSeats))"
}
let prefix = seatDelta < 0 ? "Smaller bird today" : "Bigger bird today"
let magnitude = abs(seatDelta)
let direction = seatDelta < 0 ? "fewer" : "more"
return "\(prefix): \(scheduledKey) (\(scheduledSeats)) vs \(liveKey) (\(liveSeats)) — \(magnitude) \(direction) seats"
}
}
+201
View File
@@ -0,0 +1,201 @@
import Foundation
/// Live aircraft feed sourced from flightradar24.com's public
/// `/zones/fcgi/feed.js` endpoint. We use it as the primary live data
/// source because, unlike OpenSky's anonymous tier, FR24 aggregates
/// ASDE-X / MLAT / multiple ADS-B receivers and has solid ground
/// coverage at major airports i.e. the parked SWA jet at DAL that
/// OpenSky just doesn't return.
///
/// Feed format (positional array per aircraft):
/// [0] icao24 hex (uppercase)
/// [1] latitude
/// [2] longitude
/// [3] heading (deg true)
/// [4] altitude (feet, baro)
/// [5] ground speed (knots)
/// [6] squawk
/// [7] radar source id (e.g. "T-KDFW42") informational
/// [8] ICAO aircraft type designator ("B738")
/// [9] registration / tail number
/// [10] unix timestamp (seconds)
/// [11] departure airport IATA
/// [12] arrival airport IATA
/// [13] flight number with IATA carrier ("AA2152")
/// [14] on_ground (0/1)
/// [15] vertical rate (ft/min)
/// [16] callsign with ICAO carrier ("AAL2152")
/// [17] is_glider/special
/// [18] airline ICAO ("AAL")
///
/// The endpoint requires browser-shaped request headers (User-Agent +
/// Referer pointing at flightradar24.com). Plain curl is rejected.
actor FR24Client {
enum ClientError: LocalizedError {
case http(Int)
case decode(String)
case network(Error)
var errorDescription: String? {
switch self {
case .http(let c): return "FR24 returned HTTP \(c)."
case .decode(let s): return "Couldn't read FR24 response: \(s)."
case .network(let e): return e.localizedDescription
}
}
}
private let session: URLSession
init(session: URLSession = .shared) {
self.session = session
}
/// Fetch every aircraft currently inside the bbox. The map view passes
/// the visible region's corners typical bbox at city zoom returns
/// ~330 entries; continental zoom can return several hundred.
func states(latMin: Double, lonMin: Double, latMax: Double, lonMax: Double) async throws -> [LiveAircraft] {
// FR24 expects bounds in the order: latNorth, latSouth, lonWest, lonEast.
let bounds = String(format: "%.4f,%.4f,%.4f,%.4f", latMax, latMin, lonMin, lonMax)
var comps = URLComponents(string: "https://data-cloud.flightradar24.com/zones/fcgi/feed.js")!
comps.queryItems = [
URLQueryItem(name: "bounds", value: bounds),
URLQueryItem(name: "faa", value: "1"),
URLQueryItem(name: "satellite", value: "1"),
URLQueryItem(name: "mlat", value: "1"),
URLQueryItem(name: "flarm", value: "1"),
URLQueryItem(name: "adsb", value: "1"),
URLQueryItem(name: "gnd", value: "1"),
URLQueryItem(name: "air", value: "1"),
URLQueryItem(name: "vehicles", value: "1"),
URLQueryItem(name: "estimated", value: "1"),
URLQueryItem(name: "maxage", value: "14400"),
URLQueryItem(name: "gliders", value: "1"),
URLQueryItem(name: "stats", value: "1"),
]
var req = URLRequest(url: comps.url!)
req.timeoutInterval = 12
req.setValue(
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148",
forHTTPHeaderField: "User-Agent"
)
req.setValue("https://www.flightradar24.com/", forHTTPHeaderField: "Referer")
req.setValue("application/json", forHTTPHeaderField: "Accept")
let data: Data
let response: URLResponse
do {
(data, response) = try await session.data(for: req)
} catch {
throw ClientError.network(error)
}
guard let http = response as? HTTPURLResponse else {
throw ClientError.network(URLError(.badServerResponse))
}
guard (200..<300).contains(http.statusCode) else {
throw ClientError.http(http.statusCode)
}
return try parse(data: data)
}
private func parse(data: Data) throws -> [LiveAircraft] {
let root: Any
do {
root = try JSONSerialization.jsonObject(with: data, options: [])
} catch {
throw ClientError.decode("not json")
}
guard let dict = root as? [String: Any] else {
throw ClientError.decode("root not object")
}
var out: [LiveAircraft] = []
out.reserveCapacity(dict.count)
for (_, value) in dict {
guard let arr = value as? [Any] else { continue }
if arr.count < 18 { continue }
if let ac = Self.aircraft(from: arr) {
out.append(ac)
}
}
return out
}
/// Convert one positional entry into a LiveAircraft, returning nil
/// when required fields are missing (no position, no icao24).
private static func aircraft(from a: [Any]) -> LiveAircraft? {
guard let icaoRaw = a[0] as? String, !icaoRaw.isEmpty,
let lat = doubleVal(a[1]),
let lon = doubleVal(a[2]) else {
return nil
}
let heading = doubleVal(a[3])
let altFeet = intVal(a[4]) ?? 0
let speedKnots = doubleVal(a[5])
let squawkRaw = a[6] as? String
let modelType = nonEmpty(a[8] as? String)
let registration = nonEmpty(a[9] as? String)
let timestamp = intVal(a[10]) ?? Int(Date().timeIntervalSince1970)
let depIATA = nonEmpty(a[11] as? String)
let arrIATA = nonEmpty(a[12] as? String)
let flightIATA = nonEmpty(a[13] as? String)
let onGround = (intVal(a[14]) ?? 0) != 0
let vertRateFpm = doubleVal(a[15]) ?? 0
let callsign = nonEmpty(a[16] as? String)
let airlineICAO = nonEmpty(a.count > 18 ? a[18] as? String : nil)
// Unit conversions LiveAircraft stores baroAlt in meters,
// velocity in m/s, vertical rate in m/s (matching OpenSky).
let baroAltMeters: Double? = altFeet > 0 ? Double(altFeet) * 0.3048 : nil
let velocityMps: Double? = speedKnots.map { $0 * 0.514444 }
let vertRateMps: Double? = vertRateFpm != 0 ? vertRateFpm * 0.00508 : nil
return LiveAircraft(
icao24: icaoRaw.lowercased(),
callsign: callsign,
originCountry: "",
latitude: lat,
longitude: lon,
baroAltitude: baroAltMeters,
geoAltitude: nil,
velocity: velocityMps,
trueTrack: heading,
verticalRate: vertRateMps,
onGround: onGround,
squawk: nonEmpty(squawkRaw),
category: nil,
lastContact: Date(timeIntervalSince1970: TimeInterval(timestamp)),
enrichment: LiveAircraft.Enrichment(
modelType: modelType,
registration: registration,
flightIATA: flightIATA,
departureIATA: depIATA,
arrivalIATA: arrIATA,
airlineICAO: airlineICAO
)
)
}
private static func doubleVal(_ x: Any) -> Double? {
if let d = x as? Double { return d }
if let i = x as? Int { return Double(i) }
if let s = x as? String { return Double(s) }
return nil
}
private static func intVal(_ x: Any) -> Int? {
if let i = x as? Int { return i }
if let d = x as? Double { return Int(d) }
if let s = x as? String { return Int(s) }
return nil
}
private static func nonEmpty(_ s: String?) -> String? {
guard let s, !s.isEmpty else { return nil }
return s
}
}
+160
View File
@@ -0,0 +1,160 @@
import Foundation
/// Best-effort aircraft type lookup by scraping FlightAware's
/// `/live/flight/<callsign>` page. Their server embeds a
/// `trackpollBootstrap` JSON in the page source that contains an
/// `activityLog.flights[]` array each entry has an `aircraftType`
/// in ICAO designator form (B738, B38M, A21N, etc.), the route as
/// IATA codes, and the scheduled gate departure timestamp.
///
/// Pages are not Cloudflare-gated for direct GET requests with a
/// browser-shaped User-Agent. No auth required.
///
/// Matching strategy: prefer an activity-log entry whose route
/// matches the user's flight; otherwise fall back to the most common
/// `aircraftType` across the log (good proxy because flight numbers
/// usually keep the same equipment class across many days).
actor FlightAwareLookup {
static let shared = FlightAwareLookup()
private let session: URLSession
private var cache: [String: String?] = [:] // callsign -> "B738" or nil for miss
init(session: URLSession = .shared) {
self.session = session
}
/// Look up the ICAO aircraft type for one flight.
/// `callsign` is ICAO carrier + number, e.g. "SWA1942".
/// `departureIATA` + `arrivalIATA` are used to find the best
/// route match in the activity log.
func lookupType(
callsign: String,
departureIATA: String,
arrivalIATA: String
) async -> String? {
let key = "\(callsign)-\(departureIATA)-\(arrivalIATA)"
if let cached = cache[key] { return cached }
guard let url = URL(string: "https://flightaware.com/live/flight/\(callsign)") else {
cache[key] = nil
return nil
}
var req = URLRequest(url: url)
req.timeoutInterval = 10
req.setValue(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15",
forHTTPHeaderField: "User-Agent"
)
req.setValue("text/html,application/xhtml+xml", forHTTPHeaderField: "Accept")
do {
let (data, response) = try await session.data(for: req)
guard let http = response as? HTTPURLResponse,
(200..<300).contains(http.statusCode),
let html = String(data: data, encoding: .utf8)
else {
cache[key] = nil
return nil
}
let result = parse(html: html, dep: departureIATA, arr: arrivalIATA)
cache[key] = result
return result
} catch {
cache[key] = nil
return nil
}
}
// MARK: - Parsing
/// Find the `trackpollBootstrap` JSON and pull aircraft types from
/// its activity log. Brace-walking handles the trailing JS noise
/// after the object literal (no easy regex sentinel).
private func parse(html: String, dep: String, arr: String) -> String? {
guard let blob = extractTrackpollBootstrap(from: html),
let json = try? JSONSerialization.jsonObject(with: Data(blob.utf8)) as? [String: Any]
else { return nil }
// The bootstrap is `{flights: {<flightId>: {activityLog: {flights: [...]}}}}`.
// We don't know the key, so just take the first one.
guard let flights = json["flights"] as? [String: Any],
let first = flights.values.first as? [String: Any],
let activityLog = first["activityLog"] as? [String: Any],
let entries = activityLog["flights"] as? [[String: Any]],
!entries.isEmpty
else { return nil }
// Pull (route, type) pairs from each entry.
var byRoute: [String: [String]] = [:] // "DAL-HOU" ["B738", "B38M", ...]
var allTypes: [String] = []
for entry in entries {
guard let origin = entry["origin"] as? [String: Any],
let destination = entry["destination"] as? [String: Any],
let oIata = (origin["iata"] as? String)?.uppercased(),
let dIata = (destination["iata"] as? String)?.uppercased(),
let type = (entry["aircraftType"] as? String)?.uppercased(),
!type.isEmpty
else { continue }
let routeKey = "\(oIata)-\(dIata)"
byRoute[routeKey, default: []].append(type)
allTypes.append(type)
}
// 1) Exact route match most common type for that route
let routeKey = "\(dep)-\(arr)"
if let types = byRoute[routeKey], let top = mostCommon(types) {
return top
}
// 2) Reverse-direction match (return leg of same flight)
let reverseKey = "\(arr)-\(dep)"
if let types = byRoute[reverseKey], let top = mostCommon(types) {
return top
}
// 3) Most common across the entire activity log
return mostCommon(allTypes)
}
/// Locate `var trackpollBootstrap = {...};` in the page and
/// return just the `{...}` literal, brace-balanced.
private func extractTrackpollBootstrap(from html: String) -> String? {
guard let start = html.range(of: "var trackpollBootstrap"),
let openBrace = html.range(of: "{", range: start.upperBound..<html.endIndex)
else { return nil }
var depth = 0
var inString = false
var escaped = false
var endIdx = openBrace.lowerBound
var idx = openBrace.lowerBound
while idx < html.endIndex {
let ch = html[idx]
if escaped {
escaped = false
} else if ch == "\\" {
escaped = true
} else if ch == "\"" {
inString.toggle()
} else if !inString {
if ch == "{" { depth += 1 }
else if ch == "}" {
depth -= 1
if depth == 0 {
endIdx = html.index(after: idx)
break
}
}
}
idx = html.index(after: idx)
}
guard depth == 0 else { return nil }
return String(html[openBrace.lowerBound..<endIdx])
}
private func mostCommon(_ list: [String]) -> String? {
guard !list.isEmpty else { return nil }
var counts: [String: Int] = [:]
for v in list { counts[v, default: 0] += 1 }
return counts.max(by: { $0.value < $1.value })?.key
}
}
@@ -0,0 +1,614 @@
import Foundation
/// Resolves direct-flight schedules for a route+date by scraping two open
/// FlightAware web pages. Replaces ``RouteExplorerClient`` for the
/// destination-set search path now that route-explorer's `/api/token`
/// endpoint is gated behind Cloudflare Turnstile.
///
/// Pipeline (canonical reference: `scripts/probe_flightaware.py`):
///
/// 1. Resolve dep / arr IATAs to ICAO via ``AirportDatabase/icao(forIATA:)``.
/// 2. GET `https://flightaware.com/analysis/route.rvt?origin=<ICAO>&destination=<ICAO>`
/// and pull every distinct flight ident from its "Itemized List" table.
/// 3. For each ident: GET `https://flightaware.com/live/flight/<ident>`
/// and brace-balance-extract the inlined `var trackpollBootstrap = {...};`
/// JSON blob.
/// 4. From `flights[*].activityLog.flights`, project each leg whose
/// origin/destination match and whose `gateDepartureTimes.scheduled`
/// falls on the requested local-departure date (in the origin's TZ).
/// 5. Wrap each match as a single-leg ``RouteConnection`` and ship.
///
/// Boundary conditions:
/// * `activityLog` covers ~14 days back + ~12 days forward per ident.
/// Far-future dates return an empty result callers should surface a
/// "schedules become available within ~48h" hint.
/// * No auth, no cookies, no WKWebView. Plain `URLSession`. The user agent
/// is set to iOS Safari for parity with how `/live/flight/<ident>` renders
/// its JSON blob FlightAware's HTML shape is identical to what a real
/// browser receives, validated by `probe_flightaware.py`.
actor FlightAwareScheduleClient {
// MARK: - Errors
enum ClientError: Error, LocalizedError {
case unknownAirport(iata: String)
case routePageFailed(status: Int)
case noOperatingFlights
case trackpollMissing(ident: String)
case decodingFailed(underlying: Error)
var errorDescription: String? {
switch self {
case .unknownAirport(let iata):
return "We don't have an ICAO mapping for \(iata) yet."
case .routePageFailed(let status):
return "FlightAware route lookup failed (HTTP \(status))."
case .noOperatingFlights:
return "FlightAware lists no recent flights on this route."
case .trackpollMissing(let ident):
return "FlightAware returned no schedule for \(ident)."
case .decodingFailed(let error):
return "Could not parse FlightAware response: \(error.localizedDescription)"
}
}
}
// MARK: - Properties
private let session: URLSession
private let database: AirportDatabase
private let calendar: Calendar
private let blobClient: BlobRouteClient
/// Cap the number of distinct idents we fan out trackpoll fetches for.
/// Busy routes (DALHOU surfaces ~46 idents including private/business
/// jet callsigns we don't care about) would otherwise spend ~25 seconds
/// pulling 500 KB pages. 16 is enough for any commercial-carrier route.
private static let maxIdentsPerRoute = 16
/// Curated hub catalog. For a 1-stop search like DFWAMS we only
/// look for via-airports that show up here without this filter
/// we'd fan out blob fetches against every one of DFW's 263
/// destinations. Covers US majors, European majors, ME/Asia hubs
/// commonly used for transatlantic + transpacific connections.
private static let connectionHubs: Set<String> = [
// US majors
"ATL", "JFK", "LGA", "EWR", "ORD", "BOS", "IAH", "IAD", "PHL",
"CLT", "MSP", "DTW", "DEN", "LAX", "SFO", "SEA", "LAS", "MIA",
"DFW", "BWI", "DCA", "MCO", "FLL", "SLC", "PHX",
// Europe
"LHR", "LGW", "MAN", "CDG", "ORY", "FRA", "MUC", "AMS",
"MAD", "BCN", "FCO", "MXP", "IST", "ZRH", "VIE", "BRU",
"DUB", "LIS", "CPH", "ARN", "OSL", "HEL", "WAW", "PRG",
"BUD", "ATH",
// Middle East
"DXB", "DOH", "AUH", "TLV", "RUH", "JED", "AMM",
// Asia
"ICN", "NRT", "HND", "HKG", "SIN", "BKK", "PEK", "PVG",
"TPE", "KIX", "MNL", "KUL", "CGK", "DEL", "BOM",
// Oceania / Africa / Latam
"SYD", "MEL", "AKL", "JNB", "CPT", "ADD", "GRU", "EZE",
"MEX", "PTY", "BOG", "LIM", "SCL",
]
/// Layover bounds for 1-stop connections. 45 minutes is the
/// industry baseline for domestic minimum connection time;
/// 8 hours is the upper bound past which it's no longer a
/// reasonable single-day connection.
private static let minLayoverMinutes = 45
private static let maxLayoverMinutes = 8 * 60
// MARK: - Init
init(database: AirportDatabase, blobClient: BlobRouteClient = BlobRouteClient()) {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 25
config.requestCachePolicy = .reloadIgnoringLocalCacheData
session = URLSession(configuration: config)
self.database = database
self.blobClient = blobClient
var cal = Calendar(identifier: .gregorian)
cal.timeZone = TimeZone(identifier: "UTC")!
self.calendar = cal
}
// MARK: - Public API
/// Look up direct flights for `(origin destination, date)` and return
/// them as single-leg ``RouteConnection``s so the existing UI surface
/// (``ConnectionRow`` / ``RouteFlight`` consumers) keeps working.
///
/// `date` is treated as a calendar day in the **origin's local
/// timezone**. A search for "2026-06-06" returns flights whose
/// scheduled departure-day in the origin TZ equals 2026-06-06.
func searchDirectFlights(
from origin: String,
to destination: String,
date: Date
) async throws -> RouteSearchResult {
let depIATA = origin.uppercased()
let arrIATA = destination.uppercased()
guard let depICAO = database.icao(forIATA: depIATA) else {
throw ClientError.unknownAirport(iata: depIATA)
}
guard let arrICAO = database.icao(forIATA: arrIATA) else {
throw ClientError.unknownAirport(iata: arrIATA)
}
let idents = try await fetchOperatingIdents(
depICAO: depICAO, arrICAO: arrICAO
)
guard !idents.isEmpty else { throw ClientError.noOperatingFlights }
var legs: [RouteFlight] = []
var seenLegKeys = Set<String>()
// Fan out trackpoll fetches concurrently they're independent and
// dominate wall-clock for routes with many operating idents.
try await withThrowingTaskGroup(of: [RouteFlight].self) { group in
for ident in idents.prefix(Self.maxIdentsPerRoute) {
group.addTask { [self] in
do {
return try await fetchScheduledLegs(
ident: ident,
depIATA: depIATA,
arrIATA: arrIATA,
on: date
)
} catch {
// A single ident failing should not poison the whole
// route search fall through with empty results.
return []
}
}
}
for try await batch in group {
for leg in batch {
let key = leg.id
if seenLegKeys.insert(key).inserted {
legs.append(leg)
}
}
}
}
// One connection per direct leg, sorted by scheduled departure.
let sortedLegs = legs.sorted { $0.departure.dateTime < $1.departure.dateTime }
let connections = sortedLegs.map { leg in
RouteConnection(
durationMinutes: leg.durationMinutes,
score: 0,
flights: [leg]
)
}
return RouteSearchResult(connections: connections, appendix: nil)
}
/// Find 1-stop itineraries from `origin` to `destination` on `date`.
///
/// Algorithm:
/// 1. Pull `origin`'s blob route catalog list of destinations
/// it serves directly.
/// 2. Intersect with the curated `connectionHubs` set so we only
/// try hubs that plausibly have onward transatlantic /
/// transpacific service.
/// 3. For each candidate via-hub `H`, check the blob whether
/// `H` serves `destination` directly.
/// 4. For surviving `H`s, fan out two `searchDirectFlights` calls
/// in parallel: `origin H` on `date`, `H destination` on
/// `date` and `date + 1` (long-haul connections frequently
/// cross midnight in the layover hub).
/// 5. Join every `(leg1, leg2)` pair whose layover at `H` falls
/// inside `[minLayoverMinutes, maxLayoverMinutes]` and return
/// one ``RouteConnection`` per valid join.
///
/// Wall-clock budget: with ~10 candidate hubs surviving the filter
/// and 2 FA fetches per hub, we make ~20 HTTP requests in parallel.
/// On a warm network it returns in ~5s. We cap candidates to keep
/// the search bounded.
func searchOneStopConnections(
from origin: String,
to destination: String,
date: Date
) async -> [RouteConnection] {
let depIATA = origin.uppercased()
let arrIATA = destination.uppercased()
// Step 1+2: candidate via-hubs DFW serves AND that are curated.
let originDestinations: [BlobRoute]
do {
let catalog = try await blobClient.catalog(for: depIATA)
originDestinations = catalog.routes
} catch {
return []
}
let curatedCandidates = originDestinations.compactMap { route -> String? in
let via = route.dest
guard via != arrIATA else { return nil } // skip the direct
guard Self.connectionHubs.contains(via) else { return nil }
// Honour seasonality so we don't suggest a hub that's not
// operating from origin on `date`.
guard route.isOperating(on: date) else { return nil }
return via
}
// Step 3: filter to hubs that also serve `destination`.
var validVias: [String] = []
await withTaskGroup(of: (String, Bool).self) { group in
for via in curatedCandidates.prefix(30) {
group.addTask { [self] in
(via, await blobClient.serves(origin: via, destination: arrIATA))
}
}
for await (via, serves) in group {
if serves { validVias.append(via) }
}
}
guard !validVias.isEmpty else { return [] }
// Step 4: fan out 2 FA lookups per via-hub in parallel.
let nextDay = calendar.date(byAdding: .day, value: 1, to: date) ?? date
struct LegPair {
let leg1: [RouteFlight]
let leg2: [RouteFlight]
}
var pairsByVia: [String: LegPair] = [:]
await withTaskGroup(of: (String, [RouteFlight], [RouteFlight]).self) { group in
for via in validVias {
group.addTask { [self] in
async let leg1Result = (try? await searchDirectFlights(
from: depIATA, to: via, date: date
)) ?? RouteSearchResult(connections: [], appendix: nil)
async let leg2Today = (try? await searchDirectFlights(
from: via, to: arrIATA, date: date
)) ?? RouteSearchResult(connections: [], appendix: nil)
async let leg2Tomorrow = (try? await searchDirectFlights(
from: via, to: arrIATA, date: nextDay
)) ?? RouteSearchResult(connections: [], appendix: nil)
let (l1, l2t, l2n) = await (leg1Result, leg2Today, leg2Tomorrow)
let leg1Flights = l1.connections.compactMap { $0.flights.first }
let leg2Flights = (l2t.connections + l2n.connections)
.compactMap { $0.flights.first }
return (via, leg1Flights, leg2Flights)
}
}
for await (via, leg1Flights, leg2Flights) in group {
pairsByVia[via] = LegPair(leg1: leg1Flights, leg2: leg2Flights)
}
}
// Step 5: join legs by layover validity.
var connections: [RouteConnection] = []
for (_, pair) in pairsByVia {
for leg1 in pair.leg1 {
for leg2 in pair.leg2 {
let layoverMin = Int(
leg2.departure.dateTime.timeIntervalSince(leg1.arrival.dateTime) / 60
)
guard layoverMin >= Self.minLayoverMinutes,
layoverMin <= Self.maxLayoverMinutes
else { continue }
let total = leg1.durationMinutes + layoverMin + leg2.durationMinutes
connections.append(RouteConnection(
durationMinutes: total,
score: 0,
flights: [leg1, leg2]
))
}
}
}
return connections.sorted { $0.firstDeparture < $1.firstDeparture }
}
// MARK: - Step 1: route.rvt distinct idents
/// Parse the FlightAware "Route Analysis" page and return the distinct
/// flight idents that have recently operated this route. Order matches
/// the page (most-recent first), so the cap honors recency.
func fetchOperatingIdents(depICAO: String, arrICAO: String) async throws -> [String] {
let url = URL(string:
"https://flightaware.com/analysis/route.rvt"
+ "?origin=\(depICAO)&destination=\(arrICAO)"
)!
let html = try await fetchHTML(url: url)
return Self.parseIdents(routeHTML: html)
}
/// Strip tags, collapse whitespace, then match the row shape:
/// `<Dow> HH:MM[AP]M <TZ?> <IDENT> <ORIGIN_ICAO> ...`
/// Returns idents in first-seen order, deduped.
static func parseIdents(routeHTML: String) -> [String] {
let stripped = routeHTML
.replacingOccurrences(
of: #"<[^>]+>"#,
with: " ",
options: .regularExpression
)
.replacingOccurrences(
of: #"\s+"#,
with: " ",
options: .regularExpression
)
let pattern = #"(?:Sun|Mon|Tue|Wed|Thu|Fri|Sat)\s+\d{1,2}:\d{2}[AP]M.+?([A-Z]{2,3}\d{1,4})\s+[A-Z]{4}\s+"#
guard let regex = try? NSRegularExpression(
pattern: pattern,
options: [.dotMatchesLineSeparators]
) else { return [] }
let range = NSRange(stripped.startIndex..., in: stripped)
let matches = regex.matches(in: stripped, range: range)
var idents: [String] = []
var seen = Set<String>()
for m in matches where m.numberOfRanges >= 2 {
guard let r = Range(m.range(at: 1), in: stripped) else { continue }
let ident = String(stripped[r])
if seen.insert(ident).inserted {
idents.append(ident)
}
}
return idents
}
// MARK: - Step 2: trackpoll RouteFlight
/// Pull and project the `trackpollBootstrap` blob for a single ident.
func fetchScheduledLegs(
ident: String,
depIATA: String,
arrIATA: String,
on date: Date
) async throws -> [RouteFlight] {
let url = URL(string: "https://flightaware.com/live/flight/\(ident)")!
let html = try await fetchHTML(url: url)
guard let blob = Self.extractTrackpollBlob(from: html) else {
throw ClientError.trackpollMissing(ident: ident)
}
let decoded: TrackpollBootstrap
do {
decoded = try JSONDecoder().decode(
TrackpollBootstrap.self,
from: Data(blob.utf8)
)
} catch {
throw ClientError.decodingFailed(underlying: error)
}
let carrierIATA = Self.airlineIATA(forICAO: Self.identCarrierICAO(ident))
?? Self.identCarrierICAO(ident)
let flightNumber = Self.identFlightNumber(ident)
var legs: [RouteFlight] = []
for (_, flight) in decoded.flights {
for leg in flight.activityLog.flights {
guard leg.origin.iata == depIATA, leg.destination.iata == arrIATA
else { continue }
guard let depSec = leg.gateDepartureTimes?.scheduled,
let arrSec = leg.gateArrivalTimes?.scheduled
else { continue }
let depDate = Date(timeIntervalSince1970: TimeInterval(depSec))
let arrDate = Date(timeIntervalSince1970: TimeInterval(arrSec))
// Date filter is *origin-local*: a 23:50 departure on the 6th
// appears as the 7th in UTC for negative-offset airports.
let originTZ = Self.parseTZ(leg.origin.TZ)
if !Self.sameLocalDay(depDate, target: date, tz: originTZ) {
continue
}
let durationMin = Int((arrDate.timeIntervalSince(depDate)) / 60)
let endpoint = { (e: TrackpollEndpoint, instant: Date) -> RouteEndpoint in
RouteEndpoint(
airportIata: e.iata,
dateTime: instant,
terminal: e.terminal
)
}
let leg = RouteFlight(
id: "FA-\(ident)-\(depSec)-\(depIATA)-\(arrIATA)",
carrierIata: carrierIATA,
carrierIcao: Self.identCarrierICAO(ident),
flightNumber: flightNumber,
flightSuffix: nil,
departure: endpoint(leg.origin, depDate),
arrival: endpoint(leg.destination, arrDate),
durationMinutes: max(0, durationMin),
equipmentIata: leg.aircraftType,
serviceType: nil,
isCodeshare: false,
stops: 0,
stopCodes: nil,
totalSeats: nil,
classes: nil,
inFlightService: nil,
isWetlease: nil,
codeshares: nil
)
legs.append(leg)
}
}
return legs
}
/// Locate `var trackpollBootstrap = {};` and return the JSON for the
/// brace-balanced object literal. Returns nil if the marker isn't found
/// or if the braces are unbalanced (malformed page).
static func extractTrackpollBlob(from html: String) -> String? {
guard let markerRange = html.range(
of: #"var\s+trackpollBootstrap\s*=\s*\{"#,
options: .regularExpression
) else { return nil }
// Position the cursor at the opening brace.
let start = html.index(before: markerRange.upperBound)
var depth = 0
var inString = false
var index = start
while index < html.endIndex {
let c = html[index]
if inString {
if c == "\\" {
index = html.index(after: index)
if index >= html.endIndex { return nil }
} else if c == "\"" {
inString = false
}
} else {
if c == "\"" {
inString = true
} else if c == "{" {
depth += 1
} else if c == "}" {
depth -= 1
if depth == 0 {
let end = html.index(after: index)
return String(html[start..<end])
}
}
}
index = html.index(after: index)
}
return nil
}
// MARK: - Helpers
private func fetchHTML(url: URL) async throws -> String {
var req = URLRequest(url: url)
req.setValue(Self.safariUA, forHTTPHeaderField: "User-Agent")
req.setValue(
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
forHTTPHeaderField: "Accept"
)
let (data, response) = try await session.data(for: req)
guard let http = response as? HTTPURLResponse else {
throw ClientError.routePageFailed(status: -1)
}
guard (200..<300).contains(http.statusCode) else {
throw ClientError.routePageFailed(status: http.statusCode)
}
return String(data: data, encoding: .utf8) ?? ""
}
private static let safariUA =
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) "
+ "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 "
+ "Mobile/15E148 Safari/604.1"
/// "AAL220" "AAL". "BAW296" "BAW".
static func identCarrierICAO(_ ident: String) -> String {
var icao = ""
for c in ident {
if c.isLetter { icao.append(c) } else { break }
}
return icao
}
/// "AAL220" 220.
static func identFlightNumber(_ ident: String) -> Int {
var digits = ""
for c in ident.reversed() {
if c.isNumber { digits.insert(c, at: digits.startIndex) } else { break }
}
return Int(digits) ?? 0
}
/// Carrier ICAO IATA prefix for human-facing flight numbers. Covers
/// the major commercial carriers FlightAware uses idents for. Returns
/// nil for callsigns we don't recognise (private jets, regionals we
/// haven't mapped) caller falls back to the raw ICAO prefix.
static func airlineIATA(forICAO icao: String) -> String? {
return airlineICAOToIATA[icao]
}
private static let airlineICAOToIATA: [String: String] = [
// US majors + low-cost
"AAL": "AA", "DAL": "DL", "UAL": "UA", "SWA": "WN", "ASA": "AS",
"JBU": "B6", "FFT": "F9", "NKS": "NK", "AAY": "G4", "HAL": "HA",
// US regionals
"SKW": "OO", "RPA": "YX", "AWI": "9E", "ENY": "MQ", "EDV": "9E",
"EJM": "AX", "ASH": "9E", "JIA": "OH", "PDT": "PT", "GJS": "ZW",
"TCF": "9X",
// Europe
"BAW": "BA", "DLH": "LH", "KLM": "KL", "AFR": "AF", "VIR": "VS",
"IBE": "IB", "SAS": "SK", "FIN": "AY", "TAP": "TP", "AZA": "AZ",
"SWR": "LX", "AUA": "OS", "LOT": "LO", "TRA": "HV", "EZY": "U2",
"RYR": "FR", "WZZ": "W6", "PGT": "PC", "AEE": "A3", "TVS": "QS",
"CFE": "BA",
// Oceania
"QFA": "QF", "VOZ": "VA", "ANZ": "NZ", "JST": "JQ",
// Asia
"ANA": "NH", "JAL": "JL", "ACA": "AC", "WJA": "WS",
"EVA": "BR", "CAL": "CI", "CES": "MU", "CCA": "CA", "CSN": "CZ",
"AAR": "OZ", "KAL": "KE", "SIA": "SQ", "THA": "TG", "CPA": "CX",
"AIC": "AI", "GIA": "GA", "MAS": "MH", "PAL": "PR", "BRU": "B7",
// Middle East
"QTR": "QR", "UAE": "EK", "ETD": "EY", "RJA": "RJ", "SVA": "SV",
// Africa
"ETH": "ET", "MEA": "ME", "MSR": "MS", "RAM": "AT", "KQA": "KQ",
// Latin America
"LAN": "LA", "TAM": "JJ", "AVA": "AV", "AMX": "AM", "VIV": "VB",
"VOI": "Y4", "CMP": "CM",
// Israel
"ELY": "LY",
// Cargo (operates passenger-numbered flights occasionally)
"FDX": "FX", "UPS": "5X", "NCA": "KZ", "GTI": "5Y",
// Charter / leasure operators we've seen on US routes
"TZP": "ZX", "JSX": "XE",
]
/// Strip leading colon (FlightAware emits ":America/Chicago" rather than
/// the canonical "America/Chicago") and convert to a `TimeZone`. Falls
/// back to UTC when the string is missing/unparseable.
private static func parseTZ(_ raw: String?) -> TimeZone {
guard var v = raw else { return TimeZone(identifier: "UTC")! }
if v.hasPrefix(":") { v.removeFirst() }
return TimeZone(identifier: v) ?? TimeZone(identifier: "UTC")!
}
/// True when `instant` falls on `target`'s calendar day in `tz`.
private static func sameLocalDay(_ instant: Date, target: Date, tz: TimeZone) -> Bool {
var cal = Calendar(identifier: .gregorian)
cal.timeZone = tz
return cal.isDate(instant, inSameDayAs: target)
}
}
// MARK: - trackpollBootstrap shape (just what we need)
/// Decoded subset of FlightAware's `trackpollBootstrap` JSON literal. The
/// real blob has dozens of fields per leg; we model only what feeds our
/// ``RouteFlight`` projection.
struct TrackpollBootstrap: Decodable {
let flights: [String: TrackpollFlight]
}
struct TrackpollFlight: Decodable {
let activityLog: TrackpollActivityLog
}
struct TrackpollActivityLog: Decodable {
let flights: [TrackpollLeg]
}
struct TrackpollLeg: Decodable {
let origin: TrackpollEndpoint
let destination: TrackpollEndpoint
let aircraftType: String?
let aircraftTypeFriendly: String?
let gateDepartureTimes: TrackpollTimes?
let gateArrivalTimes: TrackpollTimes?
let takeoffTimes: TrackpollTimes?
let landingTimes: TrackpollTimes?
}
struct TrackpollEndpoint: Decodable {
let iata: String
let icao: String?
let TZ: String?
let gate: String?
let terminal: String?
}
struct TrackpollTimes: Decodable {
let scheduled: Int?
let estimated: Int?
let actual: Int?
}
+152
View File
@@ -0,0 +1,152 @@
import Foundation
import SwiftData
import CoreLocation
/// Convenience wrapper around the SwiftData ModelContext for
/// LoggedFlight CRUD + airframe metadata caching. View code talks to
/// this rather than poking ModelContext directly so we have a single
/// place to enforce dedupe rules, derive computed fields, etc.
@MainActor
final class FlightHistoryStore {
let context: ModelContext
private let airportDatabase: AirportDatabase
init(context: ModelContext, airportDatabase: AirportDatabase) {
self.context = context
self.airportDatabase = airportDatabase
}
// MARK: - Persistence helper
/// Persist any pending mutations on the underlying ``ModelContext``.
/// Any thrown error is surfaced via ``DataIntegrityMonitor`` so the
/// user sees a banner about the failure instead of silently losing
/// data. Returns true on success so call sites can act on failure
/// (e.g. avoid clearing a draft).
///
/// `operation` is a short verb describing what was being saved
/// ("save flight", "delete flight", "update standby outcome"). It
/// appears in the banner so the user can correlate the failure with
/// their last action.
@discardableResult
func persist(_ operation: String) -> Bool {
do {
try context.save()
return true
} catch {
DataIntegrityMonitor.shared.reportSaveFailure(operation, error: error)
return false
}
}
// MARK: - LoggedFlight CRUD
/// Save a new flight. No dedupe logic here callers (importers)
/// own that. Direct user adds always create a fresh record.
@discardableResult
func save(_ flight: LoggedFlight) -> LoggedFlight {
context.insert(flight)
persist("save flight")
return flight
}
func delete(_ flight: LoggedFlight) {
context.delete(flight)
persist("delete flight")
}
/// Returns true if a flight with the same date + flight number +
/// route already exists. Used by importers to skip dupes.
func exists(flightDate: Date, flightLabel: String, departureIATA: String, arrivalIATA: String) -> Bool {
let day = Calendar.current.startOfDay(for: flightDate)
let next = Calendar.current.date(byAdding: .day, value: 1, to: day) ?? day
let predicate = #Predicate<LoggedFlight> { f in
f.flightDate >= day && f.flightDate < next
&& f.departureIATA == departureIATA
&& f.arrivalIATA == arrivalIATA
}
let descriptor = FetchDescriptor<LoggedFlight>(predicate: predicate)
let matches = (try? context.fetch(descriptor)) ?? []
return matches.contains { f in
f.flightLabel.uppercased() == flightLabel.uppercased()
}
}
func allFlights() -> [LoggedFlight] {
let descriptor = FetchDescriptor<LoggedFlight>(
sortBy: [SortDescriptor(\.flightDate, order: .reverse)]
)
return (try? context.fetch(descriptor)) ?? []
}
// MARK: - AirframeMetadata cache
func airframe(for registration: String) -> AirframeMetadata? {
let reg = registration.uppercased()
let descriptor = FetchDescriptor<AirframeMetadata>(
predicate: #Predicate { $0.registration == reg }
)
return (try? context.fetch(descriptor))?.first
}
@discardableResult
func upsertAirframe(
registration: String,
firstFlightDate: Date? = nil,
deliveryDate: Date? = nil
) -> AirframeMetadata {
let reg = registration.uppercased()
if let existing = airframe(for: reg) {
if let firstFlightDate { existing.firstFlightDate = firstFlightDate }
if let deliveryDate { existing.deliveryDate = deliveryDate }
existing.scrapedAt = Date()
persist("update airframe metadata")
return existing
}
let m = AirframeMetadata(
registration: reg,
firstFlightDate: firstFlightDate,
deliveryDate: deliveryDate,
scrapedAt: Date()
)
context.insert(m)
persist("cache airframe metadata")
return m
}
/// How many previously-logged flights have used this same tail
/// number. Used for the "2nd time on this plane" callout.
func repeatCount(for registration: String?, before flightDate: Date) -> Int {
guard let registration, !registration.isEmpty else { return 0 }
let reg = registration.uppercased()
let descriptor = FetchDescriptor<LoggedFlight>(
predicate: #Predicate { f in
f.registration == reg && f.flightDate < flightDate
}
)
return (try? context.fetch(descriptor))?.count ?? 0
}
// MARK: - Distance / duration helpers
/// Great-circle distance in statute miles between this flight's
/// dep and arr airports.
func distanceMiles(for flight: LoggedFlight) -> Int? {
guard let dep = airportDatabase.airport(byIATA: flight.departureIATA),
let arr = airportDatabase.airport(byIATA: flight.arrivalIATA)
else { return nil }
let depLoc = CLLocation(latitude: dep.coordinate.latitude, longitude: dep.coordinate.longitude)
let arrLoc = CLLocation(latitude: arr.coordinate.latitude, longitude: arr.coordinate.longitude)
let meters = depLoc.distance(from: arrLoc)
return Int(meters / 1609.34)
}
/// Duration in minutes prefers actual times, falls back to
/// scheduled, returns nil if neither is set.
func durationMinutes(for flight: LoggedFlight) -> Int? {
let dep = flight.actualDeparture ?? flight.scheduledDeparture
let arr = flight.actualArrival ?? flight.scheduledArrival
guard let dep, let arr, arr > dep else { return nil }
return Int(arr.timeIntervalSince(dep) / 60)
}
}
+2
View File
@@ -2,6 +2,8 @@ import Foundation
actor FlightService {
static let shared = FlightService()
// MARK: - Configuration
private let session: URLSession
+115
View File
@@ -0,0 +1,115 @@
import Foundation
/// User-facing sort options for the history list. Flighty's Passport
/// defaults to newest first; we mirror that and offer a few common
/// alternatives.
enum HistorySort: String, CaseIterable, Identifiable {
case newestFirst = "Newest first"
case oldestFirst = "Oldest first"
case longestFirst = "Longest first"
case shortestFirst = "Shortest first"
case airline = "By airline"
case flightNumber = "By flight #"
var id: String { rawValue }
var systemImage: String {
switch self {
case .newestFirst: return "arrow.down.circle"
case .oldestFirst: return "arrow.up.circle"
case .longestFirst: return "arrow.up.right"
case .shortestFirst: return "arrow.down.right"
case .airline: return "building.2"
case .flightNumber: return "number"
}
}
}
/// Plain-value filter set the history list + map share. Equatable so
/// `.onChange` can drive cached re-derivations cleanly. Empty sets mean
/// "no constraint" anything passes.
struct HistoryFilters: Equatable {
var query: String = ""
var years: Set<Int> = []
var airlines: Set<String> = [] // ICAO codes ("SWA")
var airports: Set<String> = [] // IATA codes ("DAL")
var aircraftTypes: Set<String> = [] // ICAO type ("B738")
var isEmpty: Bool {
query.isEmpty && years.isEmpty && airlines.isEmpty && airports.isEmpty && aircraftTypes.isEmpty
}
var activeCount: Int {
var n = 0
if !query.isEmpty { n += 1 }
if !years.isEmpty { n += 1 }
if !airlines.isEmpty { n += 1 }
if !airports.isEmpty { n += 1 }
if !aircraftTypes.isEmpty { n += 1 }
return n
}
func matches(_ f: LoggedFlight) -> Bool {
if !years.isEmpty {
let y = Calendar.current.component(.year, from: f.flightDate)
if !years.contains(y) { return false }
}
if !airlines.isEmpty {
let icao = f.carrierICAO ?? f.carrierIATA
guard let icao, airlines.contains(icao) else { return false }
}
if !airports.isEmpty {
if !airports.contains(f.departureIATA) && !airports.contains(f.arrivalIATA) {
return false
}
}
if !aircraftTypes.isEmpty {
guard let t = f.aircraftType, aircraftTypes.contains(t) else { return false }
}
if !query.isEmpty {
let q = query.uppercased()
let label = f.flightLabel.uppercased()
let route = "\(f.departureIATA)\(f.arrivalIATA)".uppercased()
if !label.contains(q) && !route.contains(q) && !f.departureIATA.uppercased().contains(q) && !f.arrivalIATA.uppercased().contains(q) {
return false
}
}
return true
}
}
/// Sort comparator built from a HistorySort. Distance comparators take
/// a closure since we need the per-flight distance computed from
/// AirportDatabase rather than stored on the model.
extension HistorySort {
func comparator(distanceMiles: @escaping (LoggedFlight) -> Int) -> (LoggedFlight, LoggedFlight) -> Bool {
switch self {
case .newestFirst: return { $0.flightDate > $1.flightDate }
case .oldestFirst: return { $0.flightDate < $1.flightDate }
case .longestFirst: return { distanceMiles($0) > distanceMiles($1) }
case .shortestFirst:
return { lhs, rhs in
let l = distanceMiles(lhs)
let r = distanceMiles(rhs)
// Treat 0 as "missing" so 0-mile rows sink to the bottom.
if l == 0 { return false }
if r == 0 { return true }
return l < r
}
case .airline:
return { lhs, rhs in
let l = lhs.carrierICAO ?? lhs.carrierIATA ?? ""
let r = rhs.carrierICAO ?? rhs.carrierIATA ?? ""
if l == r { return lhs.flightDate > rhs.flightDate }
return l < r
}
case .flightNumber:
return { lhs, rhs in
let l = Int(lhs.flightNumber ?? "") ?? Int.max
let r = Int(rhs.flightNumber ?? "") ?? Int.max
if l == r { return lhs.flightDate > rhs.flightDate }
return l < r
}
}
}
}
@@ -0,0 +1,129 @@
import Foundation
/// Aggregates per-airport load-factor signals from the bundled BTS T-100 dataset
/// so the UI can render an at-a-glance heatmap of how "open" each hub is for
/// nonrev / standby travel on a given day.
///
/// The underlying truth comes from `BTSDataStore`, which exposes flight-segment
/// records keyed by `"CARRIER_FLIGHTNUM_ORIGIN_DEST"`. For an airport's index
/// we filter to records whose origin matches the requested IATA and compute a
/// weighted average of `avgLoadFactor` using `totalFlights` as the weight
/// busier routes count proportionally more, so a hub's score reflects its real
/// traffic mix instead of being skewed by long-tail seasonal segments.
///
/// `date` is accepted as part of the public surface so callers can later swap
/// in a date-partitioned BTS store without a signature change. The current
/// BTSDataStore returns the full bundled snapshot regardless of date; we still
/// pass it through for future use.
actor HubLoadHeatmapService {
// MARK: - Public types
/// A single airport's aggregated load picture.
struct AirportLoadIndex: Sendable {
let airport: String
let avgLoadPct: Double
let sampleSize: Int
let band: LoadBand
}
/// Coarse buckets matching the heatmap legend.
/// - `open`: < 0.60
/// - `moderate`: 0.60 0.75
/// - `tight`: 0.75 0.88
/// - `full`: > 0.88
enum LoadBand: Sendable {
case open
case moderate
case tight
case full
fileprivate static func band(for loadPct: Double) -> LoadBand {
if loadPct < 0.60 { return .open }
if loadPct < 0.75 { return .moderate }
if loadPct < 0.88 { return .tight }
return .full
}
}
// MARK: - Dependencies
private let store: BTSDataStore
// MARK: - Cache
/// Memoized indices keyed by uppercased IATA. The bundled BTS snapshot is
/// static at runtime, so once we've crunched a hub we can return the same
/// answer instantly on repeat scrolls of the heatmap.
private var cache: [String: AirportLoadIndex] = [:]
// MARK: - Init
init(store: BTSDataStore = BTSDataStore.shared) {
self.store = store
}
// MARK: - Public API
/// Returns the load index for `iata` or `nil` if BTSDataStore has no
/// matching origin segments. `date` is reserved for future date-partitioned
/// stores; the current bundled snapshot is treated as a single period.
func loadIndex(forAirport iata: String, on date: Date) async -> AirportLoadIndex? {
let key = iata.uppercased()
if let cached = cache[key] {
return cached
}
let allRecords = await store.allRecordsKeyed()
guard !allRecords.isEmpty else {
print("[HubLoadHeatmap] BTSDataStore returned no records for \(key)")
return nil
}
// Filter to segments departing this airport. Composite keys are
// "CARRIER_FLIGHTNUM_ORIGIN_DEST"; we match on the third component to
// avoid false positives where the IATA appears inside a carrier code.
var weightedSum: Double = 0
var totalWeight: Int = 0
var matchCount: Int = 0
for (compositeKey, record) in allRecords {
let parts = compositeKey.split(separator: "_")
guard parts.count == 4 else { continue }
let origin = String(parts[2])
guard origin == key else { continue }
let weight = record.totalFlights
guard weight > 0 else { continue }
weightedSum += record.avgLoadFactor * Double(weight)
totalWeight += weight
matchCount += 1
}
guard matchCount > 0, totalWeight > 0 else {
print("[HubLoadHeatmap] No origin matches for \(key)")
return nil
}
let avg = weightedSum / Double(totalWeight)
let clamped = max(0.0, min(1.0, avg))
let index = AirportLoadIndex(
airport: key,
avgLoadPct: clamped,
sampleSize: matchCount,
band: LoadBand.band(for: clamped)
)
cache[key] = index
print("[HubLoadHeatmap] \(key) → avg=\(String(format: "%.3f", clamped)) n=\(matchCount) band=\(index.band)")
return index
}
/// Clears the memoized indices. Call after BTSDataStore is rebuilt or a new
/// snapshot is bundled.
func invalidateCache() {
cache.removeAll()
}
}
+184
View File
@@ -0,0 +1,184 @@
import Foundation
/// Predicted per-flight load factor (fraction of seats expected to be
/// occupied) for a specific carrier/flight/route/date combination.
///
/// This is the key signal for an airline-employee / nonrev traveller
/// "is this flight likely to be wide open or stuffed?". The actor blends
/// BTS historical baselines with calendar adjustments (weekday-vs-weekend,
/// peak-season bumps) and an optional live-seat correction.
///
/// All math is intentionally simple and explainable: the basis string we
/// return is what the UI surfaces to the user, so they understand *why*
/// the prediction is what it is.
actor LoadFactorService {
// MARK: Singleton
static let shared = LoadFactorService()
// MARK: Dependencies
private let store: BTSDataStore
init(store: BTSDataStore = .shared) {
self.store = store
}
// MARK: Public API
/// Predict the load factor for a given flight on a given date.
///
/// - Parameters:
/// - carrier: IATA carrier code (e.g. "WN").
/// - flightNumber: Operating flight number.
/// - origin: Origin IATA.
/// - dest: Destination IATA.
/// - date: Departure date used for day-of-week + seasonal adjustments.
/// - liveSeats: Optional live seat count from the actually-assigned
/// aircraft (e.g. parsed from FR24 / AirframeMetadata).
/// If smaller than the BTS historical seat count, the
/// predicted load factor scales up proportionally
/// because the same expected pax count fills more of
/// a smaller jet.
/// - Returns: ``nil`` when there's no BTS record for the flight key
/// callers should hide the load-factor UI rather than guess.
///
/// - Note: When `database` is provided we look up the origin airport's
/// timezone so weekday + month adjustments are evaluated in
/// airport-local time. Callers that don't have a database (or that
/// want the legacy UTC behaviour) can leave it nil.
func estimate(
carrier: String,
flightNumber: Int,
origin: String,
dest: String,
date: Date,
database: AirportDatabase? = nil,
liveSeats: Int? = nil
) async -> LoadFactorEstimate? {
guard let base = await store.record(
carrier: carrier,
flightNumber: flightNumber,
origin: origin,
dest: dest
) else {
print("[LoadFactor] no BTS record for \(carrier)\(flightNumber) \(origin)->\(dest)")
return nil
}
var prediction = base.avgLoadFactor
var reasons: [String] = ["BTS avg \(Int(round(base.avgLoadFactor * 100)))% (\(base.samplePeriod))"]
// ---- Day-of-week adjustment ------------------------------------
// Calendar uses 1=Sunday ... 7=Saturday. Weekend = Sat or Sun.
// We resolve the origin airport's timezone so the weekday/month
// reflect what a passenger would actually call "Sunday" late-PT
// departures otherwise roll into Monday UTC and skip the bump.
let originTimeZone: TimeZone = database?.timeZone(forIATA: origin)
?? TimeZone(identifier: "UTC")
?? .current
var cal = Calendar(identifier: .gregorian)
cal.timeZone = originTimeZone
let weekday = cal.component(.weekday, from: date)
let isWeekend = (weekday == 1 || weekday == 7)
if isWeekend {
if Self.leisureCarriers.contains(carrier.uppercased()) {
prediction += 0.05
reasons.append("weekend leisure +5%")
} else if Self.businessCarriers.contains(carrier.uppercased()) {
prediction -= 0.05
reasons.append("weekend business -5%")
}
}
// ---- Peak-season adjustment ------------------------------------
let month = cal.component(.month, from: date)
if month == 6 || month == 7 || month == 12 {
prediction += 0.07
reasons.append("peak season +7%")
}
// ---- Live-seats correction -------------------------------------
// If today's airframe seats < historical avg seats, the same pax
// demand fills a larger fraction of the cabin. Scale by the seat
// ratio. Guards (all paths leave `prediction` untouched):
// liveSeats <= 0 missing/garbage live data.
// base.avgSeats <= 0 guards against future bad BTS records
// (divide-by-zero hazard otherwise).
// liveSeats >= avgSeats same-size or bigger airframe; the
// ratio path is only meant to push the
// number *up*, never down.
if let liveSeats,
liveSeats > 0,
base.avgSeats > 0,
liveSeats < base.avgSeats {
let ratio = Double(base.avgSeats) / Double(liveSeats)
let bumped = prediction * ratio
if bumped > prediction {
let added = bumped - prediction
prediction = bumped
reasons.append(String(
format: "smaller aircraft (%d vs %d seats) +%d%%",
liveSeats, base.avgSeats, Int(round(added * 100))
))
}
}
// Clamp to [0, 1] adjustments can push us over.
prediction = min(1.0, max(0.0, prediction))
// ---- Confidence ------------------------------------------------
let confidence: Double
if base.totalFlights >= 60 {
confidence = 0.85
} else if base.totalFlights >= 20 {
confidence = 0.65
} else {
confidence = 0.40
}
let basis = reasons.joined(separator: " · ")
print("[LoadFactor] \(carrier)\(flightNumber) \(origin)->\(dest) " +
"predicted=\(Int(round(prediction * 100)))% conf=\(confidence) \(basis)")
return LoadFactorEstimate(
predicted: prediction,
confidence: confidence,
baseSeats: liveSeats ?? base.avgSeats,
basis: basis
)
}
// MARK: Carrier classification
/// Carriers we treat as "leisure" weekend traffic skews higher.
private static let leisureCarriers: Set<String> = ["WN", "NK", "F9", "G4", "SY"]
/// Carriers we treat as "business" weekend traffic skews lower,
/// because Mon/Thu corporate trips dominate the schedule.
private static let businessCarriers: Set<String> = ["AA", "DL", "UA"]
}
// MARK: - Estimate type
/// Result of a load-factor prediction call. ``predicted`` and
/// ``confidence`` are both in 0...1, intended for direct rendering
/// (e.g. a colour gauge plus a small confidence pill).
struct LoadFactorEstimate: Sendable {
/// Predicted load factor (0...1).
let predicted: Double
/// Confidence in the prediction (0...1) derived from sample size.
let confidence: Double
/// Seat count used as the denominator in the prediction. This is
/// either the live aircraft's seat count (if provided) or the BTS
/// historical average.
let baseSeats: Int
/// Human-readable explanation of what fed into the prediction.
/// Designed to be surfaced verbatim in the UI ("Why this number?").
let basis: String
}
+82
View File
@@ -0,0 +1,82 @@
import Foundation
import CoreLocation
/// Thin wrapper around CLLocationManager exposing an async one-shot fix
/// API. We only need "where is the user right now so I can center the
/// map" not continuous tracking, not background updates. The Live tab
/// calls `requestOneShotLocation()` once on appear and is done.
///
/// Permission state is published so the view can decide between
/// (a) centering on the user, (b) restoring last viewed region, or
/// (c) showing a continental fallback.
@MainActor
final class LocationService: NSObject, ObservableObject {
static let shared = LocationService()
@Published private(set) var authorization: CLAuthorizationStatus
@Published private(set) var lastKnown: CLLocationCoordinate2D?
private let manager = CLLocationManager()
private var pendingContinuations: [CheckedContinuation<CLLocationCoordinate2D?, Never>] = []
private override init() {
self.authorization = manager.authorizationStatus
super.init()
manager.delegate = self
manager.desiredAccuracy = kCLLocationAccuracyHundredMeters
}
/// Ask the OS for permission (if needed) and return the next location
/// fix, or nil if the user denied access. Resolves once does not
/// stay subscribed.
func requestOneShotLocation() async -> CLLocationCoordinate2D? {
switch authorization {
case .denied, .restricted:
return nil
case .notDetermined:
manager.requestWhenInUseAuthorization()
// Fall through to request location after the delegate flips
// auth we capture the continuation now and resume it once
// location/auth-denied lands.
default:
break
}
return await withCheckedContinuation { cont in
pendingContinuations.append(cont)
// requestLocation is a one-shot; safe to call before auth has
// been granted CL will queue it and fire after grant.
manager.requestLocation()
}
}
private func resumeAll(with value: CLLocationCoordinate2D?) {
let waiters = pendingContinuations
pendingContinuations.removeAll()
for c in waiters { c.resume(returning: value) }
}
}
extension LocationService: CLLocationManagerDelegate {
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
Task { @MainActor in
self.authorization = manager.authorizationStatus
if manager.authorizationStatus == .denied || manager.authorizationStatus == .restricted {
self.resumeAll(with: nil)
}
}
}
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let loc = locations.last else { return }
Task { @MainActor in
self.lastKnown = loc.coordinate
self.resumeAll(with: loc.coordinate)
}
}
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
Task { @MainActor in
self.resumeAll(with: nil)
}
}
}
@@ -0,0 +1,150 @@
import Foundation
/// URLSession delegate that funnels every request lifecycle event
/// through ``DiagnosticLogger``. Drop it onto any `URLSession` whose
/// traffic we want forensically captured primarily
/// ``RouteExplorerClient`` (where we need to see the exact 403 +
/// `reason:"clearance"` body) and ``FlightAwareScheduleClient``
/// (where we need to see if FA ever rate-limits us).
///
/// We never capture full response bodies those can be 500 KB+ for
/// FA's trackpoll pages and would balloon the log file. The client
/// itself can log a body excerpt explicitly with `DiagnosticLogger`
/// after parsing, if needed.
///
/// Headers are filtered to a small forensically-useful subset the
/// CDN/edge headers Cloudflare/Vercel use to identify themselves and
/// the cookies they set. We deliberately drop the giant
/// `Set-Cookie` body sometimes seen on Vercel responses so the log
/// stays scannable.
final class LoggingURLSessionDelegate: NSObject, URLSessionTaskDelegate {
/// Label that gets prefixed onto every event for this delegate's
/// session, so a single shared log can disambiguate which client
/// the event came from (e.g. `RE`, `FA`, `BLOB`).
let tag: String
init(tag: String) {
self.tag = tag
super.init()
}
// MARK: - URLSessionTaskDelegate
/// Fired right before the request goes on the wire (post any
/// redirect resolution). Capture method + URL + key headers so
/// we can confirm e.g. the UA + Referer the client thinks it
/// sent are the ones that actually went out.
func urlSession(
_ session: URLSession,
task: URLSessionTask,
willBeginDelayedRequest request: URLRequest,
completionHandler: @escaping (URLSession.DelayedRequestDisposition, URLRequest?) -> Void
) {
logRequest("delayedRequest", request: request, taskID: task.taskIdentifier)
completionHandler(.continueLoading, request)
}
func urlSession(
_ session: URLSession,
task: URLSessionTask,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
DiagnosticLogger.shared.log("NET-\(tag)", "authChallenge", [
"taskID": task.taskIdentifier,
"method": challenge.protectionSpace.authenticationMethod,
"host": challenge.protectionSpace.host,
])
completionHandler(.performDefaultHandling, nil)
}
func urlSession(
_ session: URLSession,
task: URLSessionTask,
didCompleteWithError error: Error?
) {
if let error {
DiagnosticLogger.shared.log("NET-\(tag)", "didCompleteWithError", [
"taskID": task.taskIdentifier,
"error": error.localizedDescription,
"code": (error as NSError).code,
"domain": (error as NSError).domain,
])
return
}
// No error log the final response status/headers.
guard let response = task.response as? HTTPURLResponse else {
DiagnosticLogger.shared.log("NET-\(tag)", "didCompleteNoResponse", [
"taskID": task.taskIdentifier,
])
return
}
var fields: [String: Any] = [
"taskID": task.taskIdentifier,
"url": response.url?.absoluteString ?? "?",
"status": response.statusCode,
]
Self.collectInterestingHeaders(from: response, into: &fields)
DiagnosticLogger.shared.log("NET-\(tag)", "didComplete", fields)
}
func urlSession(
_ session: URLSession,
task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse,
newRequest request: URLRequest,
completionHandler: @escaping (URLRequest?) -> Void
) {
var fields: [String: Any] = [
"taskID": task.taskIdentifier,
"fromStatus": response.statusCode,
"fromURL": response.url?.absoluteString ?? "?",
"toURL": request.url?.absoluteString ?? "?",
]
Self.collectInterestingHeaders(from: response, into: &fields)
DiagnosticLogger.shared.log("NET-\(tag)", "redirect", fields)
completionHandler(request)
}
// MARK: - Helpers
private func logRequest(_ event: String, request: URLRequest, taskID: Int) {
DiagnosticLogger.shared.log("NET-\(tag)", event, [
"taskID": taskID,
"method": request.httpMethod ?? "?",
"url": request.url?.absoluteString ?? "?",
"ua": request.value(forHTTPHeaderField: "User-Agent") ?? "(default)",
"referer": request.value(forHTTPHeaderField: "Referer") ?? "-",
"origin": request.value(forHTTPHeaderField: "Origin") ?? "-",
"cookieHeader": request.value(forHTTPHeaderField: "Cookie") ?? "-",
"acceptLang": request.value(forHTTPHeaderField: "Accept-Language") ?? "-",
])
}
/// Pull just the CDN/edge headers that matter for diagnosing
/// Turnstile / Cloudflare behaviour. Discards bulky / noisy
/// headers (Content-Encoding, Date, Server-Timing big strings).
private static func collectInterestingHeaders(
from response: HTTPURLResponse,
into fields: inout [String: Any]
) {
let interesting = [
"Set-Cookie", "CF-Ray", "CF-Cache-Status", "Server",
"X-Vercel-Id", "X-Vercel-Cache",
"X-Powered-By", "X-Robots-Tag",
"Content-Type", "Content-Length",
"X-Request-Id", "X-Cloudflare-Worker",
"WWW-Authenticate", "PAT", // Private Access Token markers
]
for name in interesting {
// Header lookup is case-insensitive in HTTP/2 + responses,
// so try the canonical and lower forms.
if let v = response.value(forHTTPHeaderField: name)
?? response.allHeaderFields[name] as? String {
// Trim to 200 chars so a 5 KB Set-Cookie doesn't take over a line.
fields[name] = String(v.prefix(200))
}
}
}
}
@@ -0,0 +1,64 @@
import Foundation
/// Historical on-time-performance stats for a given flight key.
///
/// All numbers come straight from the bundled BTS Reporting Carrier
/// On-Time Performance dataset (see ``BTSDataStore`` + the companion
/// ``bts_bundle_meta.json`` citation file). The actor is a thin
/// projection over ``BTSDataStore`` so callers don't have to know the
/// key format.
actor OnTimePerformanceService {
// MARK: Singleton
static let shared = OnTimePerformanceService()
// MARK: Dependencies
private let store: BTSDataStore
init(store: BTSDataStore = .shared) {
self.store = store
}
// MARK: Public API
/// Headline on-time-performance stats for the flight. Returns nil when
/// the BTS bundle has no record.
func stat(
carrier: String,
flightNumber: Int,
origin: String,
dest: String
) async -> OnTimeStat? {
guard let rec = await store.record(
carrier: carrier,
flightNumber: flightNumber,
origin: origin,
dest: dest
) else {
print("[OnTime] no BTS record for \(carrier)\(flightNumber) \(origin)->\(dest)")
return nil
}
return OnTimeStat(
onTimePct: rec.onTimePct,
avgDelayMin: rec.avgDelayMin,
cancelledPct: rec.cancelledPct,
samplePeriod: rec.samplePeriod,
n: rec.totalFlights
)
}
}
// MARK: - Stat type
/// Headline on-time stats for a single flight key. ``n`` is the BTS
/// sample size the UI can use it to render a "based on N flights"
/// caption alongside the percentages.
struct OnTimeStat: Sendable {
let onTimePct: Double
let avgDelayMin: Double
let cancelledPct: Double
let samplePeriod: String
let n: Int
}
+243
View File
@@ -0,0 +1,243 @@
import Foundation
/// Thin client for the OpenSky Network REST API. Two endpoints:
/// - `/states/all` live aircraft state vectors (positions, velocity, etc.)
/// - `/flights/aircraft` recent flight history per aircraft (used for the
/// tap-to-detail "where did this come from / going to" panel)
///
/// Anonymous access is rate-limited (~10s minimum between requests, 100/day);
/// authenticated access gets higher quotas but requires the user to register.
/// We default to anonymous + heavy debouncing in the UI layer.
actor OpenSkyClient {
enum ClientError: Error, LocalizedError {
case requestFailed(status: Int)
case decodingFailed(Error)
case throttled
var errorDescription: String? {
switch self {
case .requestFailed(let s): return "OpenSky HTTP \(s)"
case .decodingFailed(let e): return "Could not parse OpenSky response: \(e.localizedDescription)"
case .throttled: return "OpenSky rate limit reached. Wait a moment and retry."
}
}
}
private let session: URLSession
private var basicAuthHeader: String?
init() {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 20
config.requestCachePolicy = .reloadIgnoringLocalCacheData
session = URLSession(configuration: config)
basicAuthHeader = Self.makeBasicAuth(OpenSkyCredentials.shared.load())
// Re-read credentials whenever the Settings screen saves new ones.
NotificationCenter.default.addObserver(
forName: .openSkyCredentialsChanged, object: nil, queue: nil
) { [weak self] _ in
Task { await self?.reloadCredentials() }
}
}
private func reloadCredentials() {
basicAuthHeader = Self.makeBasicAuth(OpenSkyCredentials.shared.load())
}
nonisolated private static func makeBasicAuth(_ creds: OpenSkyCredentials.Credentials?) -> String? {
guard let creds else { return nil }
let raw = "\(creds.username):\(creds.password)"
guard let data = raw.data(using: .utf8) else { return nil }
return "Basic \(data.base64EncodedString())"
}
/// Apply auth header to a URLRequest if credentials are stored.
private func applyAuth(_ request: inout URLRequest) {
if let auth = basicAuthHeader {
request.setValue(auth, forHTTPHeaderField: "Authorization")
}
}
/// Aircraft inside the given lat/lon bounding box.
///
/// Use the smallest bounding box that covers the user's visible map too
/// wide and you'll pull thousands of state vectors per call (and burn
/// quota faster).
func states(latMin: Double, lonMin: Double, latMax: Double, lonMax: Double) async throws -> [LiveAircraft] {
var comps = URLComponents(string: "https://opensky-network.org/api/states/all")!
comps.queryItems = [
URLQueryItem(name: "lamin", value: String(latMin)),
URLQueryItem(name: "lomin", value: String(lonMin)),
URLQueryItem(name: "lamax", value: String(latMax)),
URLQueryItem(name: "lomax", value: String(lonMax))
]
guard let url = comps.url else { throw ClientError.requestFailed(status: -1) }
return try await decodeStates(from: url)
}
private func decodeStates(from url: URL) async throws -> [LiveAircraft] {
var req = URLRequest(url: url)
applyAuth(&req)
req.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, response) = try await session.data(for: req)
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
if status == 429 { throw ClientError.throttled }
guard status == 200 else { throw ClientError.requestFailed(status: status) }
do {
let resp = try JSONDecoder().decode(StatesResponse.self, from: data)
return resp.states ?? []
} catch {
throw ClientError.decodingFailed(error)
}
}
/// In-flight track for the given aircraft sequence of (time, lat, lon,
/// alt, heading, onGround) points covering the most recent flight (or
/// current flight if it's still airborne). Used to draw the trail on
/// the map when an aircraft is selected.
func track(icao24: String) async -> AircraftTrack? {
var comps = URLComponents(string: "https://opensky-network.org/api/tracks/all")!
comps.queryItems = [
URLQueryItem(name: "icao24", value: icao24.lowercased()),
URLQueryItem(name: "time", value: "0") // 0 = current/most-recent
]
guard let url = comps.url else { return nil }
var req = URLRequest(url: url)
applyAuth(&req)
req.setValue("application/json", forHTTPHeaderField: "Accept")
do {
let (data, response) = try await session.data(for: req)
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
guard status == 200 else { return nil }
return try? JSONDecoder().decode(AircraftTrack.self, from: data)
} catch {
return nil
}
}
/// Flights an aircraft has flown in the past N days.
/// OpenSky requires a `begin` and `end` window, max 30 days each.
func recentFlights(icao24: String, daysBack: Int = 7) async -> [OpenSkyFlight] {
let now = Int(Date().timeIntervalSince1970)
let begin = now - (daysBack * 86400)
var comps = URLComponents(string: "https://opensky-network.org/api/flights/aircraft")!
comps.queryItems = [
URLQueryItem(name: "icao24", value: icao24.lowercased()),
URLQueryItem(name: "begin", value: String(begin)),
URLQueryItem(name: "end", value: String(now))
]
guard let url = comps.url else { return [] }
var req = URLRequest(url: url)
applyAuth(&req)
req.setValue("application/json", forHTTPHeaderField: "Accept")
do {
let (data, response) = try await session.data(for: req)
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
// 404 from OpenSky means "no flights in this window" not an error.
guard status == 200 else { return [] }
return (try? JSONDecoder().decode([OpenSkyFlight].self, from: data)) ?? []
} catch {
return []
}
}
}
// MARK: - Decoding
/// OpenSky returns each state vector as a heterogeneous array index 0 is
/// the ICAO24 hex, 1 is callsign, 2 is country, 3 is unix time of last
/// position, 4 is unix time of last contact, 5 is longitude, 6 is latitude,
/// 7 is barometric altitude in meters, 8 is on_ground bool, 9 is velocity
/// m/s, 10 is true_track degrees, 11 is vertical_rate m/s, 12 is sensors
/// (array of receiver IDs, skipped), 13 is geometric altitude meters, 14
/// is squawk, 15 is spi bool (skipped), 16 is position_source (skipped),
/// 17 is category. Most entries can be `null`.
private struct StatesResponse: Decodable {
let time: Int
let states: [LiveAircraft]?
enum CodingKeys: String, CodingKey { case time, states }
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
time = try c.decode(Int.self, forKey: .time)
let raw = try c.decodeIfPresent([RawStateVector].self, forKey: .states)
states = raw?.compactMap(LiveAircraft.from)
}
}
/// Intermediate decoder that consumes the heterogeneous array into named
/// optional fields without exploding on null values or shape drift.
private struct RawStateVector: Decodable {
let icao24: String
let callsign: String?
let originCountry: String
let timePosition: Int?
let lastContact: Int
let longitude: Double?
let latitude: Double?
let baroAltitude: Double?
let onGround: Bool
let velocity: Double?
let trueTrack: Double?
let verticalRate: Double?
let geoAltitude: Double?
let squawk: String?
let category: Int?
init(from decoder: Decoder) throws {
var c = try decoder.unkeyedContainer()
icao24 = (try? c.decode(String.self)) ?? ""
callsign = try? c.decodeIfPresent(String.self)
originCountry = (try? c.decode(String.self)) ?? ""
timePosition = try? c.decodeIfPresent(Int.self)
lastContact = (try? c.decode(Int.self)) ?? 0
longitude = try? c.decodeIfPresent(Double.self)
latitude = try? c.decodeIfPresent(Double.self)
baroAltitude = try? c.decodeIfPresent(Double.self)
onGround = (try? c.decode(Bool.self)) ?? false
velocity = try? c.decodeIfPresent(Double.self)
trueTrack = try? c.decodeIfPresent(Double.self)
verticalRate = try? c.decodeIfPresent(Double.self)
// [12] sensors array skip.
_ = try? c.decodeIfPresent([Int].self)
geoAltitude = try? c.decodeIfPresent(Double.self)
squawk = try? c.decodeIfPresent(String.self)
// [15] spi, [16] position_source skip.
_ = try? c.decode(Bool.self)
_ = try? c.decode(Int.self)
category = try? c.decodeIfPresent(Int.self)
}
}
private extension LiveAircraft {
static func from(_ raw: RawStateVector) -> LiveAircraft? {
guard let lat = raw.latitude, let lon = raw.longitude, !raw.icao24.isEmpty else {
return nil
}
return LiveAircraft(
icao24: raw.icao24,
callsign: raw.callsign,
originCountry: raw.originCountry,
latitude: lat,
longitude: lon,
baroAltitude: raw.baroAltitude,
geoAltitude: raw.geoAltitude,
velocity: raw.velocity,
trueTrack: raw.trueTrack,
verticalRate: raw.verticalRate,
onGround: raw.onGround,
squawk: raw.squawk,
category: raw.category,
lastContact: Date(timeIntervalSince1970: TimeInterval(raw.lastContact)),
enrichment: nil
)
}
}
+91
View File
@@ -0,0 +1,91 @@
import Foundation
import Security
/// Stores the optional OpenSky Network username + password in the Keychain.
///
/// Anonymous OpenSky access is capped at ~100 requests per 24h per IP.
/// A free OpenSky account bumps the cap to 4000/day, which is the difference
/// between "the tab works for casual viewing" and "the tab keeps refreshing
/// without complaint all day". The Settings screen lets the user paste their
/// OpenSky credentials; we stash them in the Keychain and `OpenSkyClient`
/// reads them on each request.
///
/// Posts `Notification.Name.openSkyCredentialsChanged` after every write so
/// the client can refresh its in-memory copy.
final class OpenSkyCredentials: @unchecked Sendable {
static let shared = OpenSkyCredentials()
private let service = "com.flights.app.opensky"
private let account = "credentials"
struct Credentials: Sendable, Equatable {
let username: String
let password: String
}
/// Read the stored credentials, or nil if none have been saved.
func load() -> Credentials? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnAttributes as String: true,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var item: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess,
let dict = item as? [String: Any],
let data = dict[kSecValueData as String] as? Data,
let password = String(data: data, encoding: .utf8),
let username = dict[kSecAttrGeneric as String] as? Data,
let usernameStr = String(data: username, encoding: .utf8)
else { return nil }
return Credentials(username: usernameStr, password: password)
}
/// Save credentials. Overwrites any existing entry.
func save(username: String, password: String) {
let username = username.trimmingCharacters(in: .whitespacesAndNewlines)
let password = password.trimmingCharacters(in: .whitespacesAndNewlines)
guard !username.isEmpty, !password.isEmpty else { return }
let usernameData = username.data(using: .utf8) ?? Data()
let passwordData = password.data(using: .utf8) ?? Data()
// Try update first, fall back to add.
let updateQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
let updateAttrs: [String: Any] = [
kSecAttrGeneric as String: usernameData,
kSecValueData as String: passwordData
]
let status = SecItemUpdate(updateQuery as CFDictionary, updateAttrs as CFDictionary)
if status == errSecItemNotFound {
var addQuery = updateQuery
addQuery[kSecAttrGeneric as String] = usernameData
addQuery[kSecValueData as String] = passwordData
SecItemAdd(addQuery as CFDictionary, nil)
}
NotificationCenter.default.post(name: .openSkyCredentialsChanged, object: nil)
}
/// Remove any stored credentials (back to anonymous).
func clear() {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
SecItemDelete(query as CFDictionary)
NotificationCenter.default.post(name: .openSkyCredentialsChanged, object: nil)
}
}
extension Notification.Name {
static let openSkyCredentialsChanged = Notification.Name("openSkyCredentialsChanged")
}
+212 -67
View File
@@ -16,6 +16,13 @@ actor RouteExplorerClient {
case tokenFetchFailed(status: Int)
case requestFailed(status: Int, body: String?)
case decodingFailed(underlying: Error)
/// Legacy. Server returned 403 `reason: "clearance"`.
/// Retained for backwards compat with any in-tree callers; the
/// production path now throws ``needsTokenRefresh`` instead.
case needsClearance
/// No usable token in ``RouteExplorerTokenStore`` (never captured
/// or expired). Caller should open the bookmarklet refresh flow.
case needsTokenRefresh
var errorDescription: String? {
switch self {
@@ -25,6 +32,10 @@ actor RouteExplorerClient {
return "Request failed (HTTP \(status)). \(body ?? "")"
case .decodingFailed(let error):
return "Could not parse response: \(error.localizedDescription)"
case .needsClearance:
return "Verification required."
case .needsTokenRefresh:
return "Route-explorer token missing or expired. Open Settings → Tools → Connect route-explorer to refresh."
}
}
}
@@ -32,6 +43,7 @@ actor RouteExplorerClient {
// MARK: - Properties
private let session: URLSession
private let sessionDelegate = LoggingURLSessionDelegate(tag: "RE")
private let baseURL = URL(string: "https://route-explorer.com")!
private let dateFormatter: DateFormatter
@@ -45,7 +57,13 @@ actor RouteExplorerClient {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 20
config.requestCachePolicy = .reloadIgnoringLocalCacheData
session = URLSession(configuration: config)
session = URLSession(configuration: config, delegate: nil, delegateQueue: nil)
// The delegate above is initialized so it can be reused if we
// later swap to a delegated session. The current session uses
// the configuration path because the existing fetch path is
// WKWebView-based, not URLSession; if/when that flips back, the
// delegate gets the trace.
_ = sessionDelegate
let f = DateFormatter()
f.calendar = Calendar(identifier: .gregorian)
@@ -64,16 +82,20 @@ actor RouteExplorerClient {
date: Date,
maxStops: Int = 1,
includeInterline: Bool = false,
sortBy: RouteSortOption = .departureTime,
sortBy: RouteSortOption = .departureEarliest,
limit: Int = 100
) async throws -> RouteSearchResult {
let dateStr = dateFormatter.string(from: date)
// All sorts apply client-side. Upstream is told to use
// `departure_time` so the result order is stable; RoutePlannerView
// reorders after the fetch returns.
let serverSort = sortBy.apiValue ?? "departure_time"
let payload: [String: Any] = [
"departureAirportIata": origin.uppercased(),
"arrivalAirportIata": destination.uppercased(),
"departureDates": [dateStr],
"maxStops": maxStops,
"sortBy": sortBy.rawValue,
"sortBy": serverSort,
"includeInterline": includeInterline,
"limit": limit,
"includeAppendix": true
@@ -81,6 +103,48 @@ actor RouteExplorerClient {
return try await callFlightSearch(endpoint: "/route", json: payload)
}
/// Schedule lookup for a specific flight number across a date range.
/// Powers the live-flight detail sheet given an ICAO callsign like
/// `AAL1234` we resolve it to `(carrier: "AA", flightNumber: 1234)` and
/// pull the operating record, which carries real departure + arrival
/// airports and times.
///
/// Returns `nil` if the carrier/flight isn't in route-explorer's
/// schedule feed (typical for regional codeshares, charter ops, and
/// carriers the upstream platform doesn't index).
func searchSchedule(
carrierCode: String,
flightNumber: Int,
startDate: Date,
endDate: Date? = nil
) async -> [RouteFlight] {
let startStr = dateFormatter.string(from: startDate)
let endStr = dateFormatter.string(from: endDate ?? startDate)
let payload: [String: Any] = [
"carrierCode": carrierCode.uppercased(),
"flightNumber": flightNumber,
"startDate": startStr,
"endDate": endStr,
"limit": 20,
"includeAppendix": true
]
do {
let token = try await currentToken()
let body = try JSONSerialization.data(withJSONObject: [
"endpoint": "/schedule",
"body": ["json": payload]
])
let (status, data) = try await postFlightSearch(token: token, body: body)
guard status == 200 else { return [] }
let decoded = try JSONDecoder.routeExplorer().decode(
RouteExplorerScheduleResponse.self, from: data
)
return decoded.json.flights
} catch {
return []
}
}
/// All departures from an airport on a date. We filter by time window
/// client-side because the upstream endpoint doesn't accept one.
func searchDepartures(
@@ -102,32 +166,101 @@ actor RouteExplorerClient {
// MARK: - Token
/// Returns a usable token. Prefers the user-supplied token from
/// ``RouteExplorerTokenStore`` (captured via the Safari bookmarklet
/// flow); falls back to ``cachedToken`` only if a previous in-app
/// fetch managed to mint one (rare since the gate moved).
///
/// Throws ``ClientError/needsTokenRefresh`` when there is no stored
/// token `RoutePlannerView` catches this and routes the user to
/// the bookmarklet setup screen.
private func currentToken() async throws -> String {
if let cached = cachedToken, cached.expiresAt > Date() {
return cached.value
}
let url = baseURL.appendingPathComponent("api/token")
var req = URLRequest(url: url)
req.httpMethod = "GET"
Self.applyBrowserHeaders(to: &req)
req.setValue("application/json", forHTTPHeaderField: "Accept")
// User-supplied token from the Safari bookmarklet capture.
let stored = await MainActor.run { RouteExplorerTokenStore.shared }
let token = await MainActor.run { stored.token }
let exp = await MainActor.run { stored.expiresAt }
if let token, let exp, exp > Date(), !token.isEmpty {
// Keep the in-actor cache aligned with the store.
cachedToken = (token, exp)
return token
}
throw ClientError.needsTokenRefresh
}
let (data, response) = try await session.data(for: req)
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
if status != 200 {
let bodyStr = String(data: data, encoding: .utf8) ?? "<no body>"
print("[RouteExplorer] /api/token failed status=\(status) body=\(bodyStr.prefix(300))")
throw ClientError.tokenFetchFailed(status: status)
/// Real iPhone Safari UA WKWebView's default ("Mobile/15E148"
/// only) is missing the `Version/x.x Safari/604.1` suffix that
/// Cloudflare uses to identify true Safari. Setting this on the
/// WebView gets us past the simplest UA-based filters.
private static let safariUA: String =
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) " +
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 " +
"Mobile/15E148 Safari/604.1"
/// Runs an XHR from inside a WKWebView that's been navigated to
/// `https://route-explorer.com/`. The page context provides the
/// real Safari TLS fingerprint and any first-party cookies the
/// edge expects. Returns the response body as a string, or
/// throws with the real upstream status code on failure.
private func fetchViaWebView(
method: String,
apiPath: String,
extraHeaders: [String: String],
requestBody: Data?
) async throws -> String {
let fetcher = await WebViewFetcher()
var headers: [String: String] = [
"Accept": "application/json",
"Content-Type": "application/json"
]
for (k, v) in extraHeaders { headers[k] = v }
// For POST, body is interpolated verbatim into a JS literal.
// The body we send is already a JSON-encoded byte string, so
// wrapping in JSON.stringify(...) re-emits the same string.
var bodyJS: String?
if let requestBody {
let raw = String(data: requestBody, encoding: .utf8) ?? "null"
bodyJS = "JSON.stringify(\(raw))"
}
struct TokenResponse: Decodable { let token: String }
do {
let decoded = try JSONDecoder().decode(TokenResponse.self, from: data)
cachedToken = (decoded.token, Date().addingTimeInterval(30 * 60))
return decoded.token
} catch {
throw ClientError.decodingFailed(underlying: error)
let result = await fetcher.fetch(
navigateTo: "https://route-explorer.com/",
fetchURL: "https://route-explorer.com\(apiPath)",
method: method,
headers: headers,
body: bodyJS,
userAgent: Self.safariUA,
includeCredentials: true
)
if let err = result.error {
// WebViewFetcher returns errors in the form "HTTP <code>: <body>"
// or a free-form description. A 403 whose body contains
// `"reason":"clearance"` is the Turnstile gate surface it
// distinctly so the caller can present the gate sheet.
let upstreamStatus = Self.extractStatus(from: err) ?? -1
print("[RouteExplorer] WebView \(method) \(apiPath) failed: \(err)")
if upstreamStatus == 403, err.contains("\"reason\":\"clearance\"") {
throw ClientError.needsClearance
}
throw ClientError.tokenFetchFailed(status: upstreamStatus)
}
guard let data = result.data else {
throw ClientError.tokenFetchFailed(status: -1)
}
return data
}
/// Pull the integer HTTP status code from WebViewFetcher's
/// "HTTP <code>: ..." formatted error string. Returns nil for
/// anything we can't parse.
private static func extractStatus(from err: String) -> Int? {
guard let range = err.range(of: #"HTTP (\d+)"#, options: .regularExpression),
let codeRange = err[range].range(of: #"\d+"#, options: .regularExpression)
else { return nil }
return Int(err[range][codeRange])
}
/// Browser-shaped headers `/api/token` and `/api/flight-search` are
@@ -152,65 +285,77 @@ actor RouteExplorerClient {
json: [String: Any]
) async throws -> RouteSearchResult {
let token = try await currentToken()
let url = baseURL.appendingPathComponent("api/flight-search")
let outerBody: [String: Any] = [
"endpoint": endpoint,
"body": ["json": json]
]
let bodyData = try JSONSerialization.data(withJSONObject: outerBody)
var req = URLRequest(url: url)
let (status, data) = try await postFlightSearch(
token: token,
body: bodyData
)
if status == 200 {
return try decode(data: data)
}
// 403 reason:"token" token expired or rotated. Clear and surface
// a refresh request so the caller can route the user to Settings.
let bodyStr = String(data: data, encoding: .utf8) ?? ""
if status == 403, bodyStr.contains("\"reason\":\"token\"") {
cachedToken = nil
await MainActor.run { RouteExplorerTokenStore.shared.clear() }
throw ClientError.needsTokenRefresh
}
throw ClientError.requestFailed(status: status, body: bodyStr)
}
/// Direct URLSession POST to `/api/flight-search`. Per the
/// 2026-06-05 forensic probe (see `notes/turnstile.md` and the
/// captured `[BOOT] isSimulator=false` diagnostic on a real
/// device), this endpoint validates the X-API-Token alone it does
/// *not* gate on the `rex_clearance` clearance cookie that blocks
/// `/api/token`. So once we have a token (minted by the user in
/// Safari and handed to us via the `flights://routeexplorer-token`
/// scheme), plain URLSession works.
///
/// Returns `(statusCode, responseBody)` so the caller can branch on
/// 403 reason:"token" token-expired kick off a refresh.
private func postFlightSearch(
token: String,
body: Data
) async throws -> (Int, Data) {
var req = URLRequest(url: baseURL.appendingPathComponent("api/flight-search"))
req.httpMethod = "POST"
Self.applyBrowserHeaders(to: &req)
req.httpBody = body
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue("application/json", forHTTPHeaderField: "Accept")
req.setValue(token, forHTTPHeaderField: "X-API-Token")
req.httpBody = bodyData
req.setValue("https://route-explorer.com/", forHTTPHeaderField: "Referer")
req.setValue("https://route-explorer.com", forHTTPHeaderField: "Origin")
req.setValue(Self.safariUA, forHTTPHeaderField: "User-Agent")
req.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language")
// If the bookmarklet also captured JS-visible cookies (`am_user_session`
// etc.), forward them; harmless if the endpoint doesn't require them.
let cookieHeader = await MainActor.run {
RouteExplorerTokenStore.shared.capturedCookieHeader
}
if let cookieHeader, !cookieHeader.isEmpty {
req.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
}
DiagnosticLogger.shared.log("RE", "postFlightSearch", [
"url": req.url?.absoluteString ?? "?",
"bodyLen": body.count,
"hasCookies": !(cookieHeader ?? "").isEmpty,
])
let (data, response) = try await session.data(for: req)
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
// 401 / 403 likely means the token rotated. Drop cache and retry once.
if status == 401 || status == 403 {
cachedToken = nil
return try await retryAfterTokenRotation(endpoint: endpoint, json: json)
}
guard (200...299).contains(status) else {
let bodyStr = String(data: data, encoding: .utf8)
throw ClientError.requestFailed(status: status, body: bodyStr)
}
return try decode(data: data)
}
private func retryAfterTokenRotation(
endpoint: String,
json: [String: Any]
) async throws -> RouteSearchResult {
let token = try await currentToken()
let url = baseURL.appendingPathComponent("api/flight-search")
let outerBody: [String: Any] = [
"endpoint": endpoint,
"body": ["json": json]
]
let bodyData = try JSONSerialization.data(withJSONObject: outerBody)
var req = URLRequest(url: url)
req.httpMethod = "POST"
Self.applyBrowserHeaders(to: &req)
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue(token, forHTTPHeaderField: "X-API-Token")
req.httpBody = bodyData
let (data, response) = try await session.data(for: req)
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
guard (200...299).contains(status) else {
throw ClientError.requestFailed(status: status, body: String(data: data, encoding: .utf8))
}
return try decode(data: data)
DiagnosticLogger.shared.log("RE", "postFlightSearchResult", [
"status": status,
"bodyLen": data.count,
"preview": String((String(data: data, encoding: .utf8) ?? "").prefix(220)),
])
return (status, data)
}
private func decode(data: Data) throws -> RouteSearchResult {
@@ -0,0 +1,125 @@
import Foundation
import Combine
/// Persists a route-explorer `/api/token` value (with expiry) that the
/// user captured from Safari via the bookmarklet flow. Backed by
/// `UserDefaults` because the data is small (~250 bytes) and survives
/// process restarts.
///
/// Why this exists: route-explorer's edge gates `/api/token` behind a
/// Cloudflare Turnstile challenge that requires Apple's Private Access
/// Token. PAT issuance is restricted to apps with the
/// `com.apple.developer.web-browser` entitlement (Safari, Chrome, Brave,
/// DuckDuckGo, etc.) third-party apps don't qualify, so our WKWebView
/// can never mint a token. Safari on the same device *can*, so we let
/// the user trip Turnstile in Safari with a bookmarklet, send the freshly
/// minted token back to the app via the `flights://routeexplorer-token`
/// URL scheme, and use that token from URLSession until it expires.
@MainActor
final class RouteExplorerTokenStore: ObservableObject {
static let shared = RouteExplorerTokenStore()
private let defaults = UserDefaults.standard
@Published private(set) var token: String?
@Published private(set) var expiresAt: Date?
/// Optional cookie jar captured at the same time as the token. Some
/// route-explorer endpoints may also gate on `rex_clearance` /
/// `am_user_session`; if the bookmarklet manages to capture them
/// (they need to be non-HttpOnly for `document.cookie` to read them),
/// we attach them on outgoing requests.
@Published private(set) var capturedCookieHeader: String?
private init() {
if let stored = defaults.string(forKey: Keys.token),
let expEpoch = defaults.object(forKey: Keys.expiresAt) as? TimeInterval {
self.token = stored
self.expiresAt = Date(timeIntervalSince1970: expEpoch)
}
self.capturedCookieHeader = defaults.string(forKey: Keys.cookieHeader)
}
var isValid: Bool {
guard let token, !token.isEmpty,
let expiresAt, expiresAt > Date()
else { return false }
_ = token
return true
}
var timeRemaining: TimeInterval {
guard let expiresAt else { return 0 }
return max(0, expiresAt.timeIntervalSinceNow)
}
/// Store a token captured from the Safari bookmarklet flow.
/// `expiresInSeconds` defaults to 30 minutes (route-explorer's
/// typical token TTL); the caller can override if the bookmarklet
/// surfaces a precise expiry.
func store(token: String,
expiresInSeconds: TimeInterval = 30 * 60,
cookieHeader: String? = nil) {
let exp = Date(timeIntervalSinceNow: expiresInSeconds)
self.token = token
self.expiresAt = exp
self.capturedCookieHeader = cookieHeader
defaults.set(token, forKey: Keys.token)
defaults.set(exp.timeIntervalSince1970, forKey: Keys.expiresAt)
if let cookieHeader, !cookieHeader.isEmpty {
defaults.set(cookieHeader, forKey: Keys.cookieHeader)
} else {
defaults.removeObject(forKey: Keys.cookieHeader)
}
DiagnosticLogger.shared.log("RETOK", "stored", [
"expiresAt": exp.timeIntervalSince1970,
"cookieLen": cookieHeader?.count ?? 0,
])
}
func clear() {
token = nil
expiresAt = nil
capturedCookieHeader = nil
defaults.removeObject(forKey: Keys.token)
defaults.removeObject(forKey: Keys.expiresAt)
defaults.removeObject(forKey: Keys.cookieHeader)
DiagnosticLogger.shared.log("RETOK", "cleared", [:])
}
// MARK: - URL scheme ingest
/// Returns true if `url` is the route-explorer token deep link and
/// the credentials were successfully extracted + stored.
@discardableResult
func ingest(url: URL) -> Bool {
guard url.scheme == "flights",
url.host == "routeexplorer-token"
else { return false }
let comps = URLComponents(url: url, resolvingAgainstBaseURL: false)
let items = comps?.queryItems ?? []
func val(_ k: String) -> String? { items.first { $0.name == k }?.value }
guard let token = val("token"), !token.isEmpty else {
DiagnosticLogger.shared.log("RETOK", "ingestNoToken", [
"url": url.absoluteString,
])
return false
}
let exp: TimeInterval = {
if let expStr = val("exp"), let expVal = TimeInterval(expStr) {
return max(0, expVal - Date().timeIntervalSince1970)
}
return 30 * 60
}()
let cookie = val("cookie")?.removingPercentEncoding
store(token: token, expiresInSeconds: exp, cookieHeader: cookie)
return true
}
// MARK: - Storage keys
private enum Keys {
static let token = "re.token.value"
static let expiresAt = "re.token.expiresAt"
static let cookieHeader = "re.token.cookieHeader"
}
}
+211
View File
@@ -0,0 +1,211 @@
import Foundation
/// The slice of `FlightService` SisterFlightService consumes. Defined here
/// so tests can inject a mock without standing up a live FlightConnections
/// session. FlightService conforms below (its public methods already match
/// these signatures verbatim).
protocol FlightScheduleProvider: Sendable {
func searchAirports(term: String) async throws -> [Airport]
func allSchedules(
dep: String,
des: String,
onProgress: @Sendable @escaping (Int, Int) -> Void
) async throws -> [FlightSchedule]
}
extension FlightService: FlightScheduleProvider {}
/// Discovers backup-itinerary candidates ("sister flights") for nonrev / standby travelers:
/// every flight operating the same origin-destination pair on the same calendar day,
/// surfaced with predicted load so the user can pick the lightest backup.
///
/// Backed by `FlightScheduleProvider` (FlightConnections in production, mocks in tests).
/// Predicted load is optional if `LoadFactorService` is wired in later, plug it into
/// `loadPredictor` to populate `SisterFlight.predictedLoad`. Sort puts the emptiest
/// flights on top.
actor SisterFlightService {
static let shared = SisterFlightService(flightService: FlightService.shared)
// MARK: - Public Types
struct SisterFlight: Sendable, Identifiable {
let id: String
let carrier: String
let flightNumber: Int
let scheduledDeparture: Date
let scheduledArrival: Date
let aircraftDisplay: String?
let predictedLoad: Double?
let isYourFlight: Bool
}
// MARK: - Dependencies
private let flightService: FlightScheduleProvider
/// Optional hook for predicted load. Signature: (carrier IATA, flight number, date) -> 0...1 load fraction.
/// Leave nil until `LoadFactorService` lands; the service then sets this on init.
private let loadPredictor: (@Sendable (String, Int, Date) async -> Double?)?
// MARK: - Init
init(
flightService: FlightScheduleProvider,
loadPredictor: (@Sendable (String, Int, Date) async -> Double?)? = nil
) {
self.flightService = flightService
self.loadPredictor = loadPredictor
}
// MARK: - Public API
/// Returns every scheduled flight on the origin/destination pair operating on the
/// given local date. Results are sorted by predictedLoad ascending (nil last),
/// breaking ties on scheduledDeparture ascending. The flight matching
/// `currentFlight` (carrier IATA + flight number) is flagged with `isYourFlight = true`.
func sisterFlights(
origin: String,
dest: String,
date: Date,
currentFlight: (carrier: String, number: Int)?
) async -> [SisterFlight] {
let originIATA = origin.uppercased()
let destIATA = dest.uppercased()
guard let originId = await resolveAirportId(iata: originIATA) else {
print("[SisterFlight] could not resolve origin IATA \(originIATA)")
return []
}
guard let destId = await resolveAirportId(iata: destIATA) else {
print("[SisterFlight] could not resolve dest IATA \(destIATA)")
return []
}
let schedules: [FlightSchedule]
do {
schedules = try await flightService.allSchedules(
dep: originId,
des: destId,
onProgress: { _, _ in }
)
} catch {
print("[SisterFlight] allSchedules failed: \(error.localizedDescription)")
return []
}
let operating = schedules.filter { $0.operatesOn(date: date) }
print("[SisterFlight] \(operating.count)/\(schedules.count) schedules operate on \(date)")
var results: [SisterFlight] = []
results.reserveCapacity(operating.count)
for schedule in operating {
guard let (depDate, arrDate) = scheduledDates(for: schedule, on: date) else {
continue
}
let flightNumberInt = parseFlightNumber(schedule.flightNumber)
let carrierIATA = schedule.airline.iata.uppercased()
let aircraft = displayAircraft(schedule)
let isYours: Bool = {
guard let current = currentFlight, let fn = flightNumberInt else { return false }
return current.carrier.uppercased() == carrierIATA && current.number == fn
}()
let load: Double? = await {
guard let predictor = self.loadPredictor, let fn = flightNumberInt else {
return nil
}
return await predictor(carrierIATA, fn, depDate)
}()
let id = "\(carrierIATA)-\(schedule.flightNumber)-\(Int(depDate.timeIntervalSince1970))"
results.append(SisterFlight(
id: id,
carrier: carrierIATA,
flightNumber: flightNumberInt ?? 0,
scheduledDeparture: depDate,
scheduledArrival: arrDate,
aircraftDisplay: aircraft,
predictedLoad: load,
isYourFlight: isYours
))
}
results.sort { lhs, rhs in
switch (lhs.predictedLoad, rhs.predictedLoad) {
case let (l?, r?):
if l != r { return l < r }
return lhs.scheduledDeparture < rhs.scheduledDeparture
case (_?, nil):
return true
case (nil, _?):
return false
case (nil, nil):
return lhs.scheduledDeparture < rhs.scheduledDeparture
}
}
return results
}
// MARK: - Helpers
/// Resolves an IATA code (e.g. "JFK") to the FlightConnections internal airport ID via the autocomplete API.
private func resolveAirportId(iata: String) async -> String? {
do {
let matches = try await flightService.searchAirports(term: iata)
if let exact = matches.first(where: { $0.iata.uppercased() == iata }) {
return exact.id
}
return matches.first?.id
} catch {
print("[SisterFlight] searchAirports failed for \(iata): \(error.localizedDescription)")
return nil
}
}
/// Combines the user-picked date with the schedule's HH:mm strings, interpreted in UTC,
/// to produce concrete departure/arrival Dates. Arrival rolls to the next day if it falls
/// before departure (red-eye).
private func scheduledDates(for schedule: FlightSchedule, on date: Date) -> (Date, Date)? {
var utc = Calendar(identifier: .gregorian)
utc.timeZone = TimeZone(identifier: "UTC")!
let localComponents = Calendar.current.dateComponents([.year, .month, .day], from: date)
guard let day = utc.date(from: localComponents) else { return nil }
guard let dep = applyTime(schedule.departureTime, to: day, calendar: utc) else { return nil }
guard var arr = applyTime(schedule.arrivalTime, to: day, calendar: utc) else { return nil }
if arr < dep {
arr = utc.date(byAdding: .day, value: 1, to: arr) ?? arr
}
return (dep, arr)
}
private func applyTime(_ hhmm: String, to day: Date, calendar: Calendar) -> Date? {
let parts = hhmm.split(separator: ":")
guard parts.count >= 2,
let hour = Int(parts[0]),
let minute = Int(parts[1]) else { return nil }
return calendar.date(bySettingHour: hour, minute: minute, second: 0, of: day)
}
/// FlightSchedule.flightNumber is a String like "DL 1234" or "1234" pull the trailing integer.
private func parseFlightNumber(_ raw: String) -> Int? {
let trimmed = raw.trimmingCharacters(in: .whitespaces)
if let direct = Int(trimmed) { return direct }
let lastToken = trimmed.split(whereSeparator: { !$0.isNumber }).last.map(String.init) ?? ""
return Int(lastToken)
}
private func displayAircraft(_ schedule: FlightSchedule) -> String? {
let aircraft = schedule.aircraft.trimmingCharacters(in: .whitespaces)
return aircraft.isEmpty ? nil : aircraft
}
}
+122
View File
@@ -0,0 +1,122 @@
import Foundation
import SwiftData
/// Aggregate result of a personal standby-success query. All counts are
/// derived from the user's own LoggedFlight history; nothing is fetched
/// from the network. Sendable so it can cross actor boundaries safely.
struct StandbyRate: Sendable {
/// standby-made + standby-bumped (i.e. every flight the user actually
/// stood by for, regardless of outcome).
let attempts: Int
/// Outcome == "standby-made".
let made: Int
/// Outcome == "standby-bumped".
let bumped: Int
/// Outcome == "confirmed" not a standby attempt, but useful for
/// "out of N flights on this route, X were confirmed seats".
let confirmed: Int
/// made / attempts. Zero when there are no attempts.
let rate: Double
static let empty = StandbyRate(attempts: 0, made: 0, bumped: 0, confirmed: 0, rate: 0)
}
/// Computes personal standby success metrics from the local SwiftData
/// store. Filters are optional and combined with AND. The intent is to
/// answer questions like "what's my clear rate on WN out of DAL?".
@MainActor
final class StandbyStatsService {
init() {}
/// Personal standby clear rate, optionally narrowed by carrier and/or
/// route endpoints. Carrier matches against both IATA and ICAO codes
/// so the caller doesn't need to know which one was stored.
func personalRate(
carrier: String?,
origin: String?,
dest: String?,
context: ModelContext
) -> StandbyRate {
let flights = fetchFlightsWithStandbyOutcome(
carrier: carrier,
origin: origin,
dest: dest,
context: context
)
var made = 0
var bumped = 0
var confirmed = 0
for f in flights {
switch f.standbyOutcome {
case "standby-made": made += 1
case "standby-bumped": bumped += 1
case "confirmed": confirmed += 1
default: break
}
}
let attempts = made + bumped
let rate = attempts > 0 ? Double(made) / Double(attempts) : 0
print("[StandbyStats] personalRate carrier=\(carrier ?? "*") "
+ "origin=\(origin ?? "*") dest=\(dest ?? "*") "
+ "attempts=\(attempts) made=\(made) bumped=\(bumped) "
+ "confirmed=\(confirmed) rate=\(String(format: "%.2f", rate))")
return StandbyRate(
attempts: attempts,
made: made,
bumped: bumped,
confirmed: confirmed,
rate: rate
)
}
/// Most recent flights that have any standby outcome set, newest
/// first. Used by the History tab to show a "your last N standby
/// attempts" strip.
func recentOutcomes(limit: Int, context: ModelContext) -> [LoggedFlight] {
var descriptor = FetchDescriptor<LoggedFlight>(
predicate: #Predicate { $0.standbyOutcome != nil },
sortBy: [SortDescriptor(\.flightDate, order: .reverse)]
)
descriptor.fetchLimit = max(0, limit)
let results = (try? context.fetch(descriptor)) ?? []
print("[StandbyStats] recentOutcomes limit=\(limit) returned=\(results.count)")
return results
}
// MARK: - Private
/// SwiftData's #Predicate macro is finicky about optional captures, so
/// we apply the optional carrier/origin/dest filters in Swift after a
/// single fetch of every flight with a non-nil standbyOutcome. The
/// data set is bounded by the user's own flight history, so this is
/// trivial in practice.
private func fetchFlightsWithStandbyOutcome(
carrier: String?,
origin: String?,
dest: String?,
context: ModelContext
) -> [LoggedFlight] {
let descriptor = FetchDescriptor<LoggedFlight>(
predicate: #Predicate { $0.standbyOutcome != nil }
)
let all = (try? context.fetch(descriptor)) ?? []
let carrierUpper = carrier?.uppercased()
let originUpper = origin?.uppercased()
let destUpper = dest?.uppercased()
return all.filter { f in
if let carrierUpper {
let iata = f.carrierIATA?.uppercased()
let icao = f.carrierICAO?.uppercased()
if iata != carrierUpper && icao != carrierUpper { return false }
}
if let originUpper, f.departureIATA.uppercased() != originUpper { return false }
if let destUpper, f.arrivalIATA.uppercased() != destUpper { return false }
return true
}
}
}
+136
View File
@@ -0,0 +1,136 @@
import Foundation
import CoreLocation
/// Computes totals + narrative stats over the user's flight history.
/// Pure derivation no side effects, no I/O. Built once per view body
/// pass over a flights snapshot.
@MainActor
struct StatsEngine {
let flights: [LoggedFlight]
let store: FlightHistoryStore
let database: AirportDatabase
init(store: FlightHistoryStore, database: AirportDatabase, flights: [LoggedFlight]) {
self.store = store
self.database = database
self.flights = flights
}
// MARK: - Totals
var totalFlights: Int { flights.count }
var totalMiles: Int {
flights.reduce(0) { acc, f in acc + (store.distanceMiles(for: f) ?? 0) }
}
var totalMinutes: Int {
flights.reduce(0) { acc, f in
// Prefer logged duration; fall back to estimated 7 min per 100 mi.
if let d = store.durationMinutes(for: f) { return acc + d }
if let mi = store.distanceMiles(for: f) { return acc + Int(Double(mi) / 100.0 * 7.0) }
return acc
}
}
var totalHours: Int { totalMinutes / 60 }
var uniqueAirports: Int {
Set(flights.flatMap { [$0.departureIATA, $0.arrivalIATA] }
.filter { !$0.isEmpty }).count
}
var uniqueAirlines: Int {
Set(flights.compactMap { $0.carrierICAO ?? $0.carrierIATA }).count
}
var uniqueAircraftTypes: Int {
Set(flights.compactMap { $0.aircraftType }).count
}
var uniqueCountries: Int {
Set(flights.flatMap { [$0.departureIATA, $0.arrivalIATA] }
.compactMap { database.airport(byIATA: $0)?.country }).count
}
// MARK: - Compact display
var shortDistance: String {
let n = totalMiles
if n >= 1_000_000 { return String(format: "%.1fM", Double(n) / 1_000_000) }
if n >= 10_000 { return String(format: "%.0fk", Double(n) / 1_000) }
return numberString(n)
}
var shortDuration: String {
if totalHours >= 1000 { return String(format: "%.0fk", Double(totalHours) / 1_000) }
return "\(totalHours)"
}
// MARK: - Narrative
/// Most-flown carrier ICAO.
var topAirline: (icao: String, count: Int)? {
let counts = Dictionary(grouping: flights.compactMap { $0.carrierICAO ?? $0.carrierIATA }) { $0 }
.mapValues(\.count)
return counts.max(by: { $0.value < $1.value }).map { ($0.key, $0.value) }
}
/// Most-flown route (dep + arr, ignoring direction).
var topRoute: (label: String, count: Int)? {
let pairs = flights.map { f in [f.departureIATA, f.arrivalIATA].sorted().joined(separator: "") }
let counts = Dictionary(grouping: pairs) { $0 }.mapValues(\.count)
return counts.max(by: { $0.value < $1.value }).map { ($0.key, $0.value) }
}
/// Most-visited airport (counts each endpoint independently).
var topAirport: (iata: String, count: Int)? {
let codes = flights.flatMap { [$0.departureIATA, $0.arrivalIATA] }.filter { !$0.isEmpty }
let counts = Dictionary(grouping: codes) { $0 }.mapValues(\.count)
return counts.max(by: { $0.value < $1.value }).map { ($0.key, $0.value) }
}
/// Tail numbers we've flown more than once.
var repeatedTails: [(reg: String, count: Int)] {
let regs = flights.compactMap { $0.registration }
let counts = Dictionary(grouping: regs) { $0 }.mapValues(\.count)
return counts.filter { $0.value > 1 }
.map { ($0.key, $0.value) }
.sorted { $0.count > $1.count }
}
/// Longest single flight by distance.
var longestFlight: LoggedFlight? {
flights.max { (store.distanceMiles(for: $0) ?? 0) < (store.distanceMiles(for: $1) ?? 0) }
}
/// Shortest single flight by distance.
var shortestFlight: LoggedFlight? {
flights
.filter { (store.distanceMiles(for: $0) ?? 0) > 0 }
.min { (store.distanceMiles(for: $0) ?? 0) < (store.distanceMiles(for: $1) ?? 0) }
}
/// Flights bucketed by year, most recent first.
var byYear: [(year: Int, flights: [LoggedFlight])] {
let cal = Calendar.current
let grouped = Dictionary(grouping: flights) { cal.component(.year, from: $0.flightDate) }
return grouped
.map { (year: $0.key, flights: $0.value) }
.sorted { $0.year > $1.year }
}
/// Flights for one calendar year.
func flights(for year: Int) -> [LoggedFlight] {
let cal = Calendar.current
return flights.filter { cal.component(.year, from: $0.flightDate) == year }
}
// MARK: - Helpers
private func numberString(_ n: Int) -> String {
let f = NumberFormatter()
f.numberStyle = .decimal
return f.string(from: NSNumber(value: n)) ?? "\(n)"
}
}
+154
View File
@@ -0,0 +1,154 @@
import Foundation
import PassKit
import Combine
/// Watches Apple Wallet's PKPassLibrary for newly-added boarding passes
/// and emits parsed flight data. The app can subscribe and prompt to
/// log when one shows up.
///
/// PKPassLibrary read access doesn't require the
/// `pass-type-identifiers` entitlement (which is only needed to write
/// passes you own). Listening to library-change notifications and
/// reading metadata of any boarding pass works on a default app.
@MainActor
final class WalletPassObserver: ObservableObject {
static let shared = WalletPassObserver()
@Published private(set) var pendingPass: ParsedPass?
struct ParsedPass: Hashable {
let flightDate: Date
let carrierIATA: String?
let flightNumber: String?
let departureIATA: String?
let arrivalIATA: String?
let seat: String?
let serialNumber: String
}
private var library: PKPassLibrary?
private var token: NSObjectProtocol?
private var knownSerials: Set<String> = []
private var started = false
private init() {
// Deliberately empty. PKPassLibrary touches the PassKit
// subsystem which can stall or fail under some
// configurations; defer to start() called explicitly from a
// view's .task / .onAppear.
}
/// Begin observing the user's Wallet for new boarding passes.
/// Safe to call multiple times; subsequent calls are no-ops.
func start() {
guard !started else { return }
started = true
let lib = PKPassLibrary()
self.library = lib
// Seed with currently-installed passes so we don't spam on
// first launch we only want to prompt for *new* passes.
for p in lib.passes() {
knownSerials.insert(p.serialNumber)
}
startObserving(lib)
}
private func startObserving(_ library: PKPassLibrary) {
// PKPassLibraryDidChangeNotification is posted whenever the
// user adds/removes a pass. We diff the library against our
// seen set to find the new one.
// The PKPassLibrary notification name isn't exposed as a typed
// constant on the class fall back to the raw string the
// framework posts.
token = NotificationCenter.default.addObserver(
forName: Notification.Name("PKPassLibraryDidChangeNotification"),
object: library,
queue: .main
) { [weak self] note in
Task { @MainActor [weak self] in
self?.diff()
}
}
}
private func diff() {
guard let library else { return }
let current = library.passes()
for pass in current {
if knownSerials.contains(pass.serialNumber) { continue }
knownSerials.insert(pass.serialNumber)
if let parsed = Self.parse(pass) {
pendingPass = parsed
return
}
}
}
/// Clear the published pending pass once the UI has consumed it.
func clearPending() {
pendingPass = nil
}
// MARK: - Parsing
//
// A pkpass JSON manifests includes a "boardingPass" object with
// `transitType: PKTransitTypeAir`, then a soup of structured
// fields. The standard names used by most airlines:
// primaryFields[0].key = "depart" or "origin"
// primaryFields[1].key = "destination"
// auxiliaryFields[] includes seat / gate / flight#
// We don't have direct access to the JSON only to PKPass's
// typed API (`localizedValue(forFieldKey:)`).
private static func parse(_ pass: PKPass) -> ParsedPass? {
// PKPass doesn't expose pass-style (boarding/coupon/event/etc.)
// via a typed property we infer it from the presence of
// boarding-pass-style field keys below.
// Common field keys across airlines.
let originKey = ["origin", "depart", "from", "departing"]
.first { pass.localizedValue(forFieldKey: $0) != nil }
let destKey = ["destination", "arrive", "to", "arriving"]
.first { pass.localizedValue(forFieldKey: $0) != nil }
let flightKey = ["flight", "flightNumber", "flightNo"]
.first { pass.localizedValue(forFieldKey: $0) != nil }
let seatKey = ["seat", "seatNumber"]
.first { pass.localizedValue(forFieldKey: $0) != nil }
let origin = originKey.flatMap { pass.localizedValue(forFieldKey: $0) as? String }
let dest = destKey.flatMap { pass.localizedValue(forFieldKey: $0) as? String }
let flight = flightKey.flatMap { pass.localizedValue(forFieldKey: $0) as? String }
let seat = seatKey.flatMap { pass.localizedValue(forFieldKey: $0) as? String }
// Try to split the flight into carrier + number. Boarding pass
// values are typically formatted like "WN 7" or "AA2178".
var carrier: String?
var number: String?
if let flight, let m = flight.range(of: "([A-Z]{2,3})\\s*([0-9]{1,4})", options: .regularExpression) {
let s = String(flight[m])
let scanner = Scanner(string: s)
scanner.charactersToBeSkipped = .whitespaces
var letters: NSString?
var digits: NSString?
scanner.scanCharacters(from: .uppercaseLetters, into: &letters)
scanner.scanCharacters(from: .decimalDigits, into: &digits)
carrier = letters as String?
number = digits as String?
}
// The relevant date is pass.relevantDate (when the pass should
// appear on the lock screen). For a boarding pass, that's
// typically the departure time.
let flightDate = pass.relevantDate ?? Date()
return ParsedPass(
flightDate: flightDate,
carrierIATA: carrier,
flightNumber: number,
departureIATA: origin?.uppercased(),
arrivalIATA: dest?.uppercased(),
seat: seat,
serialNumber: pass.serialNumber
)
}
}
+287
View File
@@ -0,0 +1,287 @@
import Foundation
/// Plain-text risk band derived from the hourly forecast nearest to the
/// requested time. Used by trip-day UI to surface a "heads up" banner
/// without making the user parse raw weather codes.
enum WeatherRisk: Sendable {
case low
case moderate
case high
}
/// Snapshot of conditions at an airport for a specific date. All values
/// are sampled from the hour closest to `date` in Open-Meteo's hourly
/// arrays, with daily precipitation probability folded in.
struct WeatherForecast: Sendable {
let airport: String
let temperatureC: Double
let precipMM: Double
let windKmh: Double
let visibilityM: Double
let weatherCode: Int
let precipProbabilityPct: Int
let riskScore: WeatherRisk
let summary: String
}
/// Open-Meteo forecast client.
///
/// Open-Meteo is a free, key-less public weather API. We hit the
/// `/v1/forecast` endpoint with the airport's lat/lng and ask for the
/// hourly arrays we care about (temperature, precip, wind, visibility,
/// weather code) plus the daily precip probability max. Results are
/// cached per (iata, yyyy-MM-dd) for the lifetime of the actor so a
/// trip view that asks for multiple legs on the same day doesn't fan
/// out duplicate requests.
actor WeatherClient {
/// Shared singleton so that the per-actor in-memory cache survives
/// across views and we don't fan out duplicate Open-Meteo requests
/// when multiple sheets ask about the same endpoint on the same day.
static let shared = WeatherClient()
private struct CacheKey: Hashable {
let iata: String
let dayKey: String
}
private let session: URLSession
private var cache: [CacheKey: WeatherForecast] = [:]
init(session: URLSession = .shared) {
self.session = session
}
/// Returns nil when the airport is unknown, the network call fails,
/// or the response can't be decoded. Callers should treat nil as
/// "no forecast available" and just hide the weather chip there's
/// no recovery worth retrying inline.
func forecast(forIATA iata: String, on date: Date, database: AirportDatabase) async -> WeatherForecast? {
let upper = iata.uppercased()
guard let airport = database.airport(byIATA: upper) else {
print("[Weather] Unknown airport: \(upper)")
return nil
}
// Bucket the cache by the airport's *local* calendar day. A flight
// departing JFK at 23:00 EST and an arrival into JFK at 02:00 EST
// the next morning UTC are the same operational day from the
// traveller's perspective; using the airport tz keeps the cache key
// stable for that case.
//
// Prefer the curated IANA identifier from AirportDatabase so we
// observe DST transitions (JFK is EDT in summer, not EST). Fall
// back to a longitude approximation only for airports we don't
// have an explicit entry for.
let airportTZ: TimeZone = database.timeZone(forIATA: upper)
?? Self.fallbackTimeZone(for: airport)
let dayKey = Self.dayKey(for: date, in: airportTZ)
let key = CacheKey(iata: upper, dayKey: dayKey)
if let hit = cache[key] {
return hit
}
var comps = URLComponents(string: "https://api.open-meteo.com/v1/forecast")!
comps.queryItems = [
URLQueryItem(name: "latitude", value: String(format: "%.4f", airport.lat)),
URLQueryItem(name: "longitude", value: String(format: "%.4f", airport.lng)),
URLQueryItem(name: "hourly", value: "temperature_2m,precipitation,wind_speed_10m,visibility,weather_code"),
URLQueryItem(name: "daily", value: "weathercode,precipitation_probability_max"),
URLQueryItem(name: "timezone", value: "auto"),
URLQueryItem(name: "forecast_days", value: "3"),
]
guard let url = comps.url else {
print("[Weather] Failed to build URL for \(upper)")
return nil
}
var req = URLRequest(url: url)
req.timeoutInterval = 12
req.setValue("application/json", forHTTPHeaderField: "Accept")
do {
let (data, response) = try await session.data(for: req)
if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
print("[Weather] HTTP \(http.statusCode) for \(upper)")
return nil
}
let decoded = try JSONDecoder().decode(OpenMeteoResponse.self, from: data)
guard let forecast = Self.materialize(decoded, iata: upper, target: date, airportTZ: airportTZ) else {
print("[Weather] Empty/invalid hourly arrays for \(upper)")
return nil
}
cache[key] = forecast
return forecast
} catch {
print("[Weather] Fetch failed for \(upper): \(error.localizedDescription)")
return nil
}
}
// MARK: - Materialization
private static func materialize(_ raw: OpenMeteoResponse, iata: String, target: Date, airportTZ: TimeZone) -> WeatherForecast? {
guard let hourly = raw.hourly, !hourly.time.isEmpty else { return nil }
// Open-Meteo returns times as bare wall-clock strings in the
// requested timezone (e.g. "2026-05-31T15:00") when we ask for
// timezone=auto. To find the hourly slot closest to our target
// Date we have to parse those strings in the *airport's* local
// timezone not UTC, otherwise we'd pick a slot offset by the
// airport's UTC delta.
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime]
let fallback = DateFormatter()
fallback.locale = Locale(identifier: "en_US_POSIX")
fallback.dateFormat = "yyyy-MM-dd'T'HH:mm"
// Prefer the timezone the Open-Meteo response says it used; fall
// back to the airport-derived tz when the field is missing.
let responseTZ = raw.timezone.flatMap(TimeZone.init(identifier:)) ?? airportTZ
fallback.timeZone = responseTZ
var bestIndex = 0
var bestDelta = TimeInterval.greatestFiniteMagnitude
for (idx, stamp) in hourly.time.enumerated() {
let parsed = formatter.date(from: stamp) ?? fallback.date(from: stamp)
guard let parsed else { continue }
let delta = abs(parsed.timeIntervalSince(target))
if delta < bestDelta {
bestDelta = delta
bestIndex = idx
}
}
func sample(_ arr: [Double?]?) -> Double {
guard let arr, bestIndex < arr.count, let v = arr[bestIndex] else { return 0 }
return v
}
func sampleInt(_ arr: [Int?]?) -> Int {
guard let arr, bestIndex < arr.count, let v = arr[bestIndex] else { return 0 }
return v
}
let temperatureC = sample(hourly.temperature_2m)
let precipMM = sample(hourly.precipitation)
let windKmh = sample(hourly.wind_speed_10m)
let visibilityM = sample(hourly.visibility)
let weatherCode = sampleInt(hourly.weather_code)
// Pick the daily index matching the target's calendar day in the
// airport's local time. Open-Meteo's daily.time entries are bare
// dates ("2026-05-31") aligned to the response timezone.
let targetDayKey = dayKey(for: target, in: airportTZ)
var precipProb = 0
if let daily = raw.daily, let probs = daily.precipitation_probability_max {
if let dayIdx = daily.time.firstIndex(of: targetDayKey),
dayIdx < probs.count,
let v = probs[dayIdx] {
precipProb = v
} else if let firstOpt = probs.first, let first = firstOpt {
precipProb = first
}
}
let risk = riskBand(precipProb: precipProb,
weatherCode: weatherCode,
visibilityM: visibilityM,
windKmh: windKmh)
let summary = summarize(weatherCode: weatherCode)
return WeatherForecast(
airport: iata,
temperatureC: temperatureC,
precipMM: precipMM,
windKmh: windKmh,
visibilityM: visibilityM,
weatherCode: weatherCode,
precipProbabilityPct: precipProb,
riskScore: risk,
summary: summary
)
}
private static func riskBand(precipProb: Int, weatherCode: Int, visibilityM: Double, windKmh: Double) -> WeatherRisk {
if precipProb > 60 || (95...99).contains(weatherCode) || visibilityM < 2000 {
return .high
}
if precipProb > 30 || windKmh > 40 {
return .moderate
}
return .low
}
/// WMO weather code human one-liner. Codes are grouped by
/// phenomenon, not intensity finer breakdowns (light/heavy)
/// would just clutter the chip.
private static func summarize(weatherCode code: Int) -> String {
switch code {
case 0: return "Clear sky"
case 1: return "Mostly clear"
case 2: return "Partly cloudy"
case 3: return "Overcast"
case 45, 48: return "Fog"
case 51, 53, 55: return "Drizzle"
case 56, 57: return "Freezing drizzle"
case 61, 63, 65: return "Rain"
case 66, 67: return "Freezing rain"
case 71, 73, 75: return "Snow"
case 77: return "Snow grains"
case 80, 81, 82: return "Rain showers"
case 85, 86: return "Snow showers"
case 95: return "Thunderstorm"
case 96, 99: return "Thunderstorm with hail"
default: return "Conditions unavailable"
}
}
/// Returns "yyyy-MM-dd" for `date` interpreted in the given timezone.
/// Falls back to UTC when no timezone is supplied this matches the
/// pre-refactor behaviour for any caller that doesn't yet have an
/// airport context. Exposed at module-internal scope so tests can
/// pin the local-day rollover behaviour without going through the
/// network path.
static func dayKey(for date: Date, in timeZone: TimeZone? = nil) -> String {
let fmt = DateFormatter()
fmt.locale = Locale(identifier: "en_US_POSIX")
fmt.dateFormat = "yyyy-MM-dd"
fmt.timeZone = timeZone ?? TimeZone(identifier: "UTC")!
return fmt.string(from: date)
}
/// Fallback timezone when ``AirportDatabase.timeZone(forIATA:)`` doesn't
/// have an explicit entry. Uses a longitude approximation (15° 1
/// hour) which ignores political tz boundaries + DST only correct
/// to within an hour, but better than UTC for unmapped airports.
/// Almost everything the user actually opens hits the curated IANA
/// table, so this branch is the long-tail safety net.
private static func fallbackTimeZone(for airport: MapAirport) -> TimeZone {
let rawHours = (airport.lng / 15.0).rounded()
let hours = max(-12, min(14, Int(rawHours)))
return TimeZone(secondsFromGMT: hours * 3600) ?? TimeZone(identifier: "UTC")!
}
}
// MARK: - Open-Meteo DTOs
private struct OpenMeteoResponse: Decodable {
let timezone: String?
let hourly: Hourly?
let daily: Daily?
struct Hourly: Decodable {
let time: [String]
let temperature_2m: [Double?]?
let precipitation: [Double?]?
let wind_speed_10m: [Double?]?
let visibility: [Double?]?
let weather_code: [Int?]?
}
struct Daily: Decodable {
let time: [String]
let weathercode: [Int?]?
let precipitation_probability_max: [Int?]?
}
}
+75 -99
View File
@@ -1,57 +1,16 @@
import Foundation
import WebKit
/// Uses a hidden WKWebView to execute fetch() calls with a real browser TLS fingerprint.
/// This bypasses Akamai bot detection that rejects URLSession requests.
/// Runs XHRs from inside a WKWebView that's been navigated to a target
/// origin, so the request carries Safari's TLS fingerprint and any
/// first-party cookies the edge expects. The cookie store is the
/// process-wide persistent `WKWebsiteDataStore.default()`, shared with
/// `RouteExplorerGateSheet` once the user clears Cloudflare Turnstile
/// once, the `am_clearance` cookie sticks across app launches and every
/// subsequent fetch reuses it.
@MainActor
final class WebViewFetcher {
private var webView: WKWebView?
func runJavaScript(
navigateTo pageURL: String,
userAgent: String? = nil,
waitBeforeExecutingMs: UInt64 = 2000,
script: String
) async -> (value: Any?, error: String?) {
let webView = WKWebView(frame: .zero)
self.webView = webView
webView.customUserAgent = userAgent
guard let url = URL(string: pageURL) else {
return (nil, "Invalid page URL")
}
print("[WebViewFetcher] Navigating to \(pageURL)")
let navResult = await withCheckedContinuation { (continuation: CheckedContinuation<Bool, Never>) in
let delegate = NavigationDelegate(continuation: continuation)
webView.navigationDelegate = delegate
webView.load(URLRequest(url: url))
objc_setAssociatedObject(webView, "navDelegate", delegate, .OBJC_ASSOCIATION_RETAIN)
}
guard navResult else {
self.webView = nil
return (nil, "Failed to load page")
}
try? await Task.sleep(for: .milliseconds(waitBeforeExecutingMs))
let cookieNames = await currentCookieNames(for: webView)
if !cookieNames.isEmpty {
print("[WebViewFetcher] Cookies after navigation: \(cookieNames.sorted())")
}
do {
let result = try await webView.callAsyncJavaScript(script, contentWorld: .page)
self.webView = nil
return (result, nil)
} catch {
print("[WebViewFetcher] callAsyncJavaScript error: \(error)")
self.webView = nil
return (nil, error.localizedDescription)
}
}
/// Navigate to a domain to establish cookies/session, then execute a fetch from that context.
func fetch(
navigateTo pageURL: String,
fetchURL: String,
@@ -61,8 +20,50 @@ final class WebViewFetcher {
userAgent: String? = nil,
includeCredentials: Bool = false
) async -> (data: String?, error: String?) {
DiagnosticLogger.shared.log("WVF", "begin", [
"pageURL": pageURL,
"fetchURL": fetchURL,
"method": method,
"ua": userAgent ?? "(default)",
])
let config = WKWebViewConfiguration()
config.websiteDataStore = .default()
let webView = WKWebView(frame: CGRect(x: 0, y: 0, width: 1, height: 1), configuration: config)
webView.customUserAgent = userAgent
guard let url = URL(string: pageURL) else {
DiagnosticLogger.shared.log("WVF", "invalidURL", ["url": pageURL])
return (nil, "Invalid page URL")
}
let navResult = await withCheckedContinuation { (continuation: CheckedContinuation<Bool, Never>) in
let delegate = NavigationDelegate(continuation: continuation)
webView.navigationDelegate = delegate
webView.load(URLRequest(url: url))
objc_setAssociatedObject(webView, "navDelegate", delegate, .OBJC_ASSOCIATION_RETAIN)
}
// Snapshot cookies on the data store so we can see what the
// navigation handed us. Cookie scope matters here because
// includeCredentials=true reads from this same store.
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
let domainCookies = cookies.filter {
guard let host = URL(string: pageURL)?.host else { return false }
return $0.domain.contains(host) || host.contains($0.domain.trimmingCharacters(in: .init(charactersIn: ".")))
}
DiagnosticLogger.shared.log("WVF", "navDone", [
"ok": navResult,
"cookieCount": domainCookies.count,
"cookieNames": domainCookies.map { $0.name }.sorted().joined(separator: ","),
])
guard navResult else {
DiagnosticLogger.shared.log("WVF", "navFailed", ["pageURL": pageURL])
return (nil, "Failed to load page")
}
let js = """
return await new Promise((resolve, reject) => {
return await new Promise((resolve) => {
const xhr = new XMLHttpRequest();
xhr.open("\(method)", "\(fetchURL)", true);
xhr.withCredentials = \(includeCredentials ? "true" : "false");
@@ -77,73 +78,48 @@ final class WebViewFetcher {
});
"""
print("[WebViewFetcher] Executing fetch to \(fetchURL)")
let result: (data: String?, error: String?)
let evalResult = await runJavaScript(
navigateTo: pageURL,
userAgent: userAgent,
waitBeforeExecutingMs: 2000,
script: js
)
guard let jsValue = evalResult.value else {
return (nil, evalResult.error ?? "JavaScript execution failed")
}
guard let resultStr = jsValue as? String else {
print("[WebViewFetcher] Unexpected result type: \(type(of: jsValue))")
return (nil, "No string result from JS")
}
if let data = resultStr.data(using: .utf8),
let wrapper = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
do {
let result = try await webView.callAsyncJavaScript(js, contentWorld: .page)
guard let resultStr = result as? String,
let data = resultStr.data(using: .utf8),
let wrapper = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else {
DiagnosticLogger.shared.log("WVF", "fetchNoResult", [:])
return (nil, "No string result from JS")
}
let status = wrapper["status"] as? Int ?? -1
let body = wrapper["body"] as? String ?? ""
print("[WebViewFetcher] Response status: \(status), body length: \(body.count)")
let respBody = wrapper["body"] as? String ?? ""
DiagnosticLogger.shared.log("WVF", "fetchDone", [
"fetchURL": fetchURL,
"status": status,
"bodyPreview": String(respBody.prefix(220)),
])
if status == 200 {
result = (body, nil)
return (respBody, nil)
} else {
result = (nil, "HTTP \(status): \(String(body.prefix(200)))")
}
} else {
result = (resultStr, nil)
}
return result
}
private func currentCookieNames(for webView: WKWebView) async -> [String] {
await withCheckedContinuation { continuation in
webView.configuration.websiteDataStore.httpCookieStore.getAllCookies { cookies in
continuation.resume(returning: cookies.map(\.name))
return (nil, "HTTP \(status): \(String(respBody.prefix(200)))")
}
} catch {
DiagnosticLogger.shared.log("WVF", "fetchThrew", [
"error": error.localizedDescription,
])
return (nil, error.localizedDescription)
}
}
}
// MARK: - Navigation Delegate
private class NavigationDelegate: NSObject, WKNavigationDelegate {
private var continuation: CheckedContinuation<Bool, Never>?
init(continuation: CheckedContinuation<Bool, Never>) {
self.continuation = continuation
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
continuation?.resume(returning: true)
continuation = nil
continuation?.resume(returning: true); continuation = nil
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
print("[WebViewFetcher] Navigation failed: \(error)")
continuation?.resume(returning: false)
continuation = nil
continuation?.resume(returning: false); continuation = nil
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
print("[WebViewFetcher] Provisional navigation failed: \(error)")
continuation?.resume(returning: false)
continuation = nil
continuation?.resume(returning: false); continuation = nil
}
}
+186
View File
@@ -0,0 +1,186 @@
import SwiftUI
/// Shared add-flight form. Used by:
/// - The "+" toolbar on the History tab (no prefill full manual entry)
/// - The "Add to my flights" button on a live aircraft sheet (prefilled
/// from FR24 enrichment)
/// - Calendar import (prefilled from a calendar event regex match)
/// - Mail Share Extension (prefilled from a parsed email)
///
/// The user can always edit any field. The "Look up" action hits
/// route-explorer's schedule endpoint to fill departure/arrival/times
/// given a carrier + flight # + date.
struct AddFlightView: View {
let routeExplorer: RouteExplorerClient
let database: AirportDatabase
let store: FlightHistoryStore
let prefill: Prefill?
@Environment(\.dismiss) private var dismiss
@State private var flightDate: Date = Date()
@State private var carrierIATA: String = ""
@State private var flightNumber: String = ""
@State private var departureIATA: String = ""
@State private var arrivalIATA: String = ""
@State private var scheduledDeparture: Date?
@State private var scheduledArrival: Date?
@State private var aircraftType: String = ""
@State private var registration: String = ""
@State private var icao24: String = ""
@State private var notes: String = ""
@State private var isLooking = false
@State private var lookupError: String?
struct Prefill {
var flightDate: Date
var carrierICAO: String?
var carrierIATA: String?
var flightNumber: String?
var departureIATA: String?
var arrivalIATA: String?
var scheduledDeparture: Date?
var scheduledArrival: Date?
var aircraftType: String?
var registration: String?
var icao24: String?
var source: String
}
var body: some View {
NavigationStack {
Form {
Section("Flight") {
DatePicker("Date", selection: $flightDate, displayedComponents: .date)
HStack {
TextField("Airline (e.g. WN)", text: $carrierIATA)
.autocorrectionDisabled()
.textInputAutocapitalization(.characters)
.frame(width: 100)
TextField("Flight #", text: $flightNumber)
.keyboardType(.numberPad)
Button(action: { Task { await runLookup() } }) {
if isLooking { ProgressView() }
else { Image(systemName: "magnifyingglass") }
}
.disabled(carrierIATA.isEmpty || flightNumber.isEmpty || isLooking)
}
if let lookupError {
Text(lookupError)
.font(.caption)
.foregroundStyle(.red)
}
}
Section("Route") {
TextField("From (IATA)", text: $departureIATA)
.autocorrectionDisabled()
.textInputAutocapitalization(.characters)
TextField("To (IATA)", text: $arrivalIATA)
.autocorrectionDisabled()
.textInputAutocapitalization(.characters)
if let dep = Binding($scheduledDeparture) {
DatePicker("Departure", selection: dep)
} else {
Button("Add scheduled departure") { scheduledDeparture = flightDate }
}
if let arr = Binding($scheduledArrival) {
DatePicker("Arrival", selection: arr)
} else {
Button("Add scheduled arrival") { scheduledArrival = flightDate }
}
}
Section("Aircraft") {
TextField("Type (e.g. B738)", text: $aircraftType)
.autocorrectionDisabled()
.textInputAutocapitalization(.characters)
TextField("Tail # (e.g. N281WN)", text: $registration)
.autocorrectionDisabled()
.textInputAutocapitalization(.characters)
}
Section("Notes") {
TextField("Optional", text: $notes, axis: .vertical)
.lineLimit(3...8)
}
}
.navigationTitle("Add flight")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") { save() }
.disabled(!isValid)
}
}
.onAppear { applyPrefill() }
}
}
private var isValid: Bool {
!departureIATA.isEmpty && !arrivalIATA.isEmpty
&& departureIATA.count >= 3 && arrivalIATA.count >= 3
}
private func applyPrefill() {
guard let p = prefill else { return }
flightDate = p.flightDate
carrierIATA = p.carrierIATA ?? ""
flightNumber = p.flightNumber ?? ""
departureIATA = (p.departureIATA ?? "").uppercased()
arrivalIATA = (p.arrivalIATA ?? "").uppercased()
scheduledDeparture = p.scheduledDeparture
scheduledArrival = p.scheduledArrival
aircraftType = (p.aircraftType ?? "").uppercased()
registration = (p.registration ?? "").uppercased()
icao24 = (p.icao24 ?? "").lowercased()
}
private func runLookup() async {
isLooking = true
defer { isLooking = false }
lookupError = nil
guard let num = Int(flightNumber.trimmingCharacters(in: .whitespaces)) else {
lookupError = "Flight number must be numeric"
return
}
let day = Calendar.current.startOfDay(for: flightDate)
let next = Calendar.current.date(byAdding: .day, value: 1, to: day) ?? day
let results = await routeExplorer.searchSchedule(
carrierCode: carrierIATA.uppercased(),
flightNumber: num,
startDate: day,
endDate: next
)
guard let r = results.first else {
lookupError = "No schedule match for \(carrierIATA)\(flightNumber) on this date"
return
}
departureIATA = r.departure.airportIata
arrivalIATA = r.arrival.airportIata
scheduledDeparture = r.departure.dateTime
scheduledArrival = r.arrival.dateTime
}
private func save() {
let carrierICAO = AircraftRegistry.shared.lookup(iata: carrierIATA)?.icao
let f = LoggedFlight(
flightDate: flightDate,
carrierICAO: carrierICAO,
carrierIATA: carrierIATA.isEmpty ? nil : carrierIATA.uppercased(),
flightNumber: flightNumber.isEmpty ? nil : flightNumber,
departureIATA: departureIATA.uppercased(),
arrivalIATA: arrivalIATA.uppercased(),
scheduledDeparture: scheduledDeparture,
scheduledArrival: scheduledArrival,
aircraftType: aircraftType.isEmpty ? nil : aircraftType.uppercased(),
registration: registration.isEmpty ? nil : registration.uppercased(),
icao24: icao24.isEmpty ? nil : icao24.lowercased(),
notes: notes.isEmpty ? nil : notes,
source: prefill?.source ?? "manual"
)
store.save(f)
dismiss()
}
}
+405
View File
@@ -0,0 +1,405 @@
import SwiftUI
/// Aircraft Stats screen Total / Newest / Oldest header row, then a
/// ranked list of types you've flown with real airframe photos as row
/// backgrounds, plus a "Most Flown Tail" hero card at the bottom.
struct AircraftStatsView: View {
let allFlights: [LoggedFlight]
let store: FlightHistoryStore
let routeExplorer: RouteExplorerClient
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var scheme
@State private var showingEnrich = false
var body: some View {
ScrollView {
VStack(spacing: 16) {
header
if hasAnyAircraftData {
topStatsRow
typeListSection
mostFlownTailSection
} else {
emptyState
}
Spacer(minLength: 60)
}
.padding(.horizontal, 16)
.padding(.vertical, 4)
}
.background(HistoryStyle.background(scheme).ignoresSafeArea())
.navigationTitle("")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button { dismiss() } label: { Image(systemName: "xmark") }
}
ToolbarItem(placement: .primaryAction) {
Button {
showingEnrich = true
} label: {
Image(systemName: "wand.and.stars")
}
}
}
.sheet(isPresented: $showingEnrich) {
EnrichAircraftTypesView(store: store, routeExplorer: routeExplorer)
}
}
/// True when at least one logged flight has an aircraft type OR
/// a registration we could surface.
private var hasAnyAircraftData: Bool {
!uniqueTypeCodes.isEmpty || mostFlownTail != nil
}
private var emptyState: some View {
VStack(spacing: 16) {
Image(systemName: "airplane.circle")
.font(.system(size: 56, weight: .heavy))
.foregroundStyle(HistoryStyle.runwayOrange)
.padding(.top, 32)
Text("No aircraft data yet")
.font(.system(size: 20, weight: .heavy))
.foregroundStyle(HistoryStyle.ink(scheme))
Text("Most flights in your log were imported from CSV — that export doesn't include the aircraft type. Look it up via the scheduled equipment, or fill in manually.")
.multilineTextAlignment(.center)
.foregroundStyle(HistoryStyle.inkSecondary(scheme))
.font(.system(size: 14))
.padding(.horizontal, 24)
Button {
showingEnrich = true
} label: {
HStack(spacing: 8) {
Image(systemName: "wand.and.stars")
Text("Look up missing types")
.font(.system(size: 15, weight: .heavy))
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(HistoryStyle.runwayOrange, in: Capsule())
.foregroundStyle(.white)
}
.padding(.top, 8)
VStack(alignment: .leading, spacing: 8) {
Text("OTHER WAYS")
.font(.system(size: 11, weight: .heavy))
.tracking(1.3)
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
.padding(.top, 24)
bullet("Tap an aircraft on the Live tab and \"Add to my flights\"")
bullet("Open any flight and edit the Tail # / Type by hand")
}
.font(.system(size: 13))
.padding(.horizontal, 28)
}
}
private func bullet(_ text: String) -> some View {
HStack(alignment: .top, spacing: 10) {
Circle()
.fill(HistoryStyle.runwayOrange)
.frame(width: 6, height: 6)
.padding(.top, 6)
Text(text)
.foregroundStyle(HistoryStyle.inkSecondary(scheme))
}
}
private var header: some View {
VStack(spacing: 4) {
Text("AIRCRAFT")
.font(.system(size: 40, weight: .black))
.tracking(-0.5)
.foregroundStyle(HistoryStyle.ink(scheme))
Rectangle()
.fill(HistoryStyle.runwayOrange)
.frame(width: 38, height: 3)
}
.frame(maxWidth: .infinity)
.padding(.top, 8)
.padding(.bottom, 8)
}
// MARK: - Top stats row (3 column tile bar)
private var topStatsRow: some View {
HStack(spacing: 10) {
statTile(label: "Total", value: "\(uniqueTypeCodes.count)", subtitle: "types flown")
statTile(
label: "Newest",
value: newestAirframeAgeLabel(),
subtitle: newestAirframeYearLabel()
)
statTile(
label: "Oldest",
value: oldestAirframeAgeLabel(),
subtitle: oldestAirframeYearLabel()
)
}
}
private func statTile(label: String, value: String, subtitle: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(label.uppercased())
.font(HistoryStyle.label(10))
.tracking(1.3)
.foregroundStyle(HistoryStyle.inkSecondary(scheme))
Text(value)
.font(HistoryStyle.displayNumber(22))
.foregroundStyle(HistoryStyle.ink(scheme))
.lineLimit(1)
.minimumScaleFactor(0.6)
Text(subtitle)
.font(.system(size: 11))
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(14)
.background(HistoryStyle.card(scheme), in: RoundedRectangle(cornerRadius: 16))
}
// MARK: - Type list
private var typeListSection: some View {
VStack(alignment: .leading, spacing: 12) {
HistorySectionLabel("By type")
VStack(spacing: 10) {
ForEach(rankedTypes, id: \.code) { item in
AircraftTypeCard(type: item)
}
}
}
.padding(.top, 8)
}
struct TypeRanked {
let code: String
let displayName: String
let count: Int
let sampleRegistration: String?
}
private var rankedTypes: [TypeRanked] {
let byType = Dictionary(grouping: allFlights.filter { $0.aircraftType != nil }) { $0.aircraftType! }
return byType.map { code, list in
TypeRanked(
code: code,
displayName: AircraftDatabase.shared.displayName(forTypeCode: code),
count: list.count,
sampleRegistration: list.compactMap { $0.registration }.first
)
}
.sorted { $0.count > $1.count }
}
// MARK: - Most-flown tail hero
@ViewBuilder
private var mostFlownTailSection: some View {
if let top = mostFlownTail {
VStack(alignment: .leading, spacing: 12) {
HistorySectionLabel("Most flown airframe")
MostFlownTailCard(reg: top.reg, count: top.count, sample: top.sampleFlight)
}
.padding(.top, 8)
}
}
private struct MostFlownTail {
let reg: String
let count: Int
let sampleFlight: LoggedFlight
}
private var mostFlownTail: MostFlownTail? {
let byReg = Dictionary(grouping: allFlights.filter { $0.registration != nil }) { $0.registration! }
guard let top = byReg.max(by: { $0.value.count < $1.value.count }),
let sample = top.value.first
else { return nil }
return MostFlownTail(reg: top.key, count: top.value.count, sampleFlight: sample)
}
// MARK: - Computed metadata helpers
private var uniqueTypeCodes: Set<String> {
Set(allFlights.compactMap { $0.aircraftType })
}
/// Look up airframes with metadata and find the youngest one we've
/// flown on. Falls back to a "" if no airframe has firstFlight
/// data cached yet.
private func newestAirframeAgeLabel() -> String {
guard let m = airframeWith(.newest), let date = m.firstFlightDate else { return "" }
let years = Calendar.current.dateComponents([.year], from: date, to: Date()).year ?? 0
return "\(years)y"
}
private func newestAirframeYearLabel() -> String {
guard let m = airframeWith(.newest), let date = m.firstFlightDate else { return "" }
let f = DateFormatter(); f.dateFormat = "yyyy"
return f.string(from: date)
}
private func oldestAirframeAgeLabel() -> String {
guard let m = airframeWith(.oldest), let date = m.firstFlightDate else { return "" }
let years = Calendar.current.dateComponents([.year], from: date, to: Date()).year ?? 0
return "\(years)y"
}
private func oldestAirframeYearLabel() -> String {
guard let m = airframeWith(.oldest), let date = m.firstFlightDate else { return "" }
let f = DateFormatter(); f.dateFormat = "yyyy"
return f.string(from: date)
}
private enum AirframePick { case newest, oldest }
private func airframeWith(_ pick: AirframePick) -> AirframeMetadata? {
let regs = Set(allFlights.compactMap { $0.registration })
let metas = regs.compactMap { store.airframe(for: $0) }
.filter { $0.firstFlightDate != nil }
switch pick {
case .newest: return metas.max(by: { ($0.firstFlightDate ?? .distantPast) < ($1.firstFlightDate ?? .distantPast) })
case .oldest: return metas.min(by: { ($0.firstFlightDate ?? .distantPast) < ($1.firstFlightDate ?? .distantPast) })
}
}
}
// MARK: - Aircraft type card with photo background
struct AircraftTypeCard: View {
let type: AircraftStatsView.TypeRanked
@State private var photo: AircraftPhotoService.Photo?
@Environment(\.colorScheme) private var scheme
var body: some View {
ZStack(alignment: .bottomLeading) {
background
.frame(height: 120)
LinearGradient(
colors: [Color.black.opacity(0.0), Color.black.opacity(0.85)],
startPoint: .top, endPoint: .bottom
)
.frame(height: 120)
VStack(alignment: .leading, spacing: 4) {
Text(type.displayName.uppercased())
.font(.system(size: 13, weight: .heavy))
.tracking(1.5)
.foregroundStyle(.white)
Text(type.code)
.font(.system(size: 26, weight: .black).monospaced())
.foregroundStyle(.white)
}
.padding(14)
VStack(alignment: .trailing, spacing: 2) {
Text("\(type.count)")
.font(HistoryStyle.displayNumber(28))
.foregroundStyle(HistoryStyle.runwayOrange)
Text("FLIGHTS")
.font(.system(size: 9, weight: .bold))
.tracking(1.3)
.foregroundStyle(.white.opacity(0.7))
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .trailing)
}
.frame(height: 120)
.clipShape(RoundedRectangle(cornerRadius: 16))
.task(id: type.sampleRegistration ?? type.code) {
guard let reg = type.sampleRegistration else { return }
photo = await AircraftPhotoService.shared.photo(registration: reg, icao24: "")
}
}
@ViewBuilder
private var background: some View {
if let url = photo?.largeURL {
AsyncImage(url: url) { phase in
switch phase {
case .success(let img): img.resizable().aspectRatio(contentMode: .fill)
default: HistoryStyle.cardSubtle(scheme)
}
}
} else {
ZStack {
HistoryStyle.cardSubtle(scheme)
Image(systemName: "airplane")
.font(.system(size: 60, weight: .heavy))
.foregroundStyle(HistoryStyle.runwayOrange.opacity(0.25))
.rotationEffect(.degrees(-15))
}
}
}
}
// MARK: - Most flown tail hero
struct MostFlownTailCard: View {
let reg: String
let count: Int
let sample: LoggedFlight
@State private var photo: AircraftPhotoService.Photo?
@Environment(\.colorScheme) private var scheme
var body: some View {
ZStack(alignment: .bottomLeading) {
background.frame(height: 240)
LinearGradient(
colors: [Color.black.opacity(0.0), Color.black.opacity(0.85)],
startPoint: .center, endPoint: .bottom
)
.frame(height: 240)
VStack(alignment: .leading, spacing: 6) {
Text("REPEAT OFFENDER")
.font(.system(size: 10, weight: .heavy))
.tracking(1.8)
.foregroundStyle(HistoryStyle.runwayOrange)
Text(reg)
.font(.system(size: 42, weight: .black).monospaced())
.foregroundStyle(.white)
HStack(spacing: 16) {
if let type = sample.aircraftType {
kvp(value: type, label: "Type")
}
kvp(value: "\(count)×", label: "Flown")
}
}
.padding(18)
}
.frame(height: 240)
.clipShape(RoundedRectangle(cornerRadius: 20))
.task(id: reg) {
photo = await AircraftPhotoService.shared.photo(registration: reg, icao24: "")
}
}
@ViewBuilder
private var background: some View {
if let url = photo?.largeURL {
AsyncImage(url: url) { phase in
switch phase {
case .success(let img): img.resizable().aspectRatio(contentMode: .fill)
default: HistoryStyle.heroNavyGradient
}
}
} else {
HistoryStyle.heroNavyGradient
}
}
private func kvp(value: String, label: String) -> some View {
VStack(alignment: .leading, spacing: 0) {
Text(value)
.font(.system(size: 14, weight: .heavy).monospaced())
.foregroundStyle(.white)
Text(label.uppercased())
.font(.system(size: 9, weight: .bold))
.tracking(0.8)
.foregroundStyle(.white.opacity(0.7))
}
}
}
+132
View File
@@ -0,0 +1,132 @@
import SwiftUI
/// Drilldown showing every flight you've flown through one airport
/// (as departure OR arrival), plus a "Top destinations from here"
/// rollup. Available from the lifetime route map (tap an airport
/// dot).
struct AirportFlightsView: View {
let iata: String
let allFlights: [LoggedFlight]
let database: AirportDatabase
let store: FlightHistoryStore
let openSky: OpenSkyClient
@Binding var filters: HistoryFilters
@Environment(\.dismiss) private var dismiss
var body: some View {
let through = allFlights
.filter { $0.departureIATA == iata || $0.arrivalIATA == iata }
.sorted { $0.flightDate > $1.flightDate }
return List {
Section {
summary(for: through)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
}
if !destinations(from: through).isEmpty {
Section("Top destinations") {
ForEach(destinations(from: through), id: \.iata) { d in
HStack {
Text(d.iata)
.font(.subheadline.weight(.semibold).monospaced())
if let m = database.airport(byIATA: d.iata) {
Text(m.name)
.font(.caption)
.foregroundStyle(FlightTheme.textSecondary)
.lineLimit(1)
}
Spacer()
Text("\(d.count)×")
.font(.subheadline.weight(.bold).monospacedDigit())
.foregroundStyle(FlightTheme.accent)
}
}
}
}
Section("All flights") {
ForEach(through) { flight in
NavigationLink {
HistoryDetailView(
flight: flight,
store: store,
database: database,
openSky: openSky
)
} label: {
HistoryRowView(flight: flight, database: database)
}
}
}
}
.navigationTitle(iata)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
filters.airports = [iata]
dismiss()
} label: {
Label("Filter list", systemImage: "line.3.horizontal.decrease.circle")
}
}
}
}
private func summary(for through: [LoggedFlight]) -> some View {
VStack(alignment: .leading, spacing: 8) {
if let airport = database.airport(byIATA: iata) {
Text(airport.name)
.font(.subheadline.weight(.semibold))
.foregroundStyle(FlightTheme.textPrimary)
if !airport.country.isEmpty {
Text(airport.country)
.font(.caption)
.foregroundStyle(FlightTheme.textSecondary)
}
}
HStack(spacing: 12) {
tile(label: "Total", value: "\(through.count)")
tile(label: "Departed", value: "\(through.filter { $0.departureIATA == iata }.count)")
tile(label: "Arrived", value: "\(through.filter { $0.arrivalIATA == iata }.count)")
}
.padding(.top, 6)
}
.padding(16)
}
private func tile(label: String, value: String) -> some View {
VStack(spacing: 2) {
Text(value)
.font(.title3.weight(.bold).monospacedDigit())
.foregroundStyle(FlightTheme.textPrimary)
Text(label.uppercased())
.font(.caption2.weight(.semibold))
.tracking(0.6)
.foregroundStyle(FlightTheme.textTertiary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(FlightTheme.cardBackground, in: RoundedRectangle(cornerRadius: 10))
}
private struct DestCount {
let iata: String
let count: Int
}
/// "Where did I go FROM this airport?" counts of the OTHER
/// endpoint for each flight through here, with this airport
/// itself filtered out.
private func destinations(from through: [LoggedFlight]) -> [DestCount] {
let others = through.map { f -> String in
f.departureIATA == iata ? f.arrivalIATA : f.departureIATA
}.filter { $0 != iata && !$0.isEmpty }
return Dictionary(grouping: others) { $0 }
.map { DestCount(iata: $0.key, count: $0.value.count) }
.sorted { $0.count > $1.count }
.prefix(10)
.map { $0 }
}
}
+199
View File
@@ -0,0 +1,199 @@
import SwiftUI
import EventKit
/// Scan-the-calendar import flow. Shows discovered flight-shaped events
/// as a checkable list; user toggles which to import, taps Import All,
/// and we route-explorer-autofill them in the background. Dedupes
/// against existing logs.
struct CalendarImportView: View {
let routeExplorer: RouteExplorerClient
let database: AirportDatabase
let store: FlightHistoryStore
@Environment(\.dismiss) private var dismiss
@State private var phase: Phase = .askingPermission
@State private var candidates: [CalendarFlightImporter.Candidate] = []
@State private var selected: Set<UUID> = []
@State private var importing = false
@State private var importedCount = 0
enum Phase {
case askingPermission
case denied
case scanning
case ready
case importing
case done
}
private let importer = CalendarFlightImporter()
var body: some View {
NavigationStack {
content
.navigationTitle("Scan calendar")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") { dismiss() }
}
if phase == .ready && !selected.isEmpty {
ToolbarItem(placement: .primaryAction) {
Button("Import \(selected.count)") {
Task { await importSelected() }
}
}
}
}
.task(id: phase) {
if phase == .askingPermission {
let ok = await importer.requestAccess()
phase = ok ? .scanning : .denied
} else if phase == .scanning {
let cands = importer.scan()
// Pre-dedupe against existing log
let novel = cands.filter { c in
!store.exists(
flightDate: c.flightDate,
flightLabel: c.flightLabel,
departureIATA: c.departureIATA ?? "",
arrivalIATA: c.arrivalIATA ?? ""
)
}
candidates = novel.sorted { $0.flightDate > $1.flightDate }
selected = Set(candidates.map { $0.id })
phase = .ready
}
}
}
}
@ViewBuilder
private var content: some View {
switch phase {
case .askingPermission, .scanning:
VStack(spacing: 12) {
ProgressView()
Text(phase == .askingPermission ? "Requesting access…" : "Scanning your calendar…")
.font(.subheadline)
.foregroundStyle(FlightTheme.textSecondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .denied:
ContentUnavailableView(
"Calendar access denied",
systemImage: "calendar.badge.exclamationmark",
description: Text("Enable calendar access in Settings to scan for flight events.")
)
case .ready:
if candidates.isEmpty {
ContentUnavailableView(
"No new flights found",
systemImage: "calendar.badge.checkmark",
description: Text("Your calendar didn't have any flight-shaped events that aren't already in your log.")
)
} else {
List(candidates) { c in
Button {
toggle(c.id)
} label: {
HStack {
Image(systemName: selected.contains(c.id) ? "checkmark.circle.fill" : "circle")
.foregroundStyle(selected.contains(c.id) ? FlightTheme.accent : FlightTheme.textTertiary)
VStack(alignment: .leading) {
Text(c.flightLabel)
.font(.subheadline.weight(.bold).monospaced())
if let from = c.departureIATA, let to = c.arrivalIATA {
Text("\(from)\(to)")
.font(.caption.monospaced())
.foregroundStyle(FlightTheme.textSecondary)
} else {
Text("Route TBD via lookup")
.font(.caption)
.foregroundStyle(FlightTheme.textTertiary)
}
}
Spacer()
Text(shortDate(c.flightDate))
.font(.caption.monospaced())
.foregroundStyle(FlightTheme.textTertiary)
}
}
.buttonStyle(.plain)
}
}
case .importing:
VStack(spacing: 12) {
ProgressView()
Text("Importing \(importedCount) / \(selected.count)")
.font(.subheadline)
.foregroundStyle(FlightTheme.textSecondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .done:
ContentUnavailableView(
"Imported \(importedCount) flights",
systemImage: "checkmark.circle.fill",
description: Text("Your log is up to date.")
)
}
}
private func toggle(_ id: UUID) {
if selected.contains(id) { selected.remove(id) } else { selected.insert(id) }
}
private func importSelected() async {
phase = .importing
importedCount = 0
for c in candidates where selected.contains(c.id) {
// Route-explorer enrichment when carrier + flight # are known.
var depIATA = c.departureIATA ?? ""
var arrIATA = c.arrivalIATA ?? ""
var sched: (dep: Date?, arr: Date?) = (nil, nil)
if let carrier = c.carrierIATA, let num = c.flightNumber.flatMap(Int.init) {
let day = Calendar.current.startOfDay(for: c.flightDate)
let next = Calendar.current.date(byAdding: .day, value: 1, to: day) ?? day
let results = await routeExplorer.searchSchedule(
carrierCode: carrier,
flightNumber: num,
startDate: day,
endDate: next
)
if let r = results.first {
if depIATA.isEmpty { depIATA = r.departure.airportIata }
if arrIATA.isEmpty { arrIATA = r.arrival.airportIata }
sched = (r.departure.dateTime, r.arrival.dateTime)
}
}
let icao = c.carrierIATA.flatMap { AircraftRegistry.shared.lookup(iata: $0)?.icao }
let flight = LoggedFlight(
flightDate: c.flightDate,
carrierICAO: icao,
carrierIATA: c.carrierIATA,
flightNumber: c.flightNumber,
departureIATA: depIATA,
arrivalIATA: arrIATA,
scheduledDeparture: sched.dep,
scheduledArrival: sched.arr,
source: "calendar"
)
store.save(flight)
importedCount += 1
}
phase = .done
}
private func shortDate(_ d: Date) -> String {
let f = DateFormatter()
f.dateFormat = "MMM d, yyyy"
return f.string(from: d)
}
}
+139 -12
View File
@@ -7,8 +7,13 @@ import SwiftUI
struct ConnectionRow: View {
let connection: RouteConnection
let appendix: RouteAppendix?
let database: AirportDatabase
let onLegTap: (RouteFlight) -> Void
// MARK: - Annotation state (first-leg badges)
@State private var loadEstimate: LoadFactorEstimate?
@State private var onTimeStat: OnTimeStat?
var body: some View {
VStack(alignment: .leading, spacing: 12) {
// MARK: - Summary header
@@ -25,20 +30,76 @@ struct ConnectionRow: View {
Button {
onLegTap(leg)
} label: {
LegSummary(leg: leg, appendix: appendix)
LegSummary(leg: leg, appendix: appendix, database: database)
}
.buttonStyle(.plain)
}
}
}
.flightCard()
.task(id: firstLegKey) {
await loadAnnotations()
}
}
// MARK: - Annotation fetch
/// Stable identity for the first leg so SwiftUI re-runs the task only
/// when the underlying flight key actually changes.
private var firstLegKey: String {
guard let first = connection.flights.first else { return "none" }
return "\(first.carrierIata)\(first.flightNumber)-\(first.departure.airportIata)-\(first.arrival.airportIata)"
}
private func loadAnnotations() async {
guard let first = connection.flights.first else { return }
let carrier = first.carrierIata
let flightNumber = first.flightNumber
let origin = first.departure.airportIata
let dest = first.arrival.airportIata
let date = first.departure.dateTime
// Sequential with cancellation checks between fetches. If the
// user scrolls fast or the route list re-queries, the
// `.task(id:)` re-fires and we abandon the prior load instead
// of writing stale numbers into the row.
do {
let load = await LoadFactorService.shared.estimate(
carrier: carrier,
flightNumber: flightNumber,
origin: origin,
dest: dest,
date: date,
database: database,
liveSeats: nil
)
try Task.checkCancellation()
self.loadEstimate = load
let ot = await OnTimePerformanceService.shared.stat(
carrier: carrier,
flightNumber: flightNumber,
origin: origin,
dest: dest
)
try Task.checkCancellation()
self.onTimeStat = ot
print("[ConnectionRow] \(carrier)\(flightNumber) \(origin)->\(dest) " +
"load=\(load.map { Int(round($0.predicted * 100)) }.map(String.init) ?? "nil")% " +
"ot=\(ot.map { Int(round($0.onTimePct * 100)) }.map(String.init) ?? "nil")%")
} catch is CancellationError {
print("[ConnectionRow] cancelled \(carrier)\(flightNumber) \(origin)->\(dest)")
} catch {
print("[ConnectionRow] error \(carrier)\(flightNumber): \(error)")
}
}
// MARK: - Summary header
private var summaryHeader: some View {
HStack(alignment: .center) {
VStack(alignment: .leading, spacing: 2) {
VStack(alignment: .leading, spacing: 4) {
Text(stopsLabel)
.font(.caption.weight(.semibold))
.foregroundStyle(FlightTheme.accent)
@@ -46,15 +107,24 @@ struct ConnectionRow: View {
.padding(.vertical, 3)
.background(FlightTheme.accent.opacity(0.12), in: Capsule())
Text(carriersLabel)
.font(.caption)
.foregroundStyle(FlightTheme.textSecondary)
.lineLimit(1)
HStack(spacing: 6) {
Text(carriersLabel)
.font(.caption)
.foregroundStyle(FlightTheme.textSecondary)
.lineLimit(1)
if let ot = onTimeStat {
onTimePill(ot)
}
}
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
VStack(alignment: .trailing, spacing: 4) {
if let load = loadEstimate {
loadBadge(load)
}
Text(formatDuration(connection.durationMinutes))
.font(.subheadline.weight(.bold))
.foregroundStyle(FlightTheme.textPrimary)
@@ -65,6 +135,40 @@ struct ConnectionRow: View {
}
}
// MARK: - Badge views
private func loadBadge(_ estimate: LoadFactorEstimate) -> some View {
let pct = Int(round(estimate.predicted * 100))
let color = loadColor(for: estimate.predicted)
return Text("\(pct)%")
.font(.caption.weight(.bold).monospacedDigit())
.foregroundStyle(color)
.padding(.horizontal, 7)
.padding(.vertical, 2)
.background(color.opacity(0.16), in: Capsule())
.overlay(Capsule().strokeBorder(color.opacity(0.45), lineWidth: 0.5))
.accessibilityLabel("Predicted load \(pct) percent")
}
private func onTimePill(_ stat: OnTimeStat) -> some View {
let pct = Int(round(stat.onTimePct * 100))
return Text("OT \(pct)%")
.font(.caption2.weight(.semibold).monospacedDigit())
.foregroundStyle(FlightTheme.textSecondary)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(FlightTheme.textSecondary.opacity(0.12), in: Capsule())
.accessibilityLabel("On-time \(pct) percent")
}
/// Load-factor colour ramp. Lower load = better for nonrev = green.
private func loadColor(for predicted: Double) -> Color {
let pct = predicted * 100.0
if pct < 70 { return .green }
if pct <= 85 { return .yellow }
return .red
}
private var stopsLabel: String {
switch connection.stopCount {
case 0: return "Direct"
@@ -118,6 +222,7 @@ struct ConnectionRow: View {
private struct LegSummary: View {
let leg: RouteFlight
let appendix: RouteAppendix?
let database: AirportDatabase
private static let timeFormatter: DateFormatter = {
let f = DateFormatter()
@@ -126,40 +231,54 @@ private struct LegSummary: View {
}()
var body: some View {
HStack(alignment: .center, spacing: 10) {
// Airline + flight number
HStack(alignment: .top, spacing: 10) {
// Airline + flight number (fixed-width left column)
VStack(alignment: .leading, spacing: 2) {
Text(leg.carrierIata)
.font(.caption.weight(.bold))
.foregroundStyle(FlightTheme.textPrimary)
Text("\(leg.flightNumber)")
// verbatim: prevents SwiftUI from rendering Int as "3,189".
Text(verbatim: "\(leg.flightNumber)")
.font(FlightTheme.flightNumber(11))
.foregroundStyle(FlightTheme.textSecondary)
}
.frame(width: 44, alignment: .leading)
// Times + airports
// Times + airports + names + aircraft
VStack(alignment: .leading, spacing: 4) {
// Row A times and IATAs (compact)
HStack(spacing: 8) {
timeAirport(leg.departure)
Image(systemName: "arrow.right")
.font(.caption2)
.foregroundStyle(FlightTheme.textTertiary)
timeAirport(leg.arrival)
Spacer(minLength: 0)
}
// Row B full airport names, single line, middle-truncated
Text("\(airportName(for: leg.departure.airportIata))\(airportName(for: leg.arrival.airportIata))")
.font(.caption2)
.foregroundStyle(FlightTheme.textSecondary)
.lineLimit(1)
.truncationMode(.middle)
// Row C aircraft (if known), single line, tail-truncated
if let aircraft = aircraftLabel {
Text(aircraft)
.font(.caption2)
.foregroundStyle(FlightTheme.textTertiary)
.lineLimit(1)
.truncationMode(.tail)
}
}
Spacer()
Spacer(minLength: 4)
Image(systemName: "chevron.right")
.font(.caption2)
.foregroundStyle(FlightTheme.textTertiary)
.padding(.top, 4)
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
@@ -183,4 +302,12 @@ private struct LegSummary: View {
guard let iata = leg.equipmentIata else { return nil }
return appendix?.equipment(iata: iata)?.name ?? iata
}
/// Bundled DB first (clean city names), then route-explorer appendix.
private func airportName(for iata: String) -> String {
if let m = database.airport(byIATA: iata) { return m.name }
if let n = appendix?.airport(iata: iata)?.cityName, !n.isEmpty { return n }
if let n = appendix?.airport(iata: iata)?.name, !n.isEmpty { return n }
return iata
}
}
@@ -0,0 +1,470 @@
import SwiftUI
/// Presents load data for ALL legs of a multi-stop connection at once.
///
/// Each leg's `AirlineLoadService.fetchLoad(...)` runs in parallel inside a
/// TaskGroup so the slowest carrier doesn't block the others the user sees
/// the fastest leg's open/standby summary as soon as it lands. Per-leg
/// "Full details" buttons drill into the existing `FlightLoadDetailView`
/// for the upgrade/standby passenger lists.
struct ConnectionLoadDetailView: View {
let connection: RouteConnection
let appendix: RouteAppendix?
let database: AirportDatabase
let loadService: AirlineLoadService
@Environment(\.dismiss) private var dismiss
@State private var legStates: [LegLoadState]
@State private var drillDown: RouteLoadDetailRequest?
init(
connection: RouteConnection,
appendix: RouteAppendix?,
database: AirportDatabase,
loadService: AirlineLoadService
) {
self.connection = connection
self.appendix = appendix
self.database = database
self.loadService = loadService
self._legStates = State(initialValue: connection.flights.map { LegLoadState(leg: $0) })
}
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: FlightTheme.sectionSpacing) {
// Multi-leg only: stops + carriers + total duration. For
// a single-leg presentation (direct or Where-Can-I-Go),
// the leg card itself carries all the same info.
if connection.flights.count > 1 {
headerCard
}
ForEach(Array(legStates.enumerated()), id: \.element.id) { index, state in
if index > 0, let mins = layoverMinutes(at: index) {
layoverRow(minutes: mins, at: state.leg.departure.airportIata)
}
legCard(for: state)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.background(FlightTheme.background.ignoresSafeArea())
.navigationTitle(navTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button {
dismiss()
} label: {
Image(systemName: "xmark")
.font(.subheadline.weight(.semibold))
.foregroundStyle(FlightTheme.textSecondary)
}
}
}
.task {
await fetchAllLegs()
}
.sheet(item: $drillDown) { req in
FlightLoadDetailView(
schedule: req.schedule,
departureCode: req.departureCode,
arrivalCode: req.arrivalCode,
date: req.date,
loadService: loadService
)
}
}
}
// MARK: - Header card
private var headerCard: some View {
HStack(alignment: .top, spacing: 10) {
VStack(alignment: .leading, spacing: 4) {
Text(stopsLabel)
.font(.caption.weight(.semibold))
.foregroundStyle(FlightTheme.accent)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(FlightTheme.accent.opacity(0.12), in: Capsule())
Text(carriersLabel)
.font(.caption)
.foregroundStyle(FlightTheme.textSecondary)
.lineLimit(2)
}
Spacer(minLength: 8)
VStack(alignment: .trailing, spacing: 2) {
Text(formatDuration(connection.durationMinutes))
.font(.subheadline.weight(.bold))
.foregroundStyle(FlightTheme.textPrimary)
Text("total")
.font(.caption2)
.foregroundStyle(FlightTheme.textTertiary)
}
.fixedSize(horizontal: true, vertical: false)
}
.flightCard()
}
// MARK: - Per-leg card
private func legCard(for state: LegLoadState) -> some View {
VStack(alignment: .leading, spacing: 12) {
// Flight header
HStack(alignment: .top, spacing: 10) {
VStack(alignment: .leading, spacing: 2) {
Text(verbatim: "\(state.leg.carrierIata) \(state.leg.flightNumber)")
.font(.subheadline.weight(.bold))
.foregroundStyle(FlightTheme.textPrimary)
Text(airlineName(for: state.leg))
.font(.caption)
.foregroundStyle(FlightTheme.textSecondary)
.lineLimit(1)
}
Spacer(minLength: 8)
VStack(alignment: .trailing, spacing: 2) {
Text("\(timeFmt(state.leg.departure.dateTime))\(timeFmt(state.leg.arrival.dateTime))")
.font(.subheadline.weight(.semibold).monospaced())
.foregroundStyle(FlightTheme.textPrimary)
Text(formatDuration(state.leg.durationMinutes))
.font(.caption2)
.foregroundStyle(FlightTheme.textTertiary)
}
.fixedSize(horizontal: true, vertical: false)
}
// IATAs
HStack(spacing: 12) {
Text(state.leg.departure.airportIata)
.font(FlightTheme.airportCode(22))
.foregroundStyle(FlightTheme.textPrimary)
Image(systemName: "airplane")
.font(.footnote)
.foregroundStyle(FlightTheme.textTertiary)
.rotationEffect(.degrees(-45))
Text(state.leg.arrival.airportIata)
.font(FlightTheme.airportCode(22))
.foregroundStyle(FlightTheme.textPrimary)
Spacer(minLength: 0)
if let aircraft = aircraftLabel(for: state.leg) {
Text(aircraft)
.font(FlightTheme.label(11))
.foregroundStyle(FlightTheme.textSecondary)
.lineLimit(1)
.truncationMode(.tail)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color(.quaternarySystemFill), in: Capsule())
}
}
Text("\(airportName(for: state.leg.departure.airportIata))\(airportName(for: state.leg.arrival.airportIata))")
.font(.caption)
.foregroundStyle(FlightTheme.textSecondary)
.lineLimit(1)
.truncationMode(.middle)
Divider()
// Load content (loading / data / unavailable)
loadContent(for: state)
// Drill into full details
Button {
drillDown = RouteLoadDetailRequest(
schedule: state.leg.toFlightSchedule(appendix: appendix, on: state.leg.departure.dateTime),
departureCode: state.leg.departure.airportIata,
arrivalCode: state.leg.arrival.airportIata,
date: state.leg.departure.dateTime
)
} label: {
HStack {
Text("Full details")
.font(.caption.weight(.semibold))
Spacer()
Image(systemName: "chevron.right").font(.caption2)
}
.foregroundStyle(FlightTheme.accent)
}
.buttonStyle(.plain)
}
.flightCard()
}
@ViewBuilder
private func loadContent(for state: LegLoadState) -> some View {
if state.isLoading {
HStack(spacing: 8) {
ProgressView().tint(FlightTheme.accent)
Text("Loading load data…")
.font(.caption)
.foregroundStyle(FlightTheme.textSecondary)
Spacer()
}
.frame(minHeight: 44)
} else if let load = state.load {
loadSummary(load)
} else {
HStack(spacing: 8) {
Image(systemName: "info.circle")
.foregroundStyle(FlightTheme.textTertiary)
Text("Load data isn't available for this flight.")
.font(.caption)
.foregroundStyle(FlightTheme.textSecondary)
Spacer()
}
.frame(minHeight: 44)
}
}
private func loadSummary(_ load: FlightLoad) -> some View {
let openSeats: Int
let standbyCount: Int
if load.hasCabinData {
openSeats = load.totalAvailable
standbyCount = load.totalStandbyFromPBTS
} else {
openSeats = load.seatAvailability.reduce(0) { $0 + $1.available }
standbyCount = load.standbyList.count
}
return VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 0) {
VStack(spacing: 2) {
Text(verbatim: "\(openSeats)")
.font(.system(size: 28, weight: .bold, design: .rounded))
.foregroundStyle(FlightTheme.onTime)
Text("Open")
.font(FlightTheme.label())
.foregroundStyle(FlightTheme.textSecondary)
}
.frame(maxWidth: .infinity)
Divider().frame(height: 36)
VStack(spacing: 2) {
Text(verbatim: "\(standbyCount)")
.font(.system(size: 28, weight: .bold, design: .rounded))
.foregroundStyle(standbyCount > openSeats ? FlightTheme.cancelled : FlightTheme.delayed)
Text("Standby")
.font(FlightTheme.label())
.foregroundStyle(FlightTheme.textSecondary)
}
.frame(maxWidth: .infinity)
}
if !load.cabins.isEmpty {
cabinPills(load.cabins)
} else if !load.seatAvailability.isEmpty {
seatPills(load.seatAvailability)
}
}
}
private func cabinPills(_ cabins: [CabinLoad]) -> some View {
FlowLayoutHStack(spacing: 6) {
ForEach(cabins) { cabin in
pill("\(cabinShort(cabin.name)) \(cabin.available)/\(cabin.capacity)")
}
}
}
private func seatPills(_ items: [SeatAvailability]) -> some View {
FlowLayoutHStack(spacing: 6) {
ForEach(items) { item in
pill("\(item.label): \(item.available)")
}
}
}
private func pill(_ text: String) -> some View {
Text(text)
.font(.caption2.monospaced())
.foregroundStyle(FlightTheme.textSecondary)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(FlightTheme.accent.opacity(0.10), in: Capsule())
}
// MARK: - Layover
private func layoverMinutes(at index: Int) -> Int? {
guard index >= 1, index < connection.flights.count else { return nil }
let arr = connection.flights[index - 1].arrival.dateTime
let dep = connection.flights[index].departure.dateTime
let mins = Int(dep.timeIntervalSince(arr) / 60)
return mins > 0 ? mins : nil
}
private func layoverRow(minutes: Int, at iata: String) -> some View {
HStack(spacing: 8) {
Image(systemName: "arrow.down")
.font(.caption2)
.foregroundStyle(FlightTheme.textTertiary)
Text("Layover at \(iata) · \(formatDuration(minutes))")
.font(.caption)
.foregroundStyle(FlightTheme.textSecondary)
Spacer()
}
.padding(.leading, 24)
}
// MARK: - Fetching
private func fetchAllLegs() async {
await withTaskGroup(of: (Int, FlightLoad?).self) { group in
for (i, leg) in connection.flights.enumerated() {
let airlineCode = leg.carrierIata
let flightNumber = "\(leg.flightNumber)"
let date = leg.departure.dateTime
let origin = leg.departure.airportIata
let destination = leg.arrival.airportIata
let depTime = Self.timeFormatter.string(from: leg.departure.dateTime)
group.addTask { [loadService] in
let load = await loadService.fetchLoad(
airlineCode: airlineCode,
flightNumber: flightNumber,
date: date,
origin: origin,
destination: destination,
departureTime: depTime
)
return (i, load)
}
}
for await (i, load) in group {
guard i < legStates.count else { continue }
legStates[i].load = load
legStates[i].isLoading = false
}
}
}
// MARK: - Helpers
private static let timeFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "HH:mm"
return f
}()
private func timeFmt(_ d: Date) -> String { Self.timeFormatter.string(from: d) }
private var stopsLabel: String {
switch connection.stopCount {
case 0: return "Direct"
case 1: return "1-stop Connection"
default: return "\(connection.stopCount)-stop Connection"
}
}
/// Nav-bar title. Single legs get the route ("DFW SHV"); multi-stops
/// get the stops label so the user can tell at a glance.
private var navTitle: String {
if connection.flights.count == 1, let leg = connection.flights.first {
return "\(leg.departure.airportIata)\(leg.arrival.airportIata)"
}
return stopsLabel
}
private var carriersLabel: String {
let codes = connection.carrierIatas
if codes.count == 1, let app = appendix?.airline(iata: codes[0])?.name {
return app
}
let names = codes.map { appendix?.airline(iata: $0)?.name ?? $0 }
return names.joined(separator: " · ")
}
private func airlineName(for leg: RouteFlight) -> String {
appendix?.airline(iata: leg.carrierIata)?.name ?? leg.carrierIata
}
private func aircraftLabel(for leg: RouteFlight) -> String? {
guard let iata = leg.equipmentIata else { return nil }
return appendix?.equipment(iata: iata)?.name ?? iata
}
/// Bundled DB first (clean city names), then route-explorer appendix.
private func airportName(for iata: String) -> String {
if let m = database.airport(byIATA: iata) { return m.name }
if let n = appendix?.airport(iata: iata)?.cityName, !n.isEmpty { return n }
if let n = appendix?.airport(iata: iata)?.name, !n.isEmpty { return n }
return iata
}
/// Map a cabin name to a short fare-class letter for compact pills.
private func cabinShort(_ name: String) -> String {
let lower = name.lowercased()
if lower.contains("first") { return "F" }
if lower.contains("polaris") || lower.contains("business") { return "J" }
if lower.contains("premium") { return "W" }
if lower.contains("economy") || lower.contains("main") || lower.contains("rear") { return "Y" }
if lower.contains("front") { return "F" }
if lower.contains("middle") { return "J" }
return String(name.prefix(3)).uppercased()
}
private func formatDuration(_ minutes: Int) -> String {
let h = minutes / 60
let m = minutes % 60
if h > 0, m > 0 { return "\(h)h \(m)m" }
if h > 0 { return "\(h)h" }
return "\(m)m"
}
}
// MARK: - Per-leg load state
private struct LegLoadState: Identifiable {
let id: String
let leg: RouteFlight
var load: FlightLoad?
var isLoading: Bool
init(leg: RouteFlight) {
self.id = leg.id
self.leg = leg
self.load = nil
self.isLoading = true
}
}
// MARK: - Wrapping HStack for pills
/// Lightweight wrapping HStack so cabin pills flow onto multiple lines on
/// narrow widths instead of clipping or pushing past the card edge.
private struct FlowLayoutHStack<Content: View>: View {
let spacing: CGFloat
@ViewBuilder var content: () -> Content
init(spacing: CGFloat = 6, @ViewBuilder content: @escaping () -> Content) {
self.spacing = spacing
self.content = content
}
var body: some View {
// Use SwiftUI's iOS 16+ Layout via `ViewThatFits` over single-line and
// multi-line variants. For the small pill counts we have, a simple
// horizontal stack with wrapping is enough; if the pill row overflows
// we fall back to stacking each pill on its own row.
ViewThatFits(in: .horizontal) {
HStack(spacing: spacing) {
content()
Spacer(minLength: 0)
}
VStack(alignment: .leading, spacing: spacing) {
content()
}
}
}
}
-304
View File
@@ -1,304 +0,0 @@
import SwiftUI
enum SearchRoute: Hashable {
case destinations(Airport, Date, Bool)
case routeDetail(Airport, Airport, Date)
case routePlanner
case whereToGo
}
struct ContentView: View {
let service: FlightService
let database: AirportDatabase
let loadService: AirlineLoadService
let favoritesManager: FavoritesManager
let routeExplorer: RouteExplorerClient
@State private var viewModel: SearchViewModel
@State private var path = NavigationPath()
init(
service: FlightService,
database: AirportDatabase,
loadService: AirlineLoadService = AirlineLoadService(),
favoritesManager: FavoritesManager,
routeExplorer: RouteExplorerClient = RouteExplorerClient()
) {
self.service = service
self.database = database
self.loadService = loadService
self.favoritesManager = favoritesManager
self.routeExplorer = routeExplorer
self._viewModel = State(initialValue: SearchViewModel(service: service, database: database))
}
var body: some View {
NavigationStack(path: $path) {
ScrollView {
VStack(spacing: FlightTheme.sectionSpacing) {
// MARK: - Combined FROM / TO Card
VStack(alignment: .leading, spacing: 0) {
// FROM section
VStack(alignment: .leading, spacing: 8) {
Label {
Text("FROM")
.font(FlightTheme.label())
.foregroundStyle(.secondary)
.tracking(1)
} icon: {
Image(systemName: "airplane.departure")
.font(.caption)
.foregroundStyle(.secondary)
}
AirportSearchField(
label: "Departure Airport",
searchText: $viewModel.departureSearchText,
selectedAirport: $viewModel.departureAirport,
suggestions: viewModel.departureSuggestions,
countrySuggestions: viewModel.departureCountrySuggestions,
regionResult: viewModel.departureRegionResult,
isSearching: viewModel.isDepartureSearching,
service: service,
database: database,
onTextChanged: { viewModel.departureTextChanged() },
onSelect: { viewModel.selectDeparture($0) },
onClear: { viewModel.clearDeparture() }
)
}
.padding(FlightTheme.cardPadding)
Divider()
.padding(.horizontal, FlightTheme.cardPadding)
// TO section
VStack(alignment: .leading, spacing: 8) {
Label {
Text("TO (OPTIONAL)")
.font(FlightTheme.label())
.foregroundStyle(.secondary)
.tracking(1)
} icon: {
Image(systemName: "mappin.and.ellipse")
.font(.caption)
.foregroundStyle(.secondary)
}
AirportSearchField(
label: "Arrival Airport",
searchText: $viewModel.arrivalSearchText,
selectedAirport: $viewModel.arrivalAirport,
suggestions: viewModel.arrivalSuggestions,
countrySuggestions: viewModel.arrivalCountrySuggestions,
regionResult: viewModel.arrivalRegionResult,
isSearching: viewModel.isArrivalSearching,
service: service,
database: database,
onTextChanged: { viewModel.arrivalTextChanged() },
onSelect: { viewModel.selectArrival($0) },
onClear: { viewModel.clearArrival() }
)
}
.padding(FlightTheme.cardPadding)
}
.background(FlightTheme.cardBackground)
.clipShape(RoundedRectangle(cornerRadius: FlightTheme.cardCornerRadius))
.shadow(color: FlightTheme.cardShadow, radius: 8, y: 2)
// MARK: - Date Card
HStack(spacing: 10) {
Image(systemName: "calendar")
.foregroundStyle(FlightTheme.accent)
.font(.body)
DatePicker(
"Travel Date",
selection: $viewModel.selectedDate,
displayedComponents: .date
)
.labelsHidden()
.datePickerStyle(.compact)
.tint(FlightTheme.accent)
Spacer()
}
.flightCard()
// MARK: - Search Button
Button {
navigateToResults()
} label: {
Text("Search Flights")
.font(.body.weight(.bold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: 50)
.background(
LinearGradient(
colors: [FlightTheme.accent, FlightTheme.accentLight],
startPoint: .leading,
endPoint: .trailing
)
)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.disabled(!viewModel.canSearch)
.opacity(viewModel.canSearch ? 1.0 : 0.5)
// MARK: - Multi-stop / Where-to-go entry points
VStack(spacing: 10) {
Button {
path.append(SearchRoute.routePlanner)
} label: {
HStack(spacing: 12) {
Image(systemName: "arrow.triangle.branch")
.font(.title3)
.foregroundStyle(FlightTheme.accent)
.frame(width: 36, height: 36)
.background(FlightTheme.accent.opacity(0.12), in: RoundedRectangle(cornerRadius: 10))
VStack(alignment: .leading, spacing: 2) {
Text("Find Connections")
.font(.subheadline.weight(.semibold))
.foregroundStyle(FlightTheme.textPrimary)
Text("Direct + multi-stop A→B routing")
.font(.caption)
.foregroundStyle(FlightTheme.textSecondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(FlightTheme.textTertiary)
}
.flightCard()
}
.buttonStyle(.plain)
Button {
path.append(SearchRoute.whereToGo)
} label: {
HStack(spacing: 12) {
Image(systemName: "questionmark.diamond")
.font(.title3)
.foregroundStyle(FlightTheme.accent)
.frame(width: 36, height: 36)
.background(FlightTheme.accent.opacity(0.12), in: RoundedRectangle(cornerRadius: 10))
VStack(alignment: .leading, spacing: 2) {
Text("Where can I go?")
.font(.subheadline.weight(.semibold))
.foregroundStyle(FlightTheme.textPrimary)
Text("All departures in the next few hours")
.font(.caption)
.foregroundStyle(FlightTheme.textSecondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(FlightTheme.textTertiary)
}
.flightCard()
}
.buttonStyle(.plain)
}
// MARK: - Favorites
if !favoritesManager.favorites.isEmpty {
VStack(alignment: .leading, spacing: 12) {
Text("FAVORITES")
.font(FlightTheme.label())
.foregroundStyle(FlightTheme.textTertiary)
.tracking(1)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(favoritesManager.favorites) { fav in
Button {
path.append(SearchRoute.routeDetail(fav.departure, fav.arrival, viewModel.selectedDate))
} label: {
HStack(spacing: 6) {
Text(fav.departure.iata)
.fontWeight(.bold)
Image(systemName: "arrow.right")
.font(.caption2)
Text(fav.arrival.iata)
.fontWeight(.bold)
}
.font(.subheadline)
.foregroundStyle(.primary)
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(FlightTheme.cardBackground)
.clipShape(Capsule())
.shadow(color: FlightTheme.cardShadow, radius: 4, y: 1)
}
.contextMenu {
Button(role: .destructive) {
favoritesManager.remove(fav)
} label: {
Label("Remove", systemImage: "trash")
}
}
}
}
}
}
}
}
.padding(.horizontal)
.padding(.top, 8)
.padding(.bottom, 32)
}
.background(FlightTheme.background.ignoresSafeArea())
.navigationTitle("Flights")
.navigationDestination(for: SearchRoute.self) { route in
switch route {
case let .destinations(airport, date, isArrival):
DestinationsListView(
airport: airport,
date: date,
service: service,
isArrival: isArrival,
loadService: loadService,
database: database,
favoritesManager: favoritesManager
)
case let .routeDetail(departure, arrival, date):
RouteDetailView(
departure: departure,
arrival: arrival,
date: date,
service: service,
loadService: loadService,
favoritesManager: favoritesManager
)
case .routePlanner:
RoutePlannerView(
database: database,
client: routeExplorer,
loadService: loadService
)
case .whereToGo:
WhereToGoView(
database: database,
client: routeExplorer,
loadService: loadService
)
}
}
}
}
// MARK: - Helpers
private func navigateToResults() {
let date = viewModel.selectedDate
if let departure = viewModel.departureAirport,
let arrival = viewModel.arrivalAirport {
path.append(SearchRoute.routeDetail(departure, arrival, date))
} else if let departure = viewModel.departureAirport {
path.append(SearchRoute.destinations(departure, date, false))
} else if let arrival = viewModel.arrivalAirport {
path.append(SearchRoute.destinations(arrival, date, true))
}
}
}
+360
View File
@@ -0,0 +1,360 @@
import SwiftUI
import UIKit
import WebKit
/// Settings Tools Diagnostics. Surfaces every log file
/// ``DiagnosticLogger`` has written this install, lets the user
/// preview them inline, and exports any one of them through the iOS
/// share sheet (AirDrop / mail / Files / iMessage) the path by
/// which a user on a real device can ship a forensic dump to us when
/// something fails in a way we can't reproduce in the simulator.
///
/// Buttons:
/// Run gate scenario opens an off-screen WKWebView at
/// route-explorer.com, polls /api/token every 1.5s for 30s,
/// captures cookies + status on every tick. This is the
/// "Turnstile won't pass" debug trace.
/// Run search scenario fires both the route-explorer search
/// path (with gate-clearance dependency) AND the FlightAware path
/// so the log shows both transports side-by-side for the same
/// route+date.
/// Tap a row to share uses ``UIActivityViewController`` so the
/// user gets the standard share sheet.
struct DiagnosticsView: View {
@State private var logFiles: [URL] = []
@State private var loggerEnabled: Bool = true
@State private var shareURL: URL?
@State private var scenarioRunning: String?
var body: some View {
List {
controlsSection
scenariosSection
logsSection
}
.navigationTitle("Diagnostics")
.navigationBarTitleDisplayMode(.inline)
.onAppear { refresh() }
.sheet(item: $shareURL) { url in
ShareSheet(items: [url])
}
}
// MARK: - Sections
private var controlsSection: some View {
Section {
Toggle("Logging enabled", isOn: $loggerEnabled)
.onChange(of: loggerEnabled) { _, on in
DiagnosticLogger.shared.setEnabled(on)
}
HStack {
Text("Session ID").foregroundStyle(.secondary)
Spacer()
Text(DiagnosticLogger.shared.sessionID)
.font(.footnote.monospaced())
}
HStack {
Text("Current log file").foregroundStyle(.secondary)
Spacer()
if let url = DiagnosticLogger.shared.logFileURL {
Text(url.lastPathComponent)
.font(.caption2.monospaced())
.lineLimit(1)
.truncationMode(.middle)
} else {
Text("(none)").foregroundStyle(.secondary)
}
}
Button("Clear all log files") {
DiagnosticLogger.shared.clearAll()
refresh()
}
} header: {
Text("Controls")
} footer: {
Text("Logs are tab-separated text. Each line is one event with timestamp, category, and key=value fields. Files live under the app's Documents/Diagnostics/.")
}
}
private var scenariosSection: some View {
Section {
Button {
Task { await runGateScenario() }
} label: {
scenarioRow(title: "Run gate scenario (30s)",
subtitle: "Polls route-explorer /api/token; captures cookies + JS console + final status",
symbol: "shield.lefthalf.filled",
running: scenarioRunning == "gate")
}
.disabled(scenarioRunning != nil)
Button {
Task { await runFlightAwareScenario() }
} label: {
scenarioRow(title: "Run FlightAware scenario",
subtitle: "DFW→AMS direct; captures route.rvt + trackpoll request shapes",
symbol: "airplane",
running: scenarioRunning == "fa")
}
.disabled(scenarioRunning != nil)
} header: {
Text("Scenarios")
} footer: {
Text("Tap a scenario to run a fixed trace. Result lands in the current log file; share it from the list below.")
}
}
private var logsSection: some View {
Section {
if logFiles.isEmpty {
Text("No log files").foregroundStyle(.secondary)
} else {
ForEach(logFiles, id: \.self) { url in
Button {
shareURL = url
} label: {
logRow(url: url)
}
}
}
} header: {
HStack {
Text("Log files")
Spacer()
Button("Refresh") { refresh() }
.font(.caption)
}
} footer: {
Text("Tap a file to share via AirDrop, email, or iMessage. Open files with a text app to view.")
}
}
// MARK: - Row builders
private func scenarioRow(title: String, subtitle: String, symbol: String, running: Bool) -> some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: symbol)
.foregroundStyle(.tint)
.frame(width: 28)
VStack(alignment: .leading, spacing: 2) {
Text(title).font(.footnote.weight(.semibold))
Text(subtitle).font(.caption2).foregroundStyle(.secondary)
}
Spacer()
if running {
ProgressView()
}
}
.contentShape(Rectangle())
}
private func logRow(url: URL) -> some View {
let attrs = (try? FileManager.default.attributesOfItem(atPath: url.path)) ?? [:]
let size = (attrs[.size] as? Int) ?? 0
let date = (attrs[.modificationDate] as? Date) ?? Date()
let sizeStr = ByteCountFormatter().string(fromByteCount: Int64(size))
let dateStr = Self.dateFormatter.string(from: date)
let isCurrent = url == DiagnosticLogger.shared.logFileURL
return HStack {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(url.lastPathComponent)
.font(.footnote.monospaced())
if isCurrent {
Text("CURRENT").font(.caption2.weight(.heavy)).foregroundStyle(.green)
}
}
Text("\(dateStr) · \(sizeStr)").font(.caption2).foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "square.and.arrow.up").foregroundStyle(.tint)
}
}
private static let dateFormatter: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .short
f.timeStyle = .medium
return f
}()
// MARK: - Actions
private func refresh() {
logFiles = DiagnosticLogger.shared.allLogFiles()
}
private func runGateScenario() async {
scenarioRunning = "gate"
defer { scenarioRunning = nil; refresh() }
DiagnosticLogger.shared.log("SCEN", "gateBegin", [:])
await GateScenarioRunner.run(durationSeconds: 30)
DiagnosticLogger.shared.log("SCEN", "gateEnd", [:])
}
private func runFlightAwareScenario() async {
scenarioRunning = "fa"
defer { scenarioRunning = nil; refresh() }
DiagnosticLogger.shared.log("SCEN", "faBegin", ["route": "DFW->AMS"])
let client = FlightAwareScheduleClient(database: AirportDatabase())
let today = Date()
do {
let result = try await client.searchDirectFlights(from: "DFW", to: "AMS", date: today)
DiagnosticLogger.shared.log("SCEN", "faResult", [
"connections": result.connections.count,
])
} catch {
DiagnosticLogger.shared.log("SCEN", "faError", [
"error": error.localizedDescription,
])
}
DiagnosticLogger.shared.log("SCEN", "faEnd", [:])
}
}
// MARK: - Gate scenario runner
/// Encapsulates the off-screen WKWebView poll loop used by the
/// "Run gate scenario" button. Lives outside the View so it survives
/// even if the view is dismissed mid-run (no SwiftUI state binding).
@MainActor
private enum GateScenarioRunner {
static func run(durationSeconds: Int) async {
let config = WKWebViewConfiguration()
config.websiteDataStore = .default()
let contentController = WKUserContentController()
// Bridge console messages into the logger.
let bridge = """
(function() {
const orig = window.console;
const send = (lvl, args) => {
try {
window.webkit.messageHandlers.diag.postMessage(
lvl + ": " + Array.from(args).map(a =>
(typeof a === 'object' ? JSON.stringify(a) : String(a))
).join(' ').substring(0, 240)
);
} catch (e) {}
};
['log','info','warn','error','debug'].forEach(lvl => {
const f = orig[lvl];
orig[lvl] = function(...args) { send(lvl, args); return f.apply(orig, args); };
});
})();
"""
contentController.addUserScript(WKUserScript(
source: bridge, injectionTime: .atDocumentStart, forMainFrameOnly: false
))
let handler = ScenarioConsoleHandler()
contentController.add(handler, name: "diag")
config.userContentController = contentController
let webView = WKWebView(frame: .zero, configuration: config)
webView.customUserAgent =
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) "
+ "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 "
+ "Mobile/15E148 Safari/604.1"
let delegate = ScenarioNavigationDelegate()
webView.navigationDelegate = delegate
// Load homepage.
webView.load(URLRequest(url: URL(string: "https://route-explorer.com/")!))
DiagnosticLogger.shared.log("SCEN", "gateLoaded", [
"url": "https://route-explorer.com/",
])
// Wait for first navigation to finish (best-effort).
try? await Task.sleep(nanoseconds: 1_500_000_000)
// Poll loop.
let deadline = Date().addingTimeInterval(TimeInterval(durationSeconds))
var tick = 0
while Date() < deadline {
tick += 1
await probe(webView: webView, tick: tick)
try? await Task.sleep(nanoseconds: 1_500_000_000)
}
// Snapshot final cookies.
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
let reCookies = cookies.filter { $0.domain.contains("route-explorer.com") }
DiagnosticLogger.shared.log("SCEN", "gateFinal", [
"ticks": tick,
"cookieCount": reCookies.count,
"names": reCookies.map { $0.name }.sorted().joined(separator: ","),
"hasRexClearance": reCookies.contains { $0.name == "rex_clearance" },
])
}
private static func probe(webView: WKWebView, tick: Int) async {
let js = """
return await new Promise((res) => {
fetch('/api/token', { credentials: 'include' })
.then(r => r.text().then(t => res({status: r.status, body: t})))
.catch(e => res({status: -1, body: String(e)}));
});
"""
let raw = try? await webView.callAsyncJavaScript(js, contentWorld: .page)
let dict = raw as? [String: Any]
let status = dict?["status"] as? Int ?? -1
let body = dict?["body"] as? String ?? ""
DiagnosticLogger.shared.log("SCEN", "gateProbe", [
"tick": tick,
"status": status,
"body": String(body.prefix(200)),
])
}
}
@MainActor
private final class ScenarioConsoleHandler: NSObject, WKScriptMessageHandler {
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
guard let body = message.body as? String else { return }
DiagnosticLogger.shared.log("SCEN", "gateConsole", ["msg": body])
}
}
@MainActor
private final class ScenarioNavigationDelegate: NSObject, WKNavigationDelegate {
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
DiagnosticLogger.shared.log("SCEN", "gateNavDone", [
"url": webView.url?.absoluteString ?? "?",
])
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
DiagnosticLogger.shared.log("SCEN", "gateNavFailed", [
"error": error.localizedDescription,
])
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
DiagnosticLogger.shared.log("SCEN", "gateNavFailedProvisional", [
"error": error.localizedDescription,
])
}
func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse) async -> WKNavigationResponsePolicy {
if let http = navigationResponse.response as? HTTPURLResponse {
DiagnosticLogger.shared.log("SCEN", "gateNavResponse", [
"url": http.url?.absoluteString ?? "?",
"status": http.statusCode,
"setCookie": String((http.value(forHTTPHeaderField: "Set-Cookie") ?? "").prefix(200)),
"cfRay": http.value(forHTTPHeaderField: "CF-Ray") ?? "-",
"server": http.value(forHTTPHeaderField: "Server") ?? "-",
])
}
return .allow
}
}
// MARK: - Share sheet
struct ShareSheet: UIViewControllerRepresentable {
let items: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: items, applicationActivities: nil)
}
func updateUIViewController(_ vc: UIActivityViewController, context: Context) {}
}
extension URL: @retroactive Identifiable {
public var id: String { absoluteString }
}
+188
View File
@@ -0,0 +1,188 @@
import SwiftUI
/// Walks every LoggedFlight that lacks an aircraftType, looks the
/// scheduled aircraft up via route-explorer, and patches it in. Use
/// after a CSV import that didn't get aircraft data, or any time the
/// Aircraft Stats screen looks empty.
struct EnrichAircraftTypesView: View {
let store: FlightHistoryStore
let routeExplorer: RouteExplorerClient
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var scheme
@State private var phase: Phase = .ready
@State private var candidates: [LoggedFlight] = []
@State private var processedCount = 0
@State private var enrichedCount = 0
@State private var skippedDates: [Date] = []
@State private var task: Task<Void, Never>?
enum Phase { case ready, scanning, running, cancelled, done }
var body: some View {
NavigationStack {
content
.navigationTitle("Look up aircraft")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(phase == .running ? "Stop" : "Done") {
task?.cancel()
dismiss()
}
}
}
.task { await onAppear() }
}
}
@ViewBuilder
private var content: some View {
switch phase {
case .ready:
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .scanning:
VStack(spacing: 12) {
ProgressView()
Text("Scanning your log…")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .running:
VStack(spacing: 18) {
Spacer()
VStack(spacing: 8) {
Text("\(processedCount) of \(candidates.count)")
.font(.system(size: 44, weight: .heavy).monospacedDigit())
Text("Found aircraft for \(enrichedCount)")
.font(.subheadline)
.foregroundStyle(.secondary)
}
ProgressView(value: Double(processedCount), total: Double(max(candidates.count, 1)))
.progressViewStyle(.linear)
.tint(HistoryStyle.runwayOrange)
.padding(.horizontal, 32)
Spacer()
}
case .cancelled:
ContentUnavailableView(
"Stopped",
systemImage: "stop.circle",
description: Text("Enriched \(enrichedCount) flights before stopping.")
)
case .done:
doneState
}
}
private var doneState: some View {
VStack(spacing: 14) {
Spacer()
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 56, weight: .heavy))
.foregroundStyle(HistoryStyle.runwayOrange)
Text("Found aircraft for \(enrichedCount) of \(candidates.count) flights")
.font(.system(size: 18, weight: .heavy))
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
if candidates.count - enrichedCount > 0 {
Text("Others may be too old for route-explorer's schedule data, or the carrier isn't covered. Manual edit still works for those.")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
}
Spacer()
}
}
private func onAppear() async {
phase = .scanning
candidates = store.allFlights().filter { f in
f.aircraftType == nil
&& f.carrierIATA != nil
&& (f.flightNumber.flatMap(Int.init) != nil)
}
if candidates.isEmpty {
// Nothing to do everything already has a type.
phase = .done
return
}
phase = .running
let t = Task { await runEnrichment() }
task = t
await t.value
}
private func runEnrichment() async {
for f in candidates {
if Task.isCancelled {
phase = .cancelled
return
}
if let eq = await lookupAircraftType(for: f) {
f.aircraftType = eq
enrichedCount += 1
}
processedCount += 1
}
// Save once at the end SwiftData batches writes nicely.
store.persist("enrich aircraft types")
phase = .done
}
/// Two-step lookup:
/// 1. route-explorer schedule works for future or near-future
/// flights. Returns IATA aircraft codes ("73H").
/// 2. FlightAware activity-log scrape works for historical
/// flights still on a current flight number. Returns ICAO
/// codes ("B738").
/// Either way we normalize to canonical ICAO via AircraftDatabase
/// before saving so the rest of the app recognizes the value.
private func lookupAircraftType(for f: LoggedFlight) async -> String? {
guard let carrier = f.carrierIATA,
let numStr = f.flightNumber,
let num = Int(numStr)
else { return nil }
// 1) route-explorer
let day = Calendar.current.startOfDay(for: f.flightDate)
let next = Calendar.current.date(byAdding: .day, value: 1, to: day) ?? day
let results = await routeExplorer.searchSchedule(
carrierCode: carrier,
flightNumber: num,
startDate: day,
endDate: next
)
let exact = results.first {
$0.departure.airportIata == f.departureIATA
&& $0.arrival.airportIata == f.arrivalIATA
} ?? results.first
if let eq = exact?.equipmentIata, !eq.isEmpty {
return AircraftDatabase.shared.normalizedICAO(forCode: eq)
}
// 2) FlightAware fallback
// Build the ICAO callsign FA addresses pages by ICAO carrier
// + flight number. AircraftRegistry already maps IATAICAO.
guard let carrierICAO = f.carrierICAO
?? AircraftRegistry.shared.lookup(iata: carrier)?.icao
else { return nil }
let callsign = "\(carrierICAO)\(num)"
if let icaoType = await FlightAwareLookup.shared.lookupType(
callsign: callsign,
departureIATA: f.departureIATA,
arrivalIATA: f.arrivalIATA
) {
return AircraftDatabase.shared.normalizedICAO(forCode: icaoType)
}
return nil
}
}
+9 -14
View File
@@ -25,8 +25,6 @@ struct FlightLoadDetailView: View {
.tint(FlightTheme.accent)
} else if let error {
errorView(error)
} else if schedule.airline.iata.uppercased() == "NK" {
spiritUnavailableView
} else if let load {
loadContent(load)
} else {
@@ -114,23 +112,20 @@ struct FlightLoadDetailView: View {
}
}
// MARK: - Spirit Unavailable
private var spiritUnavailableView: some View {
ContentUnavailableView {
Label("Not Available", systemImage: "info.circle")
} description: {
Text("Spirit Airlines does not provide standby or load data.")
}
}
// MARK: - Unsupported Airline
/// Shown when fetchLoad returns nil. That can be either:
/// - the airline is one we don't have a fetcher for (DL, WN, etc.), or
/// - the airline IS supported but the carrier's API has no data for
/// this specific flight (typical for regional codeshares AA Eagle
/// 4-digit flights, UA Express, etc.).
/// Without knowing which case we hit, the message stays flight-scoped
/// rather than blaming the whole airline.
private var unsupportedAirlineView: some View {
ContentUnavailableView {
Label("Not Available", systemImage: "info.circle")
Label("Load Data Unavailable", systemImage: "info.circle")
} description: {
Text("Load data not available for \(schedule.airline.name).")
Text("Load data isn't available for this flight on \(schedule.airline.name).")
}
}
+828
View File
@@ -0,0 +1,828 @@
import SwiftUI
import MapKit
import CoreLocation
/// Single-flight detail screen restyled with the passport palette.
/// Aircraft card now uses Flighty's labeled-grid pattern with
/// em-dashes for missing data. New "Detailed Timetable" card shows
/// scheduled vs actual when we have actual times, with late actuals
/// in red.
struct HistoryDetailView: View {
let flight: LoggedFlight
let store: FlightHistoryStore
let database: AirportDatabase
let openSky: OpenSkyClient
var routeExplorer: RouteExplorerClient? = nil
@Environment(\.dismiss) private var dismiss
@Environment(\.openURL) private var openURL
@Environment(\.colorScheme) private var scheme
@State private var photo: AircraftPhotoService.Photo?
@State private var track: AircraftTrack?
@State private var editedNotes: String = ""
@State private var showDeleteConfirm = false
@State private var metadataLoaded = false
// Standby editor state. Mirrors the persisted fields on LoggedFlight
// so the Picker / DatePickers have stable bindings; onChange writes
// back into the @Model and saves the context.
@State private var standbyOutcome: String = "confirmed"
@State private var standbyAttemptedAt: Date = Date()
@State private var standbyClearedAt: Date = Date()
@State private var standbyNotes: String = ""
@State private var hasStandbyAttemptedAt: Bool = false
@State private var hasStandbyClearedAt: Bool = false
// Airframe history snapshot for the section below.
@State private var airframeStats: AirframeHistoryStore.AirframeStats?
private let airframeHistory = AirframeHistoryStore()
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
header
routeCard
photoBanner.padding(.horizontal, -16)
if let cred = photo?.photographer {
photoCredit(name: cred, link: photo?.detailLink)
}
mapSection
aircraftCard
timetableCard
notesSection
standbySection
airframeHistorySection
deleteButton
}
.padding(16)
}
.background(HistoryStyle.background(scheme).ignoresSafeArea())
.navigationTitle("")
.navigationBarTitleDisplayMode(.inline)
.task {
editedNotes = flight.notes ?? ""
hydrateStandbyState()
loadAirframeHistory()
if let reg = flight.registration {
photo = await AircraftPhotoService.shared.photo(registration: reg, icao24: flight.icao24 ?? "")
}
await loadTrackIfRecent()
await loadAirframeMetadata()
await enrichAircraftTypeIfMissing()
}
.alert("Delete this flight?", isPresented: $showDeleteConfirm) {
Button("Delete", role: .destructive) {
store.delete(flight)
dismiss()
}
Button("Cancel", role: .cancel) {}
}
}
// MARK: - Header
private var header: some View {
VStack(alignment: .leading, spacing: 4) {
Text(flight.flightLabel)
.font(.system(size: 38, weight: .black).monospaced())
.foregroundStyle(HistoryStyle.ink(scheme))
HStack(spacing: 8) {
Text(airlineName)
.font(.system(size: 14, weight: .semibold))
Text("·")
Text(longDate(flight.flightDate).uppercased())
.font(.system(size: 12, weight: .heavy).monospaced())
.tracking(1)
}
.foregroundStyle(HistoryStyle.inkSecondary(scheme))
}
}
private var airlineName: String {
AircraftRegistry.shared.lookup(icao: flight.carrierICAO)?.name
?? AircraftRegistry.shared.lookup(iata: flight.carrierIATA)?.name
?? flight.carrierIATA ?? "Unknown"
}
/// Compass bearing from departure to arrival airport. Falls back to
/// 90° (eastbound) when we can't resolve coordinates so the icon
/// just stays in a sensible default.
private var routeBearing: Double {
guard let dep = database.airport(byIATA: flight.departureIATA),
let arr = database.airport(byIATA: flight.arrivalIATA)
else { return 90 }
return Self.bearing(from: dep.coordinate, to: arr.coordinate)
}
static func bearing(from a: CLLocationCoordinate2D, to b: CLLocationCoordinate2D) -> Double {
let lat1 = a.latitude * .pi / 180
let lat2 = b.latitude * .pi / 180
let dLon = (b.longitude - a.longitude) * .pi / 180
let y = sin(dLon) * cos(lat2)
let x = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon)
let theta = atan2(y, x)
return (theta * 180 / .pi + 360).truncatingRemainder(dividingBy: 360)
}
// MARK: - Route
private var routeCard: some View {
VStack(spacing: 14) {
HStack(alignment: .top) {
routeEndpoint(iata: flight.departureIATA, label: "From", time: flight.actualDeparture ?? flight.scheduledDeparture)
Spacer()
Image(systemName: "airplane")
.font(.system(size: 24, weight: .heavy))
.foregroundStyle(HistoryStyle.runwayOrange)
// SF airplane symbol naturally points up-right
// (~45°). To align with the actual travel
// bearing, rotate by (bearing - 45).
.rotationEffect(.degrees(routeBearing - 45))
Spacer()
routeEndpoint(iata: flight.arrivalIATA, label: "To", time: flight.actualArrival ?? flight.scheduledArrival)
}
Rectangle()
.fill(HistoryStyle.hairline(scheme))
.frame(height: 0.5)
HStack(spacing: 18) {
if let mi = store.distanceMiles(for: flight) {
miniStat(label: "Distance", value: "\(numberString(mi)) mi")
}
miniStat(label: "Duration", value: durationDisplay)
Spacer()
}
}
.historyCard(scheme, padding: 18)
}
private func routeEndpoint(iata: String, label: String, time: Date?) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(label.uppercased())
.font(HistoryStyle.label(10))
.tracking(1.3)
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
Text(iata.isEmpty ? "" : iata)
.font(.system(size: 32, weight: .black).monospaced())
.foregroundStyle(HistoryStyle.ink(scheme))
if let m = database.airport(byIATA: iata) {
Text(m.name)
.font(.system(size: 11))
.foregroundStyle(HistoryStyle.inkSecondary(scheme))
.lineLimit(1)
}
if let time {
Text(shortDateTime(time))
.font(.system(size: 11, weight: .semibold).monospaced())
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private func miniStat(label: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(label.uppercased())
.font(HistoryStyle.label(9))
.tracking(1.2)
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
Text(value)
.font(.system(size: 14, weight: .heavy).monospaced())
.foregroundStyle(HistoryStyle.ink(scheme))
}
}
// MARK: - Photo
@ViewBuilder
private var photoBanner: some View {
if let photo {
AsyncImage(url: photo.largeURL) { phase in
switch phase {
case .success(let img): img.resizable().aspectRatio(contentMode: .fill)
default: Rectangle().fill(HistoryStyle.cardSubtle(scheme))
}
}
.frame(maxWidth: .infinity)
.frame(height: 200)
.clipped()
}
}
private func photoCredit(name: String, link: URL?) -> some View {
HStack(spacing: 4) {
Image(systemName: "camera.fill").font(.system(size: 9))
Text("Photo by \(name) · planespotters.net")
.font(.system(size: 10))
}
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
.contentShape(Rectangle())
.onTapGesture { if let link { openURL(link) } }
}
// MARK: - Map
@ViewBuilder
private var mapSection: some View {
VStack(alignment: .leading, spacing: 8) {
HistorySectionLabel(track == nil ? "Route" : "Flown path")
FlightRouteMap(
departureIATA: flight.departureIATA,
arrivalIATA: flight.arrivalIATA,
track: track,
database: database
)
.frame(height: 220)
.clipShape(RoundedRectangle(cornerRadius: 18))
}
}
private func loadTrackIfRecent() async {
let ageDays = Date().timeIntervalSince(flight.flightDate) / 86400
guard ageDays < 7, let icao24 = flight.icao24, !icao24.isEmpty else { return }
track = await openSky.track(icao24: icao24)
}
/// If the flight is missing its aircraft type, run the same
/// two-step lookup the bulk enricher does: route-explorer for
/// future flights, FlightAware activity-log scrape for historical.
/// Result is normalized to ICAO and patched onto the LoggedFlight.
/// No-op when type is already set.
private func enrichAircraftTypeIfMissing() async {
guard flight.aircraftType == nil || flight.aircraftType?.isEmpty == true else { return }
guard let carrier = flight.carrierIATA,
let numStr = flight.flightNumber,
let num = Int(numStr)
else { return }
// 1) route-explorer (works for future schedules)
if let routeExplorer {
let day = Calendar.current.startOfDay(for: flight.flightDate)
let next = Calendar.current.date(byAdding: .day, value: 1, to: day) ?? day
let results = await routeExplorer.searchSchedule(
carrierCode: carrier,
flightNumber: num,
startDate: day,
endDate: next
)
let exact = results.first {
$0.departure.airportIata == flight.departureIATA
&& $0.arrival.airportIata == flight.arrivalIATA
} ?? results.first
if let eq = exact?.equipmentIata, !eq.isEmpty {
flight.aircraftType = AircraftDatabase.shared.normalizedICAO(forCode: eq)
store.persist("update flight")
return
}
}
// 2) FlightAware activity-log fallback
guard let carrierICAO = flight.carrierICAO
?? AircraftRegistry.shared.lookup(iata: carrier)?.icao
else { return }
let callsign = "\(carrierICAO)\(num)"
if let icaoType = await FlightAwareLookup.shared.lookupType(
callsign: callsign,
departureIATA: flight.departureIATA,
arrivalIATA: flight.arrivalIATA
) {
flight.aircraftType = AircraftDatabase.shared.normalizedICAO(forCode: icaoType)
store.persist("update flight")
}
}
private func loadAirframeMetadata() async {
guard let reg = flight.registration, !reg.isEmpty,
let icao24 = flight.icao24, !icao24.isEmpty
else { return }
if let cached = store.airframe(for: reg),
cached.firstFlightDate != nil || cached.deliveryDate != nil {
metadataLoaded.toggle()
return
}
if let meta = await AirframeMetadataService.shared.metadata(forICAO24: icao24) {
store.upsertAirframe(
registration: reg,
firstFlightDate: meta.firstFlightDate,
deliveryDate: meta.built
)
metadataLoaded.toggle()
}
}
// MARK: - Aircraft card
private var aircraftCard: some View {
let repeats = store.repeatCount(for: flight.registration, before: flight.flightDate)
let airframe = flight.registration.flatMap(store.airframe(for:))
let firstFlight = airframe?.firstFlightDate
let ageYears = firstFlight.map { years(since: $0) }
let typeDisplay: String = {
guard let code = flight.aircraftType, !code.isEmpty else { return "" }
let friendly = AircraftDatabase.shared.displayName(forTypeCode: code)
return friendly == code ? code : "\(code) · \(friendly)"
}()
return VStack(alignment: .leading, spacing: 12) {
HistorySectionLabel("Aircraft")
VStack(spacing: 0) {
aircraftRow(
leftLabel: "Type", leftValue: typeDisplay,
rightLabel: "Tail #", rightValue: flight.registration ?? ""
)
divider
aircraftRow(
leftLabel: "First flight",
leftValue: firstFlight.map { yearString($0) } ?? "",
rightLabel: "Age",
rightValue: ageYears.map { "\($0) yr" } ?? ""
)
divider
aircraftRow(
leftLabel: "On this airframe",
leftValue: repeats == 0 ? "First time" : "\(repeats + 1)\(ordinalSuffix(repeats + 1)) time",
rightLabel: "ICAO24",
rightValue: flight.icao24?.uppercased() ?? ""
)
}
.historyCard(scheme, padding: 0)
// Honest note: tail / icao24 / first-flight come from
// OpenSky metadata which requires icao24 only captured
// when the flight is added via the Live tab. Historical
// CSV imports won't have these.
if flight.registration == nil && flight.icao24 == nil {
Text("Tail #, first-flight date and ICAO24 aren't available for this flight — those come from the live ADS-B feed when you tap a plane on the Live tab. Aircraft type was looked up from FlightAware's recent activity for this flight number.")
.font(.caption2)
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
.padding(.top, 4)
}
}
}
private var divider: some View {
Rectangle()
.fill(HistoryStyle.hairline(scheme))
.frame(height: 0.5)
}
private func aircraftRow(leftLabel: String, leftValue: String, rightLabel: String, rightValue: String) -> some View {
HStack(spacing: 0) {
cell(label: leftLabel, value: leftValue)
cell(label: rightLabel, value: rightValue)
}
}
private func cell(label: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(label.uppercased())
.font(HistoryStyle.label(10))
.tracking(1.3)
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
Text(value)
.font(.system(size: 14, weight: .heavy).monospaced())
.foregroundStyle(HistoryStyle.ink(scheme))
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(14)
}
// MARK: - Timetable
@ViewBuilder
private var timetableCard: some View {
if hasTimetableData {
VStack(alignment: .leading, spacing: 12) {
HistorySectionLabel("Detailed timetable")
VStack(spacing: 0) {
timetableHeader
divider
timetableRow(
label: "Departure",
scheduled: flight.scheduledDeparture,
actual: flight.actualDeparture
)
divider
timetableRow(
label: "Arrival",
scheduled: flight.scheduledArrival,
actual: flight.actualArrival
)
}
.historyCard(scheme, padding: 0)
}
}
}
private var hasTimetableData: Bool {
flight.scheduledDeparture != nil
|| flight.scheduledArrival != nil
|| flight.actualDeparture != nil
|| flight.actualArrival != nil
}
private var timetableHeader: some View {
HStack(spacing: 0) {
Text("")
.frame(maxWidth: .infinity, alignment: .leading)
Text("SCHEDULED")
.font(HistoryStyle.label(10))
.tracking(1.2)
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
.frame(maxWidth: .infinity, alignment: .leading)
Text("ACTUAL")
.font(HistoryStyle.label(10))
.tracking(1.2)
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.horizontal, 14)
.padding(.top, 14)
}
private func timetableRow(label: String, scheduled: Date?, actual: Date?) -> some View {
let isLate: Bool = {
guard let scheduled, let actual else { return false }
return actual.timeIntervalSince(scheduled) > 5 * 60
}()
return HStack(spacing: 0) {
Text(label)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(HistoryStyle.ink(scheme))
.frame(maxWidth: .infinity, alignment: .leading)
Text(scheduled.map(shortTime) ?? "")
.font(.system(size: 13, weight: .heavy).monospaced())
.foregroundStyle(HistoryStyle.inkSecondary(scheme))
.frame(maxWidth: .infinity, alignment: .leading)
Text(actual.map(shortTime) ?? "")
.font(.system(size: 13, weight: .heavy).monospaced())
.foregroundStyle(isLate ? Color(red: 0.85, green: 0.15, blue: 0.15) : HistoryStyle.ink(scheme))
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(14)
}
// MARK: - Notes
private var notesSection: some View {
VStack(alignment: .leading, spacing: 8) {
HistorySectionLabel("Notes")
TextEditor(text: $editedNotes)
.scrollContentBackground(.hidden)
.frame(minHeight: 80)
.padding(8)
.background(HistoryStyle.card(scheme), in: RoundedRectangle(cornerRadius: 14))
.onChange(of: editedNotes) { _, newValue in
flight.notes = newValue.isEmpty ? nil : newValue
// Bug: this used to be a silent assignment with no
// save call every notes edit was wiped on dismiss.
store.persist("update notes")
}
}
}
// MARK: - Standby outcome
/// Editable card for logging a nonrev / standby outcome. The Picker
/// is always visible; the date pickers and the cleared-at date only
/// appear for the standby outcomes (and only standby-made for
/// cleared-at, since a bumped flight never cleared).
private var standbySection: some View {
VStack(alignment: .leading, spacing: 8) {
HistorySectionLabel("Standby outcome")
VStack(alignment: .leading, spacing: 14) {
Picker("Outcome", selection: $standbyOutcome) {
Text("Confirmed").tag("confirmed")
Text("Standby — Made").tag("standby-made")
Text("Standby — Bumped").tag("standby-bumped")
}
.pickerStyle(.segmented)
.tint(FlightTheme.accent)
.onChange(of: standbyOutcome) { _, newValue in
flight.standbyOutcome = newValue
if newValue == "confirmed" {
// Confirmed flights don't carry standby
// timestamps; clear them so we never leak stale
// values after a user toggles back.
flight.standbyAttemptedAt = nil
flight.standbyClearedAt = nil
hasStandbyAttemptedAt = false
hasStandbyClearedAt = false
} else if newValue == "standby-bumped" {
// Bumped means the user never cleared the list.
// Persist the attempted-at date if the user hasn't
// touched the picker otherwise the @State default
// (flight's scheduled departure) silently disappears
// when they leave the screen.
if !hasStandbyAttemptedAt {
flight.standbyAttemptedAt = standbyAttemptedAt
hasStandbyAttemptedAt = true
}
flight.standbyClearedAt = nil
hasStandbyClearedAt = false
} else if newValue == "standby-made" {
// Same lossless-default treatment as bumped, but
// also write the cleared-at default so a
// toggle-and-leave doesn't drop the timestamp.
if !hasStandbyAttemptedAt {
flight.standbyAttemptedAt = standbyAttemptedAt
hasStandbyAttemptedAt = true
}
if !hasStandbyClearedAt {
flight.standbyClearedAt = standbyClearedAt
hasStandbyClearedAt = true
}
}
store.persist("update standby outcome")
}
if standbyOutcome.contains("standby") {
VStack(alignment: .leading, spacing: 10) {
DatePicker(
"Attempted at",
selection: $standbyAttemptedAt,
displayedComponents: [.date, .hourAndMinute]
)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(HistoryStyle.ink(scheme))
.onChange(of: standbyAttemptedAt) { _, newValue in
flight.standbyAttemptedAt = newValue
hasStandbyAttemptedAt = true
store.persist("update standby outcome")
}
if standbyOutcome == "standby-made" {
DatePicker(
"Cleared at",
selection: $standbyClearedAt,
displayedComponents: [.date, .hourAndMinute]
)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(HistoryStyle.ink(scheme))
.onChange(of: standbyClearedAt) { _, newValue in
flight.standbyClearedAt = newValue
hasStandbyClearedAt = true
store.persist("update standby outcome")
}
}
}
}
VStack(alignment: .leading, spacing: 4) {
Text("NOTES")
.font(HistoryStyle.label(10))
.tracking(1.2)
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
TextField(
"List position, jumpseat carrier, who cleared ahead of you…",
text: $standbyNotes,
axis: .vertical
)
.lineLimit(3, reservesSpace: true)
.font(.system(size: 13))
.foregroundStyle(HistoryStyle.ink(scheme))
.padding(10)
.background(HistoryStyle.cardSubtle(scheme), in: RoundedRectangle(cornerRadius: 10))
.onChange(of: standbyNotes) { _, newValue in
flight.standbyNotes = newValue.isEmpty ? nil : newValue
store.persist("update standby outcome")
}
}
}
.padding(14)
.historyCard(scheme, padding: 0)
}
}
/// Pulls persisted standby fields onto our local @State so the
/// editor reflects existing data. Missing dates default to the
/// flight's scheduled departure (or now) so the DatePicker doesn't
/// open on 2001-01-01.
private func hydrateStandbyState() {
standbyOutcome = flight.standbyOutcome ?? "confirmed"
standbyNotes = flight.standbyNotes ?? ""
let fallback = flight.scheduledDeparture ?? flight.flightDate
if let attempted = flight.standbyAttemptedAt {
standbyAttemptedAt = attempted
hasStandbyAttemptedAt = true
} else {
standbyAttemptedAt = fallback
hasStandbyAttemptedAt = false
}
if let cleared = flight.standbyClearedAt {
standbyClearedAt = cleared
hasStandbyClearedAt = true
} else {
standbyClearedAt = fallback
hasStandbyClearedAt = false
}
}
// MARK: - Airframe history
/// Shows the user's personal history on this tail. Hidden when no
/// registration is set (e.g. CSV imports) or when this is the only
/// flight on the airframe single-flight stats aren't interesting.
@ViewBuilder
private var airframeHistorySection: some View {
if let reg = flight.registration, !reg.isEmpty,
let stats = airframeStats, stats.totalFlights > 1 {
VStack(alignment: .leading, spacing: 8) {
HistorySectionLabel("Airframe history")
VStack(spacing: 0) {
aircraftRow(
leftLabel: "Your flights",
leftValue: "\(stats.totalFlights)",
rightLabel: "Distinct routes",
rightValue: "\(stats.routes.count)"
)
divider
VStack(alignment: .leading, spacing: 4) {
Text("MOST COMMON ROUTE")
.font(HistoryStyle.label(10))
.tracking(1.3)
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
Text(stats.mostCommonRoute ?? "")
.font(.system(size: 14, weight: .heavy).monospaced())
.foregroundStyle(HistoryStyle.ink(scheme))
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(14)
}
.historyCard(scheme, padding: 0)
}
}
}
private func loadAirframeHistory() {
guard let reg = flight.registration, !reg.isEmpty else {
airframeStats = nil
return
}
airframeStats = airframeHistory.stats(forTail: reg, context: store.context)
}
// MARK: - Delete
private var deleteButton: some View {
Button(role: .destructive) {
showDeleteConfirm = true
} label: {
HStack {
Image(systemName: "trash")
Text("Delete flight")
}
.font(.system(size: 14, weight: .semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.background(Color.red.opacity(0.12), in: RoundedRectangle(cornerRadius: 14))
.foregroundStyle(Color.red)
.padding(.top, 8)
}
// MARK: - Helpers
private var durationDisplay: String {
guard let min = store.durationMinutes(for: flight) else { return "" }
let h = min / 60
let m = min % 60
return h > 0 ? "\(h)h \(m)m" : "\(m)m"
}
private func numberString(_ n: Int) -> String {
let f = NumberFormatter(); f.numberStyle = .decimal
return f.string(from: NSNumber(value: n)) ?? "\(n)"
}
private func years(since: Date) -> Int {
Calendar.current.dateComponents([.year], from: since, to: Date()).year ?? 0
}
private func yearString(_ d: Date) -> String {
let f = DateFormatter(); f.dateFormat = "yyyy"
return f.string(from: d)
}
private func ordinalSuffix(_ n: Int) -> String {
let r = n % 100
if r >= 11 && r <= 13 { return "th" }
switch n % 10 {
case 1: return "st"
case 2: return "nd"
case 3: return "rd"
default: return "th"
}
}
private func longDate(_ d: Date) -> String {
let f = DateFormatter(); f.dateFormat = "EEE, MMM d, yyyy"
return f.string(from: d)
}
private func shortDateTime(_ d: Date) -> String {
let f = DateFormatter(); f.dateFormat = "MMM d, HH:mm"
return f.string(from: d)
}
private func shortTime(_ d: Date) -> String {
let f = DateFormatter(); f.dateFormat = "h:mm a"
return f.string(from: d)
}
}
/// Map view used by the history detail. Draws the actual flown track
/// when supplied; otherwise a great-circle arc between dep + arr.
private struct FlightRouteMap: View {
let departureIATA: String
let arrivalIATA: String
let track: AircraftTrack?
let database: AirportDatabase
var body: some View {
let dep = database.airport(byIATA: departureIATA)?.coordinate
let arr = database.airport(byIATA: arrivalIATA)?.coordinate
let bearing = (dep != nil && arr != nil)
? HistoryDetailView.bearing(from: dep!, to: arr!)
: 90
Map {
if let dep {
Annotation("From " + departureIATA, coordinate: dep) {
routeMarker(systemName: "airplane.departure",
bearing: bearing,
tint: HistoryStyle.stampGreen)
}
.annotationTitles(.hidden)
}
if let arr {
Annotation("To " + arrivalIATA, coordinate: arr) {
routeMarker(systemName: "airplane.arrival",
bearing: bearing,
tint: HistoryStyle.runwayOrange)
}
.annotationTitles(.hidden)
}
if let track {
let coords = track.path.map {
CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude)
}
MapPolyline(coordinates: coords)
.stroke(HistoryStyle.runwayOrange, lineWidth: 3)
} else if let dep = database.airport(byIATA: departureIATA),
let arr = database.airport(byIATA: arrivalIATA) {
MapPolyline(coordinates: greatCircle(from: dep.coordinate, to: arr.coordinate, segments: 64))
.stroke(HistoryStyle.runwayOrange.opacity(0.7), style: StrokeStyle(lineWidth: 2, dash: [5, 4]))
}
}
}
/// Map-pin chrome for an airport endpoint. Wraps the SF symbol
/// in a circle background, rotates the icon so it points in the
/// actual flight direction (so a DALSAN flight shows planes
/// pointing left, not the default right).
@ViewBuilder
private func routeMarker(systemName: String, bearing: Double, tint: Color) -> some View {
Image(systemName: systemName)
.font(.system(size: 14, weight: .black))
.foregroundStyle(.white)
// SF airplane.departure/airplane.arrival glyphs orient
// roughly up-right at ~45°; rotation matches the central
// route icon's correction.
.rotationEffect(.degrees(bearing - 45))
.frame(width: 30, height: 30)
.background(tint, in: Circle())
.overlay(Circle().stroke(.white, lineWidth: 1.5))
.shadow(color: .black.opacity(0.4), radius: 2, y: 1)
}
private func greatCircle(from a: CLLocationCoordinate2D, to b: CLLocationCoordinate2D, segments: Int) -> [CLLocationCoordinate2D] {
let lat1 = a.latitude * .pi / 180
let lon1 = a.longitude * .pi / 180
let lat2 = b.latitude * .pi / 180
let lon2 = b.longitude * .pi / 180
let d = 2 * asin(sqrt(
pow(sin((lat2 - lat1) / 2), 2)
+ cos(lat1) * cos(lat2) * pow(sin((lon2 - lon1) / 2), 2)
))
if d == 0 { return [a, b] }
var out: [CLLocationCoordinate2D] = []
out.reserveCapacity(segments + 1)
for i in 0...segments {
let f = Double(i) / Double(segments)
let A = sin((1 - f) * d) / sin(d)
let B = sin(f * d) / sin(d)
let x = A * cos(lat1) * cos(lon1) + B * cos(lat2) * cos(lon2)
let y = A * cos(lat1) * sin(lon1) + B * cos(lat2) * sin(lon2)
let z = A * sin(lat1) + B * sin(lat2)
let lat = atan2(z, sqrt(x * x + y * y))
let lon = atan2(y, x)
out.append(CLLocationCoordinate2D(latitude: lat * 180 / .pi, longitude: lon * 180 / .pi))
}
return out
}
}
+133
View File
@@ -0,0 +1,133 @@
import SwiftUI
/// Multi-select filter sheet. Each section is the universe of values
/// the user actually has in their log (years they've flown in,
/// airlines they've actually flown, etc.) so we never show options
/// that would match zero flights. Counts are next to each row.
struct HistoryFilterSheet: View {
let allFlights: [LoggedFlight]
@Binding var filters: HistoryFilters
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List {
if !filters.isEmpty {
Section {
Button(role: .destructive) {
filters = HistoryFilters()
} label: {
Label("Clear all filters", systemImage: "xmark.circle")
}
}
}
Section("Year") {
ForEach(yearOptions, id: \.value) { o in
toggleRow(label: String(o.value), count: o.count,
isOn: filters.years.contains(o.value)) { on in
toggle(&filters.years, o.value, on)
// Years are the user's primary "scope this
// whole screen" filter picking one is a
// commitment, so close the sheet so the
// animation can play immediately.
if on { dismiss() }
}
}
}
Section("Airline") {
ForEach(airlineOptions, id: \.value) { o in
let entry = AircraftRegistry.shared.lookup(icao: o.value)
let name = entry?.name ?? o.value
toggleRow(label: name, count: o.count,
isOn: filters.airlines.contains(o.value)) { on in
toggle(&filters.airlines, o.value, on)
}
}
}
Section("Airport") {
ForEach(airportOptions, id: \.value) { o in
toggleRow(label: o.value, count: o.count,
isOn: filters.airports.contains(o.value)) { on in
toggle(&filters.airports, o.value, on)
}
}
}
Section("Aircraft type") {
ForEach(typeOptions, id: \.value) { o in
let friendly = AircraftDatabase.shared.displayName(forTypeCode: o.value)
let label = friendly == o.value ? o.value : "\(friendly) · \(o.value)"
toggleRow(label: label, count: o.count,
isOn: filters.aircraftTypes.contains(o.value)) { on in
toggle(&filters.aircraftTypes, o.value, on)
}
}
}
}
.navigationTitle("Filters")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
}
}
}
}
// MARK: - Option lists derived from log
private struct Option {
let value: String
let count: Int
}
private var yearOptions: [Item<Int>] {
let yrs = allFlights.map { Calendar.current.component(.year, from: $0.flightDate) }
return Dictionary(grouping: yrs) { $0 }
.map { Item(value: $0.key, count: $0.value.count) }
.sorted { $0.value > $1.value }
}
private var airlineOptions: [Item<String>] {
let codes = allFlights.compactMap { $0.carrierICAO ?? $0.carrierIATA }
return Dictionary(grouping: codes) { $0 }
.map { Item(value: $0.key, count: $0.value.count) }
.sorted { $0.count > $1.count }
}
private var airportOptions: [Item<String>] {
let codes = allFlights.flatMap { [$0.departureIATA, $0.arrivalIATA] }
.filter { !$0.isEmpty }
return Dictionary(grouping: codes) { $0 }
.map { Item(value: $0.key, count: $0.value.count) }
.sorted { $0.count > $1.count }
}
private var typeOptions: [Item<String>] {
let codes = allFlights.compactMap { $0.aircraftType }
return Dictionary(grouping: codes) { $0 }
.map { Item(value: $0.key, count: $0.value.count) }
.sorted { $0.count > $1.count }
}
private struct Item<T: Hashable> {
let value: T
let count: Int
}
private func toggleRow(label: String, count: Int, isOn: Bool, change: @escaping (Bool) -> Void) -> some View {
HStack {
Image(systemName: isOn ? "checkmark.circle.fill" : "circle")
.foregroundStyle(isOn ? FlightTheme.accent : FlightTheme.textTertiary)
Text(label)
Spacer()
Text("\(count)")
.font(.caption.monospacedDigit())
.foregroundStyle(FlightTheme.textTertiary)
}
.contentShape(Rectangle())
.onTapGesture { change(!isOn) }
}
private func toggle<T: Hashable>(_ set: inout Set<T>, _ v: T, _ on: Bool) {
if on { set.insert(v) } else { set.remove(v) }
}
}
+665
View File
@@ -0,0 +1,665 @@
import SwiftUI
import MapKit
import CoreLocation
/// Lifetime route map redesigned.
///
/// What it does, in order:
/// 1. On appear (and whenever filters change), the camera fits to
/// the bounding region of every filtered flight's dep + arr
/// airports with padding. You always see your data.
/// 2. An animation auto-plays from oldest flight to newest. Each
/// flight gets a small plane icon that flies along the great-
/// circle from departure to arrival, drawing a solid orange
/// line behind it. The whole sweep takes ~4 seconds regardless
/// of flight count.
/// 3. Airport dots are invisible at start. Each one pops in with a
/// brief pulse when its first flight lands there.
/// 4. Drawn arcs stay drawn at full color; the most-recent flight
/// stays slightly brighter/thicker as a focal point.
/// 5. Bottom drawer is a real swipe-up sheet with detents peek
/// shows the count + replay; expanded shows filter chips.
struct HistoryRouteMapView: View {
/// The user's full unfiltered log. The map filters this set
/// internally by `filters` so changes from the in-map filter sheet
/// reliably re-scope the animation, even if the parent's
/// .sheet content closure doesn't propagate a new pre-filtered
/// array to us in time.
let allFlights: [LoggedFlight]
let database: AirportDatabase
let openSky: OpenSkyClient
let store: FlightHistoryStore
@Binding var filters: HistoryFilters
/// Locally-derived "what to draw" set. Recomputed on every body
/// pass; cheap because it's a single array filter on ~hundreds of
/// items at most.
private var flights: [LoggedFlight] {
allFlights.filter { filters.matches($0) }
}
@State private var position: MapCameraPosition = .automatic
@State private var progress: Double = 0
@State private var animationKey: Int = 0
@State private var schedule: AnimationSchedule = .empty
@State private var selectedAirportSheet: AirportSheet?
@State private var drawerExpanded: Bool = false
@State private var dragOffset: CGFloat = 0
@State private var showingFilterSheet: Bool = false
/// Drawer heights peek shows just the row of stats + replay,
/// expanded shows filter chips + per-airport stats.
private static let drawerPeekHeight: CGFloat = 96
private static let drawerExpandedHeight: CGFloat = 340
/// ~4 second total sweep, snappy. Per-flight slice is computed
/// from this in `AnimationSchedule.build`.
private static let totalDuration: TimeInterval = 4.0
struct AirportSheet: Identifiable {
let iata: String
var id: String { iata }
}
var body: some View {
ZStack(alignment: .bottom) {
mapLayer
// Overlay drawer (not a sheet) so the parent sheet's
// swipe-down-to-dismiss gesture still works on the map
// surface. The drawer has two states: peek and expanded.
// Tap or drag the handle to toggle; drag offset gives
// live feedback during the gesture.
mapDrawer
.frame(maxWidth: .infinity)
.frame(height: drawerHeight)
.offset(y: dragOffset)
.gesture(drawerDragGesture)
}
.navigationTitle(filters.isEmpty ? "Routes" : "Filtered Routes")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button { showingFilterSheet = true } label: {
Image(systemName: filters.activeCount > 0
? "line.3.horizontal.decrease.circle.fill"
: "line.3.horizontal.decrease.circle")
}
}
ToolbarItem(placement: .primaryAction) {
Button { restart() } label: {
Image(systemName: "arrow.clockwise.circle.fill")
.font(.title3)
.foregroundStyle(HistoryStyle.runwayOrange)
}
}
}
.onAppear { reset() }
.onChange(of: filters) { _, _ in reset() }
.task(id: animationKey) {
await runAnimation()
}
.sheet(isPresented: $showingFilterSheet) {
HistoryFilterSheet(allFlights: allFlights, filters: $filters)
.presentationDetents([.medium, .large])
}
.sheet(item: $selectedAirportSheet) { sheet in
NavigationStack {
AirportFlightsView(
iata: sheet.iata,
allFlights: allFlights,
database: database,
store: store,
openSky: openSky,
filters: $filters
)
}
.presentationDetents([.medium, .large])
}
}
private var drawerHeight: CGFloat {
drawerExpanded ? Self.drawerExpandedHeight : Self.drawerPeekHeight
}
/// Drag gesture for the drawer handle area.
/// - Upward drag expand
/// - Downward drag (from expanded) collapse to peek
/// - dragOffset gives live feedback while the finger is down,
/// then snaps to a state on release with a spring.
private var drawerDragGesture: some Gesture {
DragGesture()
.onChanged { value in
let raw = value.translation.height
// Clamp the live offset so the drawer can't be dragged
// off-screen in either direction.
if drawerExpanded {
dragOffset = max(0, raw)
} else {
dragOffset = min(0, raw)
}
}
.onEnded { value in
let delta = value.translation.height
withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) {
if drawerExpanded {
if delta > 60 { drawerExpanded = false }
} else {
if delta < -40 { drawerExpanded = true }
}
dragOffset = 0
}
}
}
// MARK: - Map
private var mapLayer: some View {
Map(position: $position) {
// Completed + currently-animating arcs
ForEach(schedule.segments) { seg in
if let visibleCoords = seg.coordsVisible(at: progress) {
let isMostRecent = seg.id == schedule.mostRecentId
MapPolyline(coordinates: visibleCoords)
.stroke(
isMostRecent ? Color.yellow : HistoryStyle.runwayOrange,
style: StrokeStyle(
lineWidth: isMostRecent ? 3.0 : 2.0,
lineCap: .round,
lineJoin: .round
)
)
}
}
// In-flight plane icons (only the segments currently animating).
// The plane scales 0 1 0 across its flight: takes off
// tiny, hits full size at the apex of the journey, lands
// small again. Reads like a real plane disappearing into
// the horizon and reappearing.
ForEach(schedule.segments) { seg in
if let head = seg.head(at: progress) {
let scale = seg.planeScale(at: progress)
Annotation("", coordinate: head.coord) {
Image(systemName: "airplane")
.font(.system(size: 16, weight: .black))
.foregroundStyle(.white)
.padding(5)
.background(HistoryStyle.runwayOrange, in: Circle())
.shadow(color: .black.opacity(0.4), radius: 3, y: 1)
.rotationEffect(.degrees(head.bearing - 90))
.scaleEffect(scale)
.opacity(scale)
}
.annotationTitles(.hidden)
}
}
// Airports pop in on first arrival
ForEach(schedule.airports) { ap in
if progress >= ap.litAt {
Annotation(ap.iata, coordinate: ap.coord) {
AirportPulseDot(
size: ap.dotSize,
timeSinceLit: progress - ap.litAt,
isSelected: filters.airports.contains(ap.iata)
)
.onTapGesture {
selectedAirportSheet = AirportSheet(iata: ap.iata)
}
}
.annotationTitles(.hidden)
}
}
}
.mapStyle(.standard(elevation: .flat, emphasis: .muted))
}
// MARK: - Bottom drawer (sheet)
@ViewBuilder
private var mapDrawer: some View {
VStack(spacing: 0) {
drawerHandle
drawerPeek
if drawerExpanded {
Divider().opacity(0.5)
drawerExpandedContent
}
Spacer(minLength: 0)
}
.background(
UnevenRoundedRectangle(
topLeadingRadius: 22,
topTrailingRadius: 22
)
.fill(.regularMaterial)
.ignoresSafeArea(edges: .bottom)
)
.shadow(color: .black.opacity(0.15), radius: 10, y: -2)
}
private var drawerHandle: some View {
Capsule()
.fill(.gray.opacity(0.4))
.frame(width: 36, height: 5)
.padding(.top, 8)
.padding(.bottom, 4)
.frame(maxWidth: .infinity)
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) {
drawerExpanded.toggle()
}
}
}
private var drawerPeek: some View {
HStack(spacing: 14) {
VStack(alignment: .leading, spacing: 1) {
Text(filters.isEmpty ? "ALL TIME" : "FILTERED")
.font(.system(size: 10, weight: .heavy))
.tracking(2)
.foregroundStyle(HistoryStyle.runwayOrange)
Text("\(flights.count) flights · \(totalMilesString)")
.font(.system(size: 17, weight: .heavy))
.foregroundStyle(.primary)
}
Spacer(minLength: 12)
// Animation progress
ZStack {
Circle()
.stroke(.gray.opacity(0.18), lineWidth: 3)
Circle()
.trim(from: 0, to: progress)
.stroke(HistoryStyle.runwayOrange,
style: StrokeStyle(lineWidth: 3, lineCap: .round))
.rotationEffect(.degrees(-90))
if progress >= 1 {
Image(systemName: "checkmark")
.font(.system(size: 11, weight: .black))
.foregroundStyle(HistoryStyle.runwayOrange)
}
}
.frame(width: 24, height: 24)
Button { restart() } label: {
Image(systemName: "arrow.clockwise")
.font(.system(size: 14, weight: .bold))
.foregroundStyle(.white)
.frame(width: 36, height: 36)
.background(HistoryStyle.runwayOrange, in: Circle())
}
.buttonStyle(.plain)
}
.padding(.horizontal, 18)
.padding(.vertical, 12)
}
@ViewBuilder
private var drawerExpandedContent: some View {
VStack(alignment: .leading, spacing: 14) {
if !filters.years.isEmpty || !filters.airlines.isEmpty || !filters.airports.isEmpty {
Text("FILTERS")
.font(.system(size: 10, weight: .heavy))
.tracking(2)
.foregroundStyle(HistoryStyle.runwayOrange)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(Array(filters.years).sorted(by: >), id: \.self) { y in
drawerChip("\(y)") { filters.years.remove(y) }
}
ForEach(Array(filters.airlines).sorted(), id: \.self) { a in
drawerChip(a) { filters.airlines.remove(a) }
}
ForEach(Array(filters.airports).sorted(), id: \.self) { a in
drawerChip(a) { filters.airports.remove(a) }
}
}
}
}
// Quick stats grid
HStack(spacing: 12) {
statTile(label: "Airports", value: "\(schedule.airports.count)")
statTile(label: "Routes", value: "\(uniqueRoutes)")
statTile(label: "Years", value: "\(yearSpan)")
}
.padding(.top, 4)
Spacer(minLength: 12)
}
.padding(.horizontal, 18)
.padding(.vertical, 14)
}
private func drawerChip(_ label: String, onRemove: @escaping () -> Void) -> some View {
HStack(spacing: 4) {
Text(label).font(.system(size: 11, weight: .bold).monospaced())
Image(systemName: "xmark").font(.system(size: 8, weight: .bold))
}
.foregroundStyle(.white)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(HistoryStyle.runwayOrange, in: Capsule())
.onTapGesture(perform: onRemove)
}
private func statTile(label: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(label.uppercased())
.font(.system(size: 10, weight: .heavy))
.tracking(1.2)
.foregroundStyle(.secondary)
Text(value)
.font(.system(size: 22, weight: .heavy).monospacedDigit())
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
}
// MARK: - Derived
private var uniqueRoutes: Int {
Set(flights.map { [$0.departureIATA, $0.arrivalIATA].sorted().joined(separator: "") }).count
}
private var yearSpan: Int {
let years = Set(flights.map { Calendar.current.component(.year, from: $0.flightDate) })
return years.count
}
private var totalMilesString: String {
let total = flights.reduce(0) { $0 + (store.distanceMiles(for: $1) ?? 0) }
let f = NumberFormatter()
f.numberStyle = .decimal
return (f.string(from: NSNumber(value: total)) ?? "\(total)") + " mi"
}
// MARK: - Lifecycle
/// Build the schedule and snap the camera to fit before the
/// animation begins. Called on appear and on every filter change.
private func reset() {
schedule = AnimationSchedule.build(flights: flights, database: database, totalDuration: Self.totalDuration)
if let region = schedule.fitRegion {
position = .region(region)
}
progress = 0
animationKey += 1
}
private func restart() {
progress = 0
animationKey += 1
}
/// Drive `progress` from 0 1 over `totalDuration` seconds. Uses
/// the system clock so the animation finishes in real time even
/// if a frame is dropped.
private func runAnimation() async {
let start = Date()
let dur = Self.totalDuration
while !Task.isCancelled {
let elapsed = Date().timeIntervalSince(start)
if elapsed >= dur {
progress = 1
return
}
progress = max(progress, elapsed / dur)
// ~60fps tick
try? await Task.sleep(nanoseconds: 16_666_666)
}
}
}
// MARK: - Animation schedule
//
// Plain value types. Built once per filter change. No I/O, no closure
// captures. Reads cleanly from the view's `body` because `coordsVisible`
// and `head` are O(1) sliced lookups against precomputed arrays.
struct AnimationSchedule {
var segments: [FlightSegment] = []
var airports: [AirportLight] = []
var fitRegion: MKCoordinateRegion?
var mostRecentId: UUID?
static let empty = AnimationSchedule()
/// Build the per-flight start/end progress windows and the per-
/// airport "lights up at" progress from the user's filtered set.
static func build(flights: [LoggedFlight], database: AirportDatabase, totalDuration: TimeInterval) -> AnimationSchedule {
let sorted = flights.sorted { $0.flightDate < $1.flightDate }
let resolved: [(flight: LoggedFlight, dep: CLLocationCoordinate2D, arr: CLLocationCoordinate2D)] =
sorted.compactMap { f in
guard let dep = database.airport(byIATA: f.departureIATA),
let arr = database.airport(byIATA: f.arrivalIATA)
else { return nil }
return (f, dep.coordinate, arr.coordinate)
}
guard !resolved.isEmpty else { return .empty }
// Per-flight slice: we want a ~4s total with the planes
// overlapping just enough that one starts before the previous
// finishes (otherwise the map looks empty between flights).
let n = Double(resolved.count)
// Each plane traverses for `flightDur`. We stagger starts by
// `(1 - flightDur) / (n - 1)` so the last finishes at 1.0.
let flightDur: Double = min(0.18, max(0.06, 1.0 / max(n, 1.0) * 2.5))
let stagger: Double = n > 1 ? (1.0 - flightDur) / (n - 1) : 0
var segments: [FlightSegment] = []
var firstArrivalForAirport: [String: Double] = [:] // IATA litAt progress
var airportCounts: [String: Int] = [:]
var airportCoords: [String: CLLocationCoordinate2D] = [:]
for (i, item) in resolved.enumerated() {
let startP = Double(i) * stagger
let endP = startP + flightDur
let coords = greatCircle(from: item.dep, to: item.arr, segments: 40)
let seg = FlightSegment(
id: UUID(),
flightId: item.flight.id,
coords: coords,
startProgress: startP,
endProgress: endP
)
segments.append(seg)
// Departure lights up at startP (when its plane takes off);
// arrival at endP (when the plane lands).
let depIata = item.flight.departureIATA
let arrIata = item.flight.arrivalIATA
firstArrivalForAirport[depIata] = min(firstArrivalForAirport[depIata] ?? .infinity, startP)
firstArrivalForAirport[arrIata] = min(firstArrivalForAirport[arrIata] ?? .infinity, endP)
airportCounts[depIata, default: 0] += 1
airportCounts[arrIata, default: 0] += 1
airportCoords[depIata] = item.dep
airportCoords[arrIata] = item.arr
}
let airports = firstArrivalForAirport.compactMap { iata, lit -> AirportLight? in
guard let coord = airportCoords[iata] else { return nil }
let count = airportCounts[iata] ?? 1
return AirportLight(
iata: iata,
coord: coord,
litAt: lit,
dotSize: dotSizeFor(count: count)
)
}
// Fit region union of all dep + arr coordinates, padded.
let coords = resolved.flatMap { [$0.dep, $0.arr] }
let fit = boundingRegion(for: coords)
return AnimationSchedule(
segments: segments,
airports: airports,
fitRegion: fit,
mostRecentId: segments.last?.id
)
}
private static func dotSizeFor(count: Int) -> CGFloat {
// log scale, 9pt 22pt
let v = log(Double(count) + 1) * 4 + 8
return CGFloat(min(22, max(9, v)))
}
/// Bounding `MKCoordinateRegion` that fits all coords with padding.
/// Adds ~20% margin to span so dots don't pin to the edges.
private static func boundingRegion(for coords: [CLLocationCoordinate2D]) -> MKCoordinateRegion? {
guard !coords.isEmpty else { return nil }
let lats = coords.map { $0.latitude }
let lons = coords.map { $0.longitude }
let minLat = lats.min() ?? 0, maxLat = lats.max() ?? 0
let minLon = lons.min() ?? 0, maxLon = lons.max() ?? 0
let center = CLLocationCoordinate2D(
latitude: (minLat + maxLat) / 2,
longitude: (minLon + maxLon) / 2
)
let latDelta = max(0.5, (maxLat - minLat) * 1.4)
let lonDelta = max(0.5, (maxLon - minLon) * 1.4)
return MKCoordinateRegion(
center: center,
span: MKCoordinateSpan(latitudeDelta: latDelta, longitudeDelta: lonDelta)
)
}
/// 41-point great-circle sample. MapKit doesn't draw GC paths
/// natively, so we approximate with straight segments.
private static func greatCircle(from a: CLLocationCoordinate2D, to b: CLLocationCoordinate2D, segments: Int) -> [CLLocationCoordinate2D] {
let lat1 = a.latitude * .pi / 180
let lon1 = a.longitude * .pi / 180
let lat2 = b.latitude * .pi / 180
let lon2 = b.longitude * .pi / 180
let d = 2 * asin(sqrt(
pow(sin((lat2 - lat1) / 2), 2)
+ cos(lat1) * cos(lat2) * pow(sin((lon2 - lon1) / 2), 2)
))
if d == 0 { return [a, b] }
var out: [CLLocationCoordinate2D] = []
out.reserveCapacity(segments + 1)
for i in 0...segments {
let f = Double(i) / Double(segments)
let A = sin((1 - f) * d) / sin(d)
let B = sin(f * d) / sin(d)
let x = A * cos(lat1) * cos(lon1) + B * cos(lat2) * cos(lon2)
let y = A * cos(lat1) * sin(lon1) + B * cos(lat2) * sin(lon2)
let z = A * sin(lat1) + B * sin(lat2)
let lat = atan2(z, sqrt(x * x + y * y))
let lon = atan2(y, x)
out.append(CLLocationCoordinate2D(latitude: lat * 180 / .pi, longitude: lon * 180 / .pi))
}
return out
}
}
/// One animatable flight segment. `coordsVisible` slices the pre-
/// computed great-circle array based on the global animation
/// progress; `head` gives the current plane position.
struct FlightSegment: Identifiable, Hashable {
let id: UUID
let flightId: UUID
let coords: [CLLocationCoordinate2D]
let startProgress: Double
let endProgress: Double
/// Returns the visible portion of the arc at the given global
/// progress. nil if the flight hasn't started yet.
func coordsVisible(at progress: Double) -> [CLLocationCoordinate2D]? {
if progress < startProgress { return nil }
if progress >= endProgress { return coords }
let local = (progress - startProgress) / max(0.0001, endProgress - startProgress)
let cutoff = max(1, Int(local * Double(coords.count - 1)) + 1)
return Array(coords.prefix(cutoff))
}
struct Head {
let coord: CLLocationCoordinate2D
let bearing: Double
}
/// Returns the moving plane's current coordinate and travel
/// bearing. nil when the flight isn't in-flight at this moment.
func head(at progress: Double) -> Head? {
guard progress >= startProgress, progress < endProgress else { return nil }
let local = (progress - startProgress) / max(0.0001, endProgress - startProgress)
let i = max(0, min(coords.count - 1, Int(local * Double(coords.count - 1))))
let here = coords[i]
let next = coords[min(coords.count - 1, i + 1)]
return Head(coord: here, bearing: bearing(from: here, to: next))
}
/// Plane scale across the flight 0 at takeoff, 1.0 at the
/// midpoint, 0 at landing. Half-sine curve. Returns 0 when the
/// flight isn't currently animating.
func planeScale(at progress: Double) -> CGFloat {
guard progress >= startProgress, progress < endProgress else { return 0 }
let local = (progress - startProgress) / max(0.0001, endProgress - startProgress)
return CGFloat(sin(local * .pi))
}
private func bearing(from a: CLLocationCoordinate2D, to b: CLLocationCoordinate2D) -> Double {
let lat1 = a.latitude * .pi / 180
let lat2 = b.latitude * .pi / 180
let dLon = (b.longitude - a.longitude) * .pi / 180
let y = sin(dLon) * cos(lat2)
let x = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon)
let theta = atan2(y, x)
return (theta * 180 / .pi + 360).truncatingRemainder(dividingBy: 360)
}
func hash(into hasher: inout Hasher) { hasher.combine(id) }
static func == (lhs: FlightSegment, rhs: FlightSegment) -> Bool { lhs.id == rhs.id }
}
/// One airport on the map. `litAt` is the progress value at which the
/// dot first appears (when the first plane to/from this airport
/// lands or takes off).
struct AirportLight: Identifiable, Hashable {
let iata: String
let coord: CLLocationCoordinate2D
let litAt: Double
let dotSize: CGFloat
var id: String { iata }
func hash(into hasher: inout Hasher) { hasher.combine(iata) }
static func == (lhs: AirportLight, rhs: AirportLight) -> Bool { lhs.iata == rhs.iata }
}
// MARK: - Airport dot with light-up pulse
/// Tappable airport dot with a brief scale-up "pop" the first time it
/// appears, then settles. `timeSinceLit` is the global progress
/// elapsed since this airport first lit up, so we can drive a
/// transient pulse without per-dot @State.
private struct AirportPulseDot: View {
let size: CGFloat
let timeSinceLit: Double
let isSelected: Bool
/// Pulse window: bump scale up for ~0.05 progress ( 200ms at our
/// 4s sweep), then ease back to 1.0.
private var pulseScale: CGFloat {
let window: Double = 0.05
guard timeSinceLit < window else { return 1.0 }
let frac = timeSinceLit / window
// half-cosine ease 1.6 at start, 1.0 at end
let eased = 1.0 + 0.6 * cos(frac * .pi / 2)
return CGFloat(eased)
}
var body: some View {
Circle()
.fill(isSelected ? Color.yellow : HistoryStyle.runwayOrange)
.frame(width: size, height: size)
.overlay(Circle().stroke(.white, lineWidth: 2))
.shadow(color: .black.opacity(0.4), radius: 3, y: 1)
.scaleEffect(pulseScale)
.contentShape(Circle().inset(by: -10))
}
}
+87
View File
@@ -0,0 +1,87 @@
import SwiftUI
/// Single row in the history list. Loads the airframe photo
/// asynchronously and renders a thumb on the left, flight identity in
/// the middle, date on the right.
struct HistoryRowView: View {
let flight: LoggedFlight
let database: AirportDatabase
@State private var photo: AircraftPhotoService.Photo?
var body: some View {
HStack(spacing: 12) {
thumbnail
.frame(width: 64, height: 48)
.clipShape(RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Text(flight.flightLabel)
.font(.subheadline.weight(.bold).monospaced())
.foregroundStyle(FlightTheme.textPrimary)
if let type = flight.aircraftType {
Text("· \(type)")
.font(.caption.monospaced())
.foregroundStyle(FlightTheme.textTertiary)
}
}
HStack(spacing: 6) {
Text(flight.departureIATA)
.font(.caption.weight(.semibold).monospaced())
.foregroundStyle(FlightTheme.textSecondary)
Image(systemName: "arrow.right")
.font(.caption2)
.foregroundStyle(FlightTheme.textTertiary)
Text(flight.arrivalIATA)
.font(.caption.weight(.semibold).monospaced())
.foregroundStyle(FlightTheme.textSecondary)
}
}
Spacer()
Text(shortDate(flight.flightDate))
.font(.caption.monospaced())
.foregroundStyle(FlightTheme.textTertiary)
}
.padding(.vertical, 4)
.task(id: flight.registration ?? flight.id.uuidString) {
guard let reg = flight.registration, !reg.isEmpty else { return }
photo = await AircraftPhotoService.shared.photo(
registration: reg,
icao24: ""
)
}
}
@ViewBuilder
private var thumbnail: some View {
if let url = photo?.thumbnailURL {
AsyncImage(url: url) { phase in
switch phase {
case .success(let img):
img.resizable().aspectRatio(contentMode: .fill)
default:
placeholder
}
}
} else {
placeholder
}
}
private var placeholder: some View {
ZStack {
FlightTheme.cardBackground
Image(systemName: "airplane")
.foregroundStyle(FlightTheme.textTertiary)
}
}
private func shortDate(_ d: Date) -> String {
let f = DateFormatter()
f.dateFormat = "MMM d"
return f.string(from: d)
}
}
+965
View File
@@ -0,0 +1,965 @@
import SwiftUI
import SwiftData
/// History tab redesigned as a "passport" experience.
///
/// Stacked hero cards at the top (current-year passport, all-time
/// passport, most-flown airframe), a horizontal year tab strip that
/// scopes everything, and a flight feed below. Sort + filter + search
/// + add affordances all live in the toolbar.
struct HistoryView: View {
let database: AirportDatabase
let routeExplorer: RouteExplorerClient
let openSky: OpenSkyClient
@Environment(\.modelContext) private var modelContext
@Environment(\.colorScheme) private var scheme
@Query(sort: \LoggedFlight.flightDate, order: .reverse)
private var flights: [LoggedFlight]
@State private var filters: HistoryFilters = .init()
@State private var sort: HistorySort = .newestFirst
@State private var selectedYear: Int? = nil // nil = ALL
@State private var standbyOnly: Bool = false
/// Cached output of the standby stats pipeline. Bug F5 we used to
/// recompute this from SwiftData on every body invalidation, which
/// got expensive once history grew. The cache is refreshed in a
/// `.task` keyed on `flights.count` so it only re-fires when the
/// flight set actually changes.
@State private var standbyRate: StandbyRate = .empty
/// Cached scoped + sorted flight list. Bug Q8 we used to recompute
/// this on every body call. The cache is refreshed in a `.task`
/// keyed on `pipelineKey` so it only re-runs when an input that
/// affects the pipeline actually changes.
@State private var scopedFlights: [LoggedFlight] = []
@State private var showingAdd = false
@State private var showingPassport = false
@State private var showingMap = false
@State private var showingAircraftStats = false
@State private var showingCalendarImport = false
@State private var showingCSVImport = false
@State private var showingYearInReview = false
@State private var showingFilterSheet = false
var body: some View {
let store = FlightHistoryStore(context: modelContext, airportDatabase: database)
let scoped = scopedFlights
let stats = StatsEngine(store: store, database: database, flights: scoped)
ScrollView {
LazyVStack(spacing: 0, pinnedViews: []) {
titleHeader
standbyStatsCard
.padding(.horizontal, 16)
.padding(.top, 12)
standbyFilterToggle
.padding(.horizontal, 16)
.padding(.top, 8)
YearTabStrip(years: yearsList, selection: $selectedYear)
.padding(.vertical, 12)
if filters.isEmpty {
heroDeck(store: store, stats: stats)
.padding(.horizontal, 16)
.padding(.bottom, 8)
}
if !filters.isEmpty {
activeChips
.padding(.horizontal, 16)
.padding(.bottom, 8)
}
if scoped.isEmpty {
emptyState
} else {
flightFeed(scoped, store: store)
}
Spacer(minLength: 80)
}
}
.background(HistoryStyle.background(scheme).ignoresSafeArea())
.navigationTitle("")
.navigationBarTitleDisplayMode(.inline)
// History data is purely local SwiftData + CloudKit. @Query
// auto-emits on every store mutation, and CloudKit sync runs
// automatically when the app is foregrounded. A user-driven
// `.refreshable` wouldn't do anything that isn't already happening
// we don't add the affordance to avoid the misleading "tap to
// force a refresh" expectation.
.searchable(text: $filters.query, prompt: "Flight #, airport, route")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
ForEach(HistorySort.allCases) { option in
Button {
sort = option
} label: {
if sort == option {
Label(option.rawValue, systemImage: "checkmark")
} else {
Label(option.rawValue, systemImage: option.systemImage)
}
}
}
} label: {
Image(systemName: "arrow.up.arrow.down")
}
}
ToolbarItem(placement: .topBarTrailing) {
Button {
showingFilterSheet = true
} label: {
Image(systemName: filters.activeCount > 0 ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle")
}
}
ToolbarItem(placement: .topBarTrailing) {
Menu {
Section("Add") {
Button { showingAdd = true } label: { Label("Add manually", systemImage: "plus") }
Button { showingCalendarImport = true } label: { Label("Scan Calendar", systemImage: "calendar") }
Button { showingCSVImport = true } label: { Label("Import CSV…", systemImage: "doc.text") }
}
Section("Explore") {
Button { showingPassport = true } label: { Label("Passport", systemImage: "book.closed") }
Button {
// Carry the year-strip scope into the map's
// filter binding so the map opens showing
// the same year the user was browsing.
if let y = selectedYear { filters.years = [y] }
showingMap = true
} label: { Label("Route map", systemImage: "map.fill") }
Button { showingAircraftStats = true } label: { Label("Aircraft stats", systemImage: "airplane.circle") }
Button { showingYearInReview = true } label: { Label("Year in Review", systemImage: "sparkles") }
}
} label: {
Image(systemName: "plus.circle.fill")
.foregroundStyle(HistoryStyle.runwayOrange)
}
}
}
.sheet(isPresented: $showingAdd) {
AddFlightView(routeExplorer: routeExplorer, database: database, store: store, prefill: nil)
}
.sheet(isPresented: $showingPassport) {
NavigationStack {
PassportView(stats: stats, allFlights: flights, database: database, store: store, selectedYear: $selectedYear)
}
}
.sheet(isPresented: $showingMap) {
NavigationStack {
HistoryRouteMapView(
allFlights: flights,
database: database,
openSky: openSky,
store: store,
filters: $filters
)
}
}
.sheet(isPresented: $showingAircraftStats) {
NavigationStack {
AircraftStatsView(allFlights: flights, store: store, routeExplorer: routeExplorer)
}
}
.sheet(isPresented: $showingCalendarImport) {
CalendarImportView(routeExplorer: routeExplorer, database: database, store: store)
}
.sheet(isPresented: $showingCSVImport) {
ImportCSVView(store: store, routeExplorer: routeExplorer)
}
.sheet(isPresented: $showingYearInReview) {
YearInReviewView(stats: stats, year: selectedYear ?? Calendar.current.component(.year, from: Date()))
}
.sheet(isPresented: $showingFilterSheet) {
HistoryFilterSheet(allFlights: flights, filters: $filters)
.presentationDetents([.medium, .large])
}
// Bug F5 / F3 refresh the standby stats cache when the flight
// set OR any flight's standbyOutcome changes. Keying on
// `flightContentSignature` covers in-place edits made through
// HistoryDetailView (which writes to the @Model directly and
// doesn't bump `flights.count`).
.task(id: flightContentSignature) {
standbyRate = StandbyStatsService().personalRate(
carrier: nil,
origin: nil,
dest: nil,
context: modelContext
)
}
// Bug Q8 refresh the scoped + sorted flight cache when any
// pipeline input changes. The key is a string so SwiftUI's
// Equatable comparison stays cheap.
.task(id: pipelineKey) {
let store = FlightHistoryStore(context: modelContext, airportDatabase: database)
scopedFlights = computeScopedFlights(store: store)
}
}
// MARK: - Pipeline
private var yearsList: [Int] {
let cal = Calendar.current
let ys = Set(flights.map { cal.component(.year, from: $0.flightDate) })
return ys.sorted(by: >)
}
/// Composite key for the scoped-flight pipeline. `.task(id:)` only
/// re-fires when this string changes, so we bundle every input that
/// affects the output: year scope, active-filter count, the
/// standby-only toggle, the chosen sort, the SwiftData row count,
/// AND a content signature over per-flight fields whose edits should
/// re-invalidate the pipeline (standby outcome + the flight's id).
/// Without the content signature, editing a flight's standbyOutcome
/// in the detail view wouldn't change `flights.count`, the task
/// wouldn't re-fire, and the user would see a stale list.
private var pipelineKey: String {
let year = selectedYear.map(String.init) ?? "ALL"
return [
year,
String(filters.activeCount),
filters.query,
standbyOnly ? "S" : "_",
sort.rawValue,
String(flights.count),
flightContentSignature
].joined(separator: "|")
}
/// Lightweight fingerprint over the per-flight fields the History
/// pipeline cares about. Re-computed on every `body` invalidation,
/// but the work is just an XOR/sum over ~1 hashable per flight so
/// even a 5k-flight log finishes well under a millisecond. Includes
/// any field that, when edited via HistoryDetailView, should
/// trigger a UI refresh.
private var flightContentSignature: String {
var hasher = Hasher()
for flight in flights {
hasher.combine(flight.id)
hasher.combine(flight.standbyOutcome)
}
return String(hasher.finalize())
}
private func computeScopedFlights(store: FlightHistoryStore) -> [LoggedFlight] {
var scoped = flights
if let y = selectedYear {
let cal = Calendar.current
scoped = scoped.filter { cal.component(.year, from: $0.flightDate) == y }
}
scoped = scoped.filter { filters.matches($0) }
if standbyOnly {
scoped = scoped.filter { $0.wasStandby }
}
let cmp = sort.comparator { store.distanceMiles(for: $0) ?? 0 }
return scoped.sorted(by: cmp)
}
// MARK: - Title
private var titleHeader: some View {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 2) {
Text("PASSPORT")
.font(.system(size: 34, weight: .black))
.tracking(-0.5)
.foregroundStyle(HistoryStyle.ink(scheme))
if !flights.isEmpty {
Text("\(flights.count) flights · \(years(of: flights)) years")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(HistoryStyle.inkSecondary(scheme))
}
}
Spacer()
Image(systemName: "airplane")
.font(.system(size: 22, weight: .heavy))
.foregroundStyle(HistoryStyle.runwayOrange)
.rotationEffect(.degrees(-45))
}
.padding(.horizontal, 16)
.padding(.top, 8)
}
private func years(of list: [LoggedFlight]) -> Int {
let yrs = Set(list.map { Calendar.current.component(.year, from: $0.flightDate) })
return yrs.count
}
// MARK: - Standby stats card
/// Compact "Standby stats" summary using StandbyStatsService over the
/// user's full LoggedFlight history (no carrier/route narrowing). Hidden
/// when the user has no recorded standby attempts.
///
/// Bug F5 reads from the cached `standbyRate` @State; population
/// happens in the `.task` modifier on `body`, keyed on `flights.count`.
@ViewBuilder
private var standbyStatsCard: some View {
let stats = standbyRate
if stats.attempts > 0 {
HStack(alignment: .center, spacing: 14) {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Image(systemName: "figure.stand.line.dotted.figure.stand")
.font(.system(size: 11, weight: .heavy))
.foregroundStyle(FlightTheme.accent)
Text("STANDBY STATS")
.font(.system(size: 10, weight: .heavy))
.tracking(1.4)
.foregroundStyle(FlightTheme.textSecondary)
}
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text("\(stats.attempts)")
.font(.system(size: 22, weight: .heavy).monospacedDigit())
.foregroundStyle(FlightTheme.textPrimary)
Text(stats.attempts == 1 ? "attempt" : "attempts")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(FlightTheme.textSecondary)
}
}
Spacer(minLength: 0)
standbyStatPill(
value: "\(stats.made)/\(stats.attempts)",
label: "MADE",
tint: FlightTheme.onTime
)
standbyStatPill(
value: "\(stats.bumped)",
label: "BUMPED",
tint: FlightTheme.cancelled
)
standbyStatPill(
value: percentString(stats.rate),
label: "RATE",
tint: FlightTheme.accent
)
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(FlightTheme.cardBackground, in: RoundedRectangle(cornerRadius: FlightTheme.cardCornerRadius))
.overlay(
RoundedRectangle(cornerRadius: FlightTheme.cardCornerRadius)
.stroke(FlightTheme.accent.opacity(0.15), lineWidth: 0.5)
)
}
}
private func standbyStatPill(value: String, label: String, tint: Color) -> some View {
VStack(spacing: 2) {
Text(value)
.font(.system(size: 14, weight: .heavy).monospacedDigit())
.foregroundStyle(tint)
.lineLimit(1)
.minimumScaleFactor(0.7)
Text(label)
.font(.system(size: 9, weight: .bold))
.tracking(0.8)
.foregroundStyle(FlightTheme.textTertiary)
}
.frame(minWidth: 48)
}
private func percentString(_ rate: Double) -> String {
let pct = Int((rate * 100).rounded())
return "\(pct)%"
}
// MARK: - Standby-only filter toggle
/// Inline toggle that constrains the feed to flights where wasStandby
/// is true. Sits between the hero stats and the year strip so it's
/// always reachable without opening the filter sheet.
private var standbyFilterToggle: some View {
HStack(spacing: 8) {
Image(systemName: standbyOnly ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(standbyOnly ? FlightTheme.accent : FlightTheme.textTertiary)
Toggle(isOn: $standbyOnly) {
Text("Standby only")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(FlightTheme.textPrimary)
}
.tint(FlightTheme.accent)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(FlightTheme.cardBackground, in: Capsule())
.overlay(
Capsule()
.stroke(standbyOnly ? FlightTheme.accent.opacity(0.4) : Color.clear, lineWidth: 1)
)
.frame(maxWidth: .infinity, alignment: .leading)
}
// MARK: - Hero deck
@ViewBuilder
private func heroDeck(store: FlightHistoryStore, stats: StatsEngine) -> some View {
VStack(spacing: 12) {
// 1) Scoped passport either current year or all-time
let year = selectedYear ?? Calendar.current.component(.year, from: Date())
let yearFlights = stats.flights(for: year)
let yearStats = StatsEngine(store: store, database: database, flights: yearFlights)
Button { showingPassport = true } label: {
if selectedYear == nil {
HeroStatCard(
label: "ALL TIME PASSPORT",
value: numberString(stats.totalFlights),
subtitle: "\(stats.shortDistance) miles · \(stats.shortDuration)h in air",
variant: .orange
) {
HStack(spacing: 14) {
kvp(value: "\(stats.uniqueAirports)", label: "airports")
kvp(value: "\(stats.uniqueAirlines)", label: "airlines")
kvp(value: "\(stats.uniqueCountries)", label: "countries")
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 13, weight: .bold))
.foregroundStyle(.white.opacity(0.6))
}
}
} else {
HeroStatCard(
label: "\(year) PASSPORT",
value: numberString(yearStats.totalFlights),
subtitle: "\(yearStats.shortDistance) miles · \(yearStats.shortDuration)h aloft",
variant: .orange
) {
HStack(spacing: 14) {
kvp(value: "\(yearStats.uniqueAirports)", label: "airports")
kvp(value: "\(yearStats.uniqueAirlines)", label: "airlines")
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 13, weight: .bold))
.foregroundStyle(.white.opacity(0.6))
}
}
}
}
.buttonStyle(.plain)
// 2) Most-flown aircraft, if we know it
mostFlownCard(stats: stats)
// 3) Quick links row
quickLinks
}
}
@ViewBuilder
private func mostFlownCard(stats: StatsEngine) -> some View {
let typeCounts = Dictionary(grouping: stats.flights.compactMap { $0.aircraftType }) { $0 }
.mapValues(\.count)
if let top = typeCounts.max(by: { $0.value < $1.value }) {
let typeName = AircraftDatabase.shared.displayName(forTypeCode: top.key)
Button { showingAircraftStats = true } label: {
HeroStatCard(
label: "MOST FLOWN AIRCRAFT",
value: typeName == top.key ? top.key : typeName,
subtitle: "\(top.value) flights",
variant: .navy
) {
HStack {
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 13, weight: .bold))
.foregroundStyle(.white.opacity(0.6))
}
}
}
.buttonStyle(.plain)
}
}
@ViewBuilder
private var quickLinks: some View {
HStack(spacing: 10) {
quickLink(title: "Map", icon: "map.fill") {
if let y = selectedYear { filters.years = [y] }
showingMap = true
}
quickLink(title: "Aircraft", icon: "airplane.circle.fill") { showingAircraftStats = true }
quickLink(title: "Year", icon: "sparkles") { showingYearInReview = true }
}
}
private func quickLink(title: String, icon: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
VStack(spacing: 6) {
Image(systemName: icon)
.font(.system(size: 18, weight: .semibold))
Text(title)
.font(.system(size: 12, weight: .semibold))
.tracking(0.5)
}
.foregroundStyle(HistoryStyle.ink(scheme))
.frame(maxWidth: .infinity, minHeight: 64)
.background(HistoryStyle.card(scheme), in: RoundedRectangle(cornerRadius: 16))
}
}
private func kvp(value: String, label: String) -> some View {
VStack(alignment: .leading, spacing: 0) {
Text(value)
.font(.system(size: 18, weight: .heavy).monospacedDigit())
.foregroundStyle(.white)
Text(label.uppercased())
.font(.system(size: 9, weight: .bold))
.tracking(0.8)
.foregroundStyle(.white.opacity(0.7))
}
}
// MARK: - Active filter chips
private var activeChips: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
if !filters.query.isEmpty {
chip("\(filters.query)", systemImage: "magnifyingglass") { filters.query = "" }
}
ForEach(Array(filters.years).sorted(by: >), id: \.self) { y in
chip("\(y)", systemImage: "calendar") { filters.years.remove(y) }
}
ForEach(Array(filters.airlines).sorted(), id: \.self) { a in
chip(AircraftRegistry.shared.lookup(icao: a)?.name ?? a, systemImage: "building.2") {
filters.airlines.remove(a)
}
}
ForEach(Array(filters.airports).sorted(), id: \.self) { a in
chip(a, systemImage: "airplane") { filters.airports.remove(a) }
}
ForEach(Array(filters.aircraftTypes).sorted(), id: \.self) { t in
chip(t, systemImage: "airplane.departure") { filters.aircraftTypes.remove(t) }
}
Button { filters = HistoryFilters() } label: {
Text("Clear")
.font(.caption.weight(.semibold))
.foregroundStyle(HistoryStyle.runwayOrange)
.padding(.horizontal, 10)
.padding(.vertical, 6)
}
}
}
}
private func chip(_ label: String, systemImage: String, onRemove: @escaping () -> Void) -> some View {
HStack(spacing: 4) {
Image(systemName: systemImage).font(.caption2)
Text(label).font(.caption.weight(.semibold))
Image(systemName: "xmark").font(.caption2)
}
.foregroundStyle(.white)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(HistoryStyle.runwayOrange, in: Capsule())
.onTapGesture(perform: onRemove)
}
// MARK: - Flight feed
@ViewBuilder
private func flightFeed(_ scoped: [LoggedFlight], store: FlightHistoryStore) -> some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
HistorySectionLabel(selectedYear == nil ? "Recent flights" : "Flights in \(selectedYear!)")
Spacer()
Text("\(scoped.count)")
.font(.system(size: 12, weight: .bold).monospacedDigit())
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
}
.padding(.horizontal, 16)
.padding(.top, 8)
ForEach(groupedFeed(scoped), id: \.key) { group in
if groupedFeed(scoped).count > 1 {
Text(group.key)
.font(.system(size: 12, weight: .bold).monospacedDigit())
.tracking(0.6)
.foregroundStyle(HistoryStyle.inkSecondary(scheme))
.padding(.horizontal, 16)
.padding(.top, 8)
}
ForEach(group.flights) { f in
NavigationLink {
HistoryDetailView(flight: f, store: store, database: database, openSky: openSky, routeExplorer: routeExplorer)
} label: {
PassportFlightRow(flight: f, database: database)
.padding(.horizontal, 16)
}
.buttonStyle(.plain)
}
}
}
}
private struct FeedGroup {
let key: String
let flights: [LoggedFlight]
}
private func groupedFeed(_ list: [LoggedFlight]) -> [FeedGroup] {
let cal = Calendar.current
switch sort {
case .newestFirst, .oldestFirst:
// When scoped to a single year, sub-group by month for
// visual rhythm; when ALL is selected, group by year.
if selectedYear != nil {
let grouped = Dictionary(grouping: list) { f -> String in
let comps = cal.dateComponents([.year, .month], from: f.flightDate)
let m = DateFormatter()
m.dateFormat = "MMMM"
return m.string(from: cal.date(from: comps) ?? f.flightDate).uppercased()
}
return grouped.map { FeedGroup(key: $0.key, flights: $0.value.sorted { sort == .newestFirst ? $0.flightDate > $1.flightDate : $0.flightDate < $1.flightDate }) }
.sorted { firstFlightDate($0.flights) > firstFlightDate($1.flights) }
} else {
let grouped = Dictionary(grouping: list) { String(cal.component(.year, from: $0.flightDate)) }
let order: (String, String) -> Bool = sort == .newestFirst ? (>) : (<)
return grouped.map { FeedGroup(key: $0.key, flights: $0.value) }
.sorted { order($0.key, $1.key) }
}
case .longestFirst, .shortestFirst, .airline, .flightNumber:
return [FeedGroup(key: "", flights: list)]
}
}
private func firstFlightDate(_ list: [LoggedFlight]) -> Date {
list.first?.flightDate ?? .distantPast
}
// MARK: - Empty state
private var emptyState: some View {
VStack(spacing: 12) {
Image(systemName: "airplane.circle")
.font(.system(size: 48))
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
Text(flights.isEmpty ? "No flights logged yet" : "No matches in \(selectedYear.map(String.init) ?? "this filter")")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(HistoryStyle.inkSecondary(scheme))
if flights.isEmpty {
Text("Tap + to add a flight, scan your calendar, or import a CSV.")
.font(.caption)
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
} else {
Button("Clear filter") {
selectedYear = nil
filters = HistoryFilters()
standbyOnly = false
}
.font(.subheadline.weight(.semibold))
.foregroundStyle(HistoryStyle.runwayOrange)
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 60)
}
private func numberString(_ n: Int) -> String {
let f = NumberFormatter()
f.numberStyle = .decimal
return f.string(from: NSNumber(value: n)) ?? "\(n)"
}
}
// MARK: - Passport-styled flight row
//
// "The Classic" boarding pass orange stub on the left, dashed
// perforation with semicircular cutouts at top and bottom, body with
// IATA route + date + meta data line. Designed in HTML mockups
// (design/boarding-pass-variants.html, variant 01) then ported here.
struct PassportFlightRow: View {
let flight: LoggedFlight
let database: AirportDatabase
@Environment(\.colorScheme) private var scheme
private let cornerRadius: CGFloat = 14
private let stubWidth: CGFloat = 88
private let punchRadius: CGFloat = 7
private let rowHeight: CGFloat = 108
var body: some View {
HStack(spacing: 0) {
stub
.frame(width: stubWidth)
body_
}
.frame(maxWidth: .infinity, minHeight: rowHeight)
.clipShape(BoardingPassShape(
cornerRadius: cornerRadius,
perforationX: stubWidth,
punchRadius: punchRadius
))
.overlay(perforationLine)
}
// MARK: - Stub
private var stub: some View {
VStack(alignment: .leading, spacing: 0) {
Text(flight.carrierIATA ?? flight.carrierICAO ?? "")
.font(.system(size: 9, weight: .heavy).monospaced())
.tracking(2.2)
.foregroundStyle(.white.opacity(0.85))
Spacer(minLength: 4)
Text(paddedFlightNumber)
.font(.system(size: 28, weight: .heavy).monospaced())
.foregroundStyle(.white)
.lineLimit(1)
.minimumScaleFactor(0.7)
.kerning(-0.6)
Spacer(minLength: 6)
BarcodeStripe()
.frame(height: 12)
.opacity(0.95)
}
.padding(.horizontal, 12)
.padding(.vertical, 14)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.background(
LinearGradient(
colors: [HistoryStyle.runwayOrange, HistoryStyle.runwayOrangeDeep],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
}
/// "0007" flight number zero-padded to 4 digits when it parses
/// as an int, else just the raw string.
private var paddedFlightNumber: String {
guard let num = flight.flightNumber, let i = Int(num) else {
return flight.flightNumber ?? ""
}
return String(format: "%04d", i)
}
// MARK: - Body
private var body_: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(flight.departureIATA.isEmpty ? "" : flight.departureIATA)
.font(.system(size: 24, weight: .heavy).monospaced())
.kerning(-0.5)
.foregroundStyle(HistoryStyle.ink(scheme))
Text("")
.font(.system(size: 13, weight: .black))
.foregroundStyle(HistoryStyle.runwayOrange)
Text(flight.arrivalIATA.isEmpty ? "" : flight.arrivalIATA)
.font(.system(size: 24, weight: .heavy).monospaced())
.kerning(-0.5)
.foregroundStyle(HistoryStyle.ink(scheme))
}
Text(stubDate)
.font(.system(size: 10, weight: .heavy).monospaced())
.tracking(1.8)
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
Spacer(minLength: 6)
metaRow
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(HistoryStyle.card(scheme))
}
private var stubDate: String {
let f = DateFormatter()
f.dateFormat = "dd MMM yy"
return f.string(from: flight.flightDate).uppercased()
}
/// Bottom-of-card metadata line: `EQP B737 · TAIL N7747C · MI 239`
private var metaRow: some View {
HStack(spacing: 14) {
metaItem(label: "EQP", value: flight.aircraftType)
metaItem(label: "TAIL", value: flight.registration)
metaItem(label: "MI", value: distanceValue)
Spacer(minLength: 0)
}
}
private var distanceValue: String? {
// We don't have the store passed in here, so we recompute the
// distance from the airport database directly.
guard let dep = database.airport(byIATA: flight.departureIATA),
let arr = database.airport(byIATA: flight.arrivalIATA)
else { return nil }
let dLat = (arr.coordinate.latitude - dep.coordinate.latitude) * .pi / 180
let dLon = (arr.coordinate.longitude - dep.coordinate.longitude) * .pi / 180
let lat1 = dep.coordinate.latitude * .pi / 180
let lat2 = arr.coordinate.latitude * .pi / 180
let a = sin(dLat / 2) * sin(dLat / 2)
+ cos(lat1) * cos(lat2) * sin(dLon / 2) * sin(dLon / 2)
let c = 2 * atan2(sqrt(a), sqrt(1 - a))
let km = 6371.0 * c
let mi = km / 1.609344
return mi >= 1 ? "\(Int(mi.rounded()))" : nil
}
private func metaItem(label: String, value: String?) -> some View {
HStack(spacing: 4) {
Text(label)
.font(.system(size: 9, weight: .bold).monospaced())
.tracking(1.0)
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
Text(value ?? "")
.font(.system(size: 10, weight: .heavy).monospaced())
.foregroundStyle(HistoryStyle.ink(scheme))
}
}
// MARK: - Perforation
/// Vertical dashed line drawn between stub and body, with a small
/// inset top and bottom so it stops short of the punch cutouts.
private var perforationLine: some View {
GeometryReader { geo in
Path { path in
path.move(to: CGPoint(x: stubWidth, y: punchRadius + 2))
path.addLine(to: CGPoint(x: stubWidth, y: geo.size.height - punchRadius - 2))
}
.stroke(
HistoryStyle.inkTertiary(scheme),
style: StrokeStyle(lineWidth: 1, dash: [3, 3])
)
}
.allowsHitTesting(false)
}
}
// MARK: - Boarding pass shape
/// Rounded rectangle with two semicircular cutouts (top + bottom) at
/// the perforation column the visual hallmark of a boarding pass.
/// Drawn clockwise starting from the top-left corner.
struct BoardingPassShape: Shape {
let cornerRadius: CGFloat
let perforationX: CGFloat
let punchRadius: CGFloat
func path(in rect: CGRect) -> Path {
var p = Path()
let r = cornerRadius
let pr = punchRadius
let pX = perforationX
let w = rect.width
let h = rect.height
// Start: top-left corner, after rounded corner
p.move(to: CGPoint(x: r, y: 0))
// Top edge to the perforation cutout
p.addLine(to: CGPoint(x: pX - pr, y: 0))
// Top semicircle cutout sweeps DOWN into the row
p.addArc(
center: CGPoint(x: pX, y: 0),
radius: pr,
startAngle: .degrees(180),
endAngle: .degrees(0),
clockwise: false
)
// Continue along top edge
p.addLine(to: CGPoint(x: w - r, y: 0))
// Top-right corner
p.addArc(
center: CGPoint(x: w - r, y: r),
radius: r,
startAngle: .degrees(-90),
endAngle: .degrees(0),
clockwise: false
)
// Right edge
p.addLine(to: CGPoint(x: w, y: h - r))
// Bottom-right corner
p.addArc(
center: CGPoint(x: w - r, y: h - r),
radius: r,
startAngle: .degrees(0),
endAngle: .degrees(90),
clockwise: false
)
// Bottom edge to the perforation cutout
p.addLine(to: CGPoint(x: pX + pr, y: h))
// Bottom semicircle cutout sweeps UP into the row
p.addArc(
center: CGPoint(x: pX, y: h),
radius: pr,
startAngle: .degrees(0),
endAngle: .degrees(180),
clockwise: false
)
// Continue along bottom edge
p.addLine(to: CGPoint(x: r, y: h))
// Bottom-left corner
p.addArc(
center: CGPoint(x: r, y: h - r),
radius: r,
startAngle: .degrees(90),
endAngle: .degrees(180),
clockwise: false
)
// Left edge
p.addLine(to: CGPoint(x: 0, y: r))
// Top-left corner
p.addArc(
center: CGPoint(x: r, y: r),
radius: r,
startAngle: .degrees(180),
endAngle: .degrees(270),
clockwise: false
)
p.closeSubpath()
return p
}
}
// MARK: - Faux barcode
/// Canvas-drawn faux barcode strip. Deliberately not a scannable
/// barcode purely decorative. The bar widths cycle through a fixed
/// pattern that *looks* random enough at a glance.
struct BarcodeStripe: View {
/// Bar widths in points: [bar, gap, bar, gap, ...]. The pattern
/// repeats horizontally across the width of the canvas.
private static let widths: [CGFloat] = [1, 2, 1, 3, 2, 1, 1, 2, 3, 1, 2, 1, 1, 3, 2, 2]
var body: some View {
Canvas { context, size in
var x: CGFloat = 0
var i = 0
while x < size.width {
let w = Self.widths[i % Self.widths.count]
// Even indices are bars; odd are gaps.
if i.isMultiple(of: 2) {
let rect = CGRect(x: x, y: 0, width: w, height: size.height)
context.fill(Path(rect), with: .color(.white))
}
x += w
i += 1
}
}
}
}
+182
View File
@@ -0,0 +1,182 @@
import SwiftUI
/// Browses aggregated load-factor "tightness" per major US hub, computed
/// from the bundled BTS Reporting Carrier dataset. Surfaces the
/// ``HubLoadHeatmapService`` (which would otherwise be dead code).
///
/// Each row shows the hub's IATA, the aggregated average load factor,
/// the BTS sample size powering the number, and a colour-coded band
/// (`open` / `moderate` / `tight` / `full`). Sort defaults to tightest
/// first since that's the nonrev-relevant ordering.
struct HubLoadsView: View {
/// Curated list of US hubs surfaced in the view. Mirrors the airports
/// the bundled BTS data covers pulling the full ~4k airport list
/// would mostly be misses since the bundle is filtered to ~8k records
/// across the major carriers.
private static let hubIATAs: [String] = [
"ATL", "DFW", "DEN", "ORD", "LAX", "JFK", "LGA", "EWR",
"CLT", "MCO", "MIA", "SEA", "PHX", "SFO", "IAH", "BOS",
"MSP", "DTW", "PHL", "FLL", "BWI", "SLC", "DCA", "IAD",
"LAS", "MDW", "MEM", "MSY", "PDX", "SAN", "STL", "TPA",
"AUS", "BNA", "DAL", "HOU", "OAK", "RDU", "RSW", "SJC",
"JAX", "ABQ", "ANC", "BHM", "BUR", "CLE", "CMH", "CVG",
"ELP", "HNL", "ICT", "IND", "MCI", "OKC", "OMA", "ONT",
"PIT", "PVD", "SAT", "SDF", "SMF", "SNA", "TUL", "TUS"
]
@State private var rows: [HubRow] = []
@State private var isLoading = true
@State private var sourcePeriod: String?
var body: some View {
ZStack {
FlightTheme.background.ignoresSafeArea()
if isLoading {
ProgressView()
.tint(FlightTheme.accent)
} else if rows.isEmpty {
emptyState
} else {
List {
Section {
ForEach(rows) { row in
HubLoadRow(row: row)
}
} header: {
Text("Tightest hubs first")
} footer: {
if let sourcePeriod {
Text("Based on DOT BTS data — \(sourcePeriod). Higher percentages = fuller flights = tougher nonrev / standby odds.")
}
}
}
.listStyle(.insetGrouped)
.scrollContentBackground(.hidden)
}
}
.navigationTitle("Hub loads")
.navigationBarTitleDisplayMode(.inline)
.task {
await load()
}
}
private var emptyState: some View {
VStack(spacing: 12) {
Image(systemName: "chart.bar.xaxis")
.font(.system(size: 36))
.foregroundStyle(FlightTheme.textTertiary)
Text("No load data available")
.font(.subheadline.weight(.semibold))
.foregroundStyle(FlightTheme.textSecondary)
Text("The bundled BTS data has no records for these hubs.")
.font(.caption)
.foregroundStyle(FlightTheme.textTertiary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
// MARK: - Load
private func load() async {
let service = HubLoadHeatmapService()
let now = Date()
var results: [HubRow] = []
for iata in Self.hubIATAs {
if let index = await service.loadIndex(forAirport: iata, on: now) {
results.append(HubRow(
id: iata,
iata: iata,
avgLoadPct: index.avgLoadPct,
sampleSize: index.sampleSize,
band: index.band
))
}
}
// Tightest first most relevant ordering for nonrev planning.
results.sort {
if $0.avgLoadPct != $1.avgLoadPct {
return $0.avgLoadPct > $1.avgLoadPct
}
return $0.iata < $1.iata
}
let meta = await BTSDataStore.shared.metadata()
await MainActor.run {
self.rows = results
self.sourcePeriod = meta.map { "DOT BTS \($0.sourcePeriod) (\($0.recordCount) records)" }
self.isLoading = false
}
}
// MARK: - Row type
struct HubRow: Identifiable {
let id: String
let iata: String
let avgLoadPct: Double
let sampleSize: Int
let band: HubLoadHeatmapService.LoadBand
}
}
// MARK: - Row
private struct HubLoadRow: View {
let row: HubLoadsView.HubRow
var body: some View {
HStack(spacing: 14) {
iataBadge
VStack(alignment: .leading, spacing: 2) {
Text(bandLabel)
.font(.caption.weight(.heavy))
.tracking(0.6)
.foregroundStyle(bandColor)
Text("\(row.sampleSize) routes sampled")
.font(.caption2)
.foregroundStyle(FlightTheme.textSecondary)
}
Spacer()
Text("\(Int(round(row.avgLoadPct * 100)))%")
.font(.title3.weight(.bold).monospacedDigit())
.foregroundStyle(bandColor)
}
.padding(.vertical, 4)
}
private var iataBadge: some View {
Text(row.iata)
.font(FlightTheme.flightNumber(13))
.foregroundStyle(.white)
.frame(width: 46, height: 30)
.background(bandColor)
.clipShape(RoundedRectangle(cornerRadius: 7))
}
private var bandLabel: String {
switch row.band {
case .open: return "OPEN — nonrev-friendly"
case .moderate: return "MODERATE"
case .tight: return "TIGHT — list early"
case .full: return "FULL — backup itinerary recommended"
}
}
private var bandColor: Color {
switch row.band {
case .open: return FlightTheme.onTime
case .moderate: return FlightTheme.accent
case .tight: return FlightTheme.delayed
case .full: return FlightTheme.cancelled
}
}
}
#Preview {
HubLoadsView()
}
+303
View File
@@ -0,0 +1,303 @@
import SwiftUI
import UniformTypeIdentifiers
/// File-importer-driven CSV import flow. User picks a CSV from Files
/// (iCloud Drive / On My iPhone / etc.); we detect the format, show a
/// preview with dedupe counts, and on confirm save every novel row as
/// a LoggedFlight. Dupes (same date + flight # + route) are skipped.
struct ImportCSVView: View {
let store: FlightHistoryStore
let routeExplorer: RouteExplorerClient
@Environment(\.dismiss) private var dismiss
@State private var phase: Phase = .picking
@State private var pickedURL: URL?
@State private var parsed: [CSVFlightImporter.ParsedFlight] = []
@State private var novel: [CSVFlightImporter.ParsedFlight] = []
@State private var skipped: Int = 0
@State private var errorText: String?
@State private var importedCount: Int = 0
@State private var enrichedCount: Int = 0
@State private var enrichEnabled: Bool = true
@State private var showFilePicker = false
enum Phase: Equatable {
case picking
case parsing
case preview
case importing
case done
case failed
}
var body: some View {
NavigationStack {
content
.navigationTitle("Import CSV")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") { dismiss() }
}
if phase == .preview && !novel.isEmpty {
ToolbarItem(placement: .primaryAction) {
Button("Import \(novel.count)") { Task { await runImport() } }
}
}
}
.fileImporter(
isPresented: $showFilePicker,
allowedContentTypes: [.commaSeparatedText, .text, .data],
allowsMultipleSelection: false
) { result in
handlePicker(result)
}
.task(id: pickedURL) {
guard let pickedURL else { return }
await runParse(url: pickedURL)
}
}
}
@ViewBuilder
private var content: some View {
switch phase {
case .picking:
VStack(spacing: 16) {
Image(systemName: "doc.text")
.font(.system(size: 56))
.foregroundStyle(FlightTheme.textTertiary)
Text("Choose a CSV file to import")
.font(.headline)
Text("Supported: Southwest PNR export\n(columns Flt No, ORG, DST, Dep Date, OPNG Flt)")
.font(.caption)
.multilineTextAlignment(.center)
.foregroundStyle(FlightTheme.textTertiary)
.padding(.horizontal, 32)
Button {
showFilePicker = true
} label: {
Label("Choose file…", systemImage: "folder")
.font(.subheadline.weight(.semibold))
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
.background(FlightTheme.accent, in: Capsule())
.foregroundStyle(.white)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .parsing:
VStack(spacing: 12) {
ProgressView()
Text("Parsing…")
.font(.subheadline)
.foregroundStyle(FlightTheme.textSecondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .preview:
previewList
case .importing:
VStack(spacing: 12) {
ProgressView()
Text("Importing \(importedCount) / \(novel.count)")
.font(.subheadline)
.foregroundStyle(FlightTheme.textSecondary)
if enrichEnabled {
Text("Found aircraft type for \(enrichedCount)")
.font(.caption)
.foregroundStyle(FlightTheme.textTertiary)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .done:
ContentUnavailableView(
"Imported \(importedCount) flights",
systemImage: "checkmark.circle.fill",
description: Text(skipped > 0 ? "Skipped \(skipped) duplicates." : "Your log is up to date.")
)
case .failed:
ContentUnavailableView(
"Couldn't read this file",
systemImage: "exclamationmark.triangle.fill",
description: Text(errorText ?? "Try a different CSV.")
)
}
}
private var previewList: some View {
List {
Section {
HStack {
Text("\(parsed.count) rows in file")
Spacer()
Text("\(novel.count) new · \(skipped) dupes")
.foregroundStyle(FlightTheme.textTertiary)
}
.font(.subheadline)
} header: {
Text("Summary")
}
Section {
Toggle(isOn: $enrichEnabled) {
VStack(alignment: .leading, spacing: 2) {
Text("Look up aircraft type")
.font(.subheadline.weight(.semibold))
Text("Adds a few seconds per flight via route-explorer schedule data. Old flights may not match.")
.font(.caption)
.foregroundStyle(FlightTheme.textSecondary)
}
}
} header: {
Text("Enrichment")
}
Section {
ForEach(Array(novel.prefix(50).enumerated()), id: \.offset) { _, p in
VStack(alignment: .leading, spacing: 2) {
Text("\(p.carrierIATA ?? "?")\(p.flightNumber ?? "?")")
.font(.subheadline.weight(.semibold).monospaced())
Text("\(p.departureIATA)\(p.arrivalIATA) · \(shortDate(p.flightDate))")
.font(.caption.monospaced())
.foregroundStyle(FlightTheme.textSecondary)
}
}
if novel.count > 50 {
Text("…and \(novel.count - 50) more")
.font(.caption)
.foregroundStyle(FlightTheme.textTertiary)
}
} header: {
Text("Will be imported")
}
}
}
private func handlePicker(_ result: Result<[URL], Error>) {
switch result {
case .success(let urls):
pickedURL = urls.first
case .failure(let error):
errorText = error.localizedDescription
phase = .failed
}
}
/// Two-step lookup. Tries route-explorer first (works for future
/// schedules, returns IATA), then FlightAware (works for
/// historical flights, returns ICAO). Normalizes the result to
/// canonical ICAO before returning.
private func lookupAircraftType(for p: CSVFlightImporter.ParsedFlight) async -> String? {
guard let carrier = p.carrierIATA,
let numStr = p.flightNumber,
let num = Int(numStr)
else { return nil }
let day = Calendar.current.startOfDay(for: p.flightDate)
let next = Calendar.current.date(byAdding: .day, value: 1, to: day) ?? day
let results = await routeExplorer.searchSchedule(
carrierCode: carrier,
flightNumber: num,
startDate: day,
endDate: next
)
let exact = results.first {
$0.departure.airportIata == p.departureIATA
&& $0.arrival.airportIata == p.arrivalIATA
} ?? results.first
if let eq = exact?.equipmentIata, !eq.isEmpty {
return AircraftDatabase.shared.normalizedICAO(forCode: eq)
}
// FlightAware fallback for historical flights
guard let carrierICAO = p.carrierICAO
?? AircraftRegistry.shared.lookup(iata: carrier)?.icao
else { return nil }
let callsign = "\(carrierICAO)\(num)"
if let icaoType = await FlightAwareLookup.shared.lookupType(
callsign: callsign,
departureIATA: p.departureIATA,
arrivalIATA: p.arrivalIATA
) {
return AircraftDatabase.shared.normalizedICAO(forCode: icaoType)
}
return nil
}
private func runParse(url: URL) async {
phase = .parsing
let didStart = url.startAccessingSecurityScopedResource()
defer {
if didStart { url.stopAccessingSecurityScopedResource() }
}
do {
let data = try Data(contentsOf: url)
let rows = try CSVFlightImporter.parse(data)
parsed = rows
var (n, s) = ([CSVFlightImporter.ParsedFlight](), 0)
for r in rows {
let label = "\(r.carrierIATA ?? "")\(r.flightNumber ?? "")"
if store.exists(
flightDate: r.flightDate,
flightLabel: label,
departureIATA: r.departureIATA,
arrivalIATA: r.arrivalIATA
) {
s += 1
} else {
n.append(r)
}
}
novel = n
skipped = s
phase = .preview
} catch {
errorText = error.localizedDescription
phase = .failed
}
}
private func runImport() async {
phase = .importing
importedCount = 0
enrichedCount = 0
for p in novel {
// Optionally look up the scheduled aircraft type via
// route-explorer for this carrier/flight/date so the
// Aircraft stats screen has data even from a bare CSV.
// Best-effort: silently skip on failure or no match.
let enrichedType: String? = enrichEnabled
? await lookupAircraftType(for: p)
: nil
if enrichedType != nil { enrichedCount += 1 }
let f = LoggedFlight(
flightDate: p.flightDate,
carrierICAO: p.carrierICAO,
carrierIATA: p.carrierIATA,
flightNumber: p.flightNumber,
departureIATA: p.departureIATA,
arrivalIATA: p.arrivalIATA,
scheduledDeparture: p.scheduledDeparture,
scheduledArrival: nil,
aircraftType: enrichedType,
registration: nil,
icao24: nil,
notes: p.pnr.map { "PNR: \($0)" },
source: "csv-import"
)
store.save(f)
importedCount += 1
}
phase = .done
}
private func shortDate(_ d: Date) -> String {
let f = DateFormatter()
f.dateFormat = "MMM d, yyyy"
return f.string(from: d)
}
}
+130
View File
@@ -0,0 +1,130 @@
import SwiftUI
/// Lifetime stats screen. Big-number tiles up top, narrative stats
/// below. Pure read-only.
struct LifetimeStatsView: View {
let stats: StatsEngine
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
tilesGrid
narrativeSection
repeatedTailsSection
}
.padding(16)
}
.background(FlightTheme.background.ignoresSafeArea())
.navigationTitle("Lifetime")
.navigationBarTitleDisplayMode(.inline)
}
private var tilesGrid: some View {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 10), count: 2), spacing: 10) {
tile(label: "Flights", value: "\(stats.totalFlights)")
tile(label: "Miles", value: stats.shortDistance)
tile(label: "Hours", value: stats.shortDuration)
tile(label: "Airports", value: "\(stats.uniqueAirports)")
tile(label: "Airlines", value: "\(stats.uniqueAirlines)")
tile(label: "Aircraft", value: "\(stats.uniqueAircraftTypes)")
tile(label: "Countries", value: "\(stats.uniqueCountries)")
}
}
private func tile(label: String, value: String) -> some View {
VStack(spacing: 4) {
Text(value)
.font(.system(size: 32, weight: .bold).monospacedDigit())
.foregroundStyle(FlightTheme.textPrimary)
Text(label.uppercased())
.font(.caption.weight(.semibold))
.tracking(0.8)
.foregroundStyle(FlightTheme.textTertiary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 18)
.background(FlightTheme.cardBackground, in: RoundedRectangle(cornerRadius: 14))
}
private var narrativeSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("HIGHLIGHTS")
.font(FlightTheme.label())
.foregroundStyle(FlightTheme.textTertiary)
.tracking(1)
VStack(spacing: 0) {
if let top = stats.topAirline {
statRow(label: "Most-flown airline", value: AircraftRegistry.shared.lookup(icao: top.icao)?.name ?? top.icao, count: top.count)
Divider()
}
if let route = stats.topRoute {
statRow(label: "Most-flown route", value: route.label, count: route.count)
Divider()
}
if let airport = stats.topAirport {
statRow(label: "Most-visited airport", value: airport.iata, count: airport.count)
Divider()
}
if let longest = stats.longestFlight {
statRow(label: "Longest flight", value: "\(longest.departureIATA)\(longest.arrivalIATA)", count: nil)
Divider()
}
if let shortest = stats.shortestFlight {
statRow(label: "Shortest flight", value: "\(shortest.departureIATA)\(shortest.arrivalIATA)", count: nil)
}
}
.background(FlightTheme.cardBackground, in: RoundedRectangle(cornerRadius: 14))
}
}
private func statRow(label: String, value: String, count: Int?) -> some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(label)
.font(.caption)
.foregroundStyle(FlightTheme.textTertiary)
Text(value)
.font(.subheadline.weight(.semibold))
.foregroundStyle(FlightTheme.textPrimary)
}
Spacer()
if let count {
Text("\(count)×")
.font(.subheadline.weight(.bold).monospacedDigit())
.foregroundStyle(FlightTheme.accent)
}
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
}
@ViewBuilder
private var repeatedTailsSection: some View {
let tails = stats.repeatedTails.prefix(8)
if !tails.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("AIRFRAMES YOU'VE REPEATED")
.font(FlightTheme.label())
.foregroundStyle(FlightTheme.textTertiary)
.tracking(1)
VStack(spacing: 0) {
ForEach(Array(tails.enumerated()), id: \.offset) { index, item in
HStack {
Text(item.reg)
.font(.subheadline.weight(.semibold).monospaced())
.foregroundStyle(FlightTheme.textPrimary)
Spacer()
Text("\(item.count) flights")
.font(.caption.monospaced())
.foregroundStyle(FlightTheme.textTertiary)
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
if index < tails.count - 1 { Divider() }
}
}
.background(FlightTheme.cardBackground, in: RoundedRectangle(cornerRadius: 14))
}
}
}
}
+96
View File
@@ -0,0 +1,96 @@
import SwiftUI
/// Sheet-based filter picker. Backs the Live tab's airline + aircraft-type
/// filters. Uses a real `List` (virtualized, smooth scrolling) instead of
/// SwiftUI's `Menu`, which renders all items eagerly in a popover and
/// stutters once the count goes past ~20.
///
/// Multi-select. Tap-to-toggle. Search-as-you-type filters the list.
struct LiveFilterPicker: View {
let title: String
let items: [Item]
@Binding var selection: Set<String>
@Environment(\.dismiss) private var dismiss
@State private var query: String = ""
struct Item: Hashable, Identifiable {
let id: String
let label: String
let count: Int
}
private var filteredItems: [Item] {
let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if q.isEmpty { return items }
return items.filter { $0.label.lowercased().contains(q) || $0.id.lowercased().contains(q) }
}
var body: some View {
NavigationStack {
List {
if !selection.isEmpty {
Section {
ForEach(items.filter { selection.contains($0.id) }) { item in
row(item)
}
Button(role: .destructive) {
selection.removeAll()
} label: {
Label("Clear selection", systemImage: "xmark.circle")
}
} header: {
Text("Selected (\(selection.count))")
}
}
Section {
ForEach(filteredItems.filter { !selection.contains($0.id) }) { item in
row(item)
}
} header: {
if !selection.isEmpty {
Text("All")
}
}
}
.listStyle(.insetGrouped)
.searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always),
prompt: "Search \(title.lowercased())")
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
.fontWeight(.semibold)
}
}
}
}
private func row(_ item: Item) -> some View {
Button {
toggle(item.id)
} label: {
HStack {
Image(systemName: selection.contains(item.id) ? "checkmark.circle.fill" : "circle")
.foregroundStyle(selection.contains(item.id) ? FlightTheme.accent : FlightTheme.textTertiary)
Text(item.label)
.foregroundStyle(FlightTheme.textPrimary)
Spacer()
Text("\(item.count)")
.font(.caption.monospacedDigit())
.foregroundStyle(FlightTheme.textSecondary)
}
}
.buttonStyle(.plain)
}
private func toggle(_ id: String) {
if selection.contains(id) {
selection.remove(id)
} else {
selection.insert(id)
}
}
}
File diff suppressed because it is too large Load Diff
+804
View File
@@ -0,0 +1,804 @@
import SwiftUI
import MapKit
import CoreLocation
struct LiveFlightsView: View {
let openSky: OpenSkyClient
let fr24: FR24Client
let routeExplorer: RouteExplorerClient
let database: AirportDatabase
// MARK: - Map state
@State private var position: MapCameraPosition = .automatic
@State private var visibleRegion: MKCoordinateRegion?
// MARK: - Data state
@State private var aircraft: [LiveAircraft] = []
@State private var lastFetchAt: Date?
@State private var isLoading = false
@State private var error: String?
// MARK: - Filters
@State private var searchText: String = ""
@State private var selectedAirlineICAO: Set<String> = []
@State private var selectedTypeCodes: Set<String> = []
@State private var selectedAltitudeBand: AltitudeBand? = nil
@State private var hideOnGround: Bool = false
/// Altitude bands the user can filter by. Derived from data we always
/// have from OpenSky (baroAltitude / geoAltitude) unlike `category`,
/// which the anonymous tier returns as null for most aircraft.
enum AltitudeBand: String, CaseIterable, Identifiable, Hashable {
case lowLevel = "Below 10k ft"
case midLevel = "10k 25k ft"
case cruiseLevel = "25k 40k ft"
case highLevel = "Above 40k ft"
var id: String { rawValue }
func contains(_ ft: Int) -> Bool {
switch self {
case .lowLevel: return ft < 10_000
case .midLevel: return ft >= 10_000 && ft < 25_000
case .cruiseLevel: return ft >= 25_000 && ft < 40_000
case .highLevel: return ft >= 40_000
}
}
}
// MARK: - Selection & sheets
//
// A single `activeSheet` is set both for tap-on-aircraft and tap-on-gear,
// so SwiftUI only ever sees one .sheet modifier on this view. Two
// stacked .sheet modifiers fight each other and produce the "tap-the-
// plane-freezes-the-app" symptom.
@State private var activeSheet: ActiveSheet?
@State private var selectedTrack: AircraftTrack?
// Cached filter-menu items recomputed only when `aircraft` changes.
// Computing them on every body re-render caused the menu-tap freeze
// (each refresh fed the airlines registry an N×M lookup on the main
// thread).
@State private var cachedAirlineItems: [AirlineFilterItem] = []
@State private var cachedTypeItems: [TypeFilterItem] = []
/// Pre-filtered aircraft snapshot. Recomputed only when `aircraft` or
/// any filter state changes. Caching here avoids running the filter
/// loop on every body re-render (e.g. when a sheet animates in).
@State private var cachedFilteredAircraft: [LiveAircraft] = []
/// Tracks the last bounding box we fetched against. Used to throttle
/// the on-pan refresh so that micro-camera-settlements (which happen
/// every time annotations re-render) don't fire fresh OpenSky calls.
@State private var lastFetchedBoundingBox: (latMin: Double, lonMin: Double, latMax: Double, lonMax: Double)?
/// Total matches against the active filters (pre-cap). The map only
/// renders `cachedFilteredAircraft`; the footer uses this to surface
/// the "Showing N of M" message when the zoom cap clips the list.
@State private var filteredTotal: Int = 0
/// Set after the first camera change that meaningfully moves away
/// from the initial region. While this is false, an incoming location
/// fix can re-center the map on the user; after it flips true, the
/// user has expressed intent and we leave their pan alone.
@State private var userHasInteracted: Bool = false
@State private var initialRegionCenter: CLLocationCoordinate2D?
enum ActiveSheet: Identifiable {
case aircraft(LiveAircraft)
case settings
case airlinePicker
case typePicker
var id: String {
switch self {
case .aircraft(let a): return "ac-\(a.icao24)"
case .settings: return "settings"
case .airlinePicker: return "airline"
case .typePicker: return "type"
}
}
}
/// Aircraft currently selected (if any), unwrapped from `activeSheet`.
private var selectedAircraft: LiveAircraft? {
if case .aircraft(let a) = activeSheet { return a }
return nil
}
// Refresh interval paired with the rate-limit guard. Anonymous OpenSky
// is 100/day so we keep the auto-refresh tab-conservative.
private static let refreshInterval: TimeInterval = 15
@Environment(\.scenePhase) private var scenePhase
var body: some View {
mapLayer
.safeAreaInset(edge: .top, spacing: 0) { topFilterBar }
.safeAreaInset(edge: .bottom, spacing: 0) { bottomStatusBar }
.task { await autoRefreshLoop() }
.task(id: selectedAircraft?.icao24) {
await loadTrackForSelection()
}
// Force an immediate fetch when the app returns to foreground
// the 15s autoloop would otherwise leave up to 15s of stale data
// visible on resume. Background active transition counts; we
// don't fire on .inactive (transient e.g. notification
// drawer pulled down) to avoid wasted requests.
.onChange(of: scenePhase) { old, new in
if new == .active && old == .background {
Task { await refreshNow() }
}
}
.onChange(of: aircraft) { _, _ in
rebuildFilterItems()
rebuildFilteredAircraft()
}
.onChange(of: selectedAirlineICAO) { _, _ in rebuildFilteredAircraft() }
.onChange(of: selectedTypeCodes) { _, _ in rebuildFilteredAircraft() }
.onChange(of: selectedAltitudeBand) { _, _ in rebuildFilteredAircraft() }
.onChange(of: hideOnGround) { _, _ in rebuildFilteredAircraft() }
.onChange(of: searchText) { _, _ in rebuildFilteredAircraft() }
.sheet(item: $activeSheet) { sheet in
switch sheet {
case .aircraft(let ac):
LiveFlightDetailSheet(
aircraft: ac,
openSky: openSky,
routeExplorer: routeExplorer,
database: database
)
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
case .settings:
OpenSkySettingsView()
case .airlinePicker:
LiveFilterPicker(
title: "Airline",
items: cachedAirlineItems.map { .init(id: $0.icao, label: $0.name, count: $0.count) },
selection: $selectedAirlineICAO
)
case .typePicker:
LiveFilterPicker(
title: "Aircraft Type",
items: cachedTypeItems.map { .init(id: $0.code, label: $0.label, count: $0.count) },
selection: $selectedTypeCodes
)
}
}
}
// MARK: - Map
private var mapLayer: some View {
Map(position: $position) {
// Trail polyline for the currently selected aircraft.
if let track = selectedTrack {
let coords = track.path.map {
CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude)
}
MapPolyline(coordinates: coords)
.stroke(FlightTheme.accent, lineWidth: 3)
}
ForEach(filteredAircraft) { ac in
// Pre-compute the pin's inputs outside the closure so the
// SwiftUI builder doesn't re-derive them on every diff.
let tint = aircraftTint(for: ac)
let rotation = Double(ac.heading ?? 0) - 45 // SF airplane symbol points up-right
let selected = selectedAircraft?.id == ac.id
Annotation(ac.trimmedCallsign ?? ac.icao24, coordinate: ac.coordinate) {
AircraftPin(tint: tint, headingMinus45: rotation, isSelected: selected)
.onTapGesture {
activeSheet = .aircraft(ac)
}
}
.annotationTitles(.hidden)
}
}
.mapStyle(.standard(elevation: .flat))
.onMapCameraChange(frequency: .onEnd) { context in
visibleRegion = context.region
if let initial = initialRegionCenter {
let dLat = abs(context.region.center.latitude - initial.latitude)
let dLon = abs(context.region.center.longitude - initial.longitude)
if dLat > 0.1 || dLon > 0.1 {
userHasInteracted = true
}
}
Self.saveRegion(context.region)
// Span change shifts the cap, so the visible set might need
// to grow/shrink even when the underlying aircraft list is
// unchanged.
rebuildFilteredAircraft()
Task { await refreshIfRegionChanged() }
}
}
/// Fetches the trail for whichever aircraft is currently selected.
/// Cleared automatically on deselection. Race-guarded.
private func loadTrackForSelection() async {
guard let selected = selectedAircraft else {
selectedTrack = nil
return
}
let track = await openSky.track(icao24: selected.icao24)
if selectedAircraft?.icao24 == selected.icao24 {
selectedTrack = track
}
}
// MARK: - Top filter bar (lives inside the safe-area inset, never under
// the nav title or the tab bar)
private var topFilterBar: some View {
VStack(spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundStyle(FlightTheme.textSecondary)
TextField("Search callsign or flight (e.g. AA2178)", text: $searchText)
.autocorrectionDisabled()
.textInputAutocapitalization(.characters)
.onSubmit { centerOnSearchMatch() }
if !searchText.isEmpty {
Button { searchText = "" } label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(FlightTheme.textSecondary)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(FlightTheme.cardBackground)
.clipShape(Capsule())
.shadow(color: FlightTheme.cardShadow, radius: 6, y: 2)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
FilterChip(
label: hideOnGround ? "Airborne only" : "Include ground",
systemImage: hideOnGround ? "airplane" : "airplane.circle",
isActive: hideOnGround
) { hideOnGround.toggle() }
Button { activeSheet = .airlinePicker } label: {
FilterChipLabel(
label: selectedAirlineICAO.isEmpty
? "Airline"
: "Airline · \(selectedAirlineICAO.count)",
systemImage: "building.2",
isActive: !selectedAirlineICAO.isEmpty
)
}
.buttonStyle(.plain)
Button { activeSheet = .typePicker } label: {
FilterChipLabel(
label: selectedTypeCodes.isEmpty
? "Type"
: "Type · \(selectedTypeCodes.count)",
systemImage: "airplane.departure",
isActive: !selectedTypeCodes.isEmpty
)
}
.buttonStyle(.plain)
Menu {
let counts = altitudeBandCounts
ForEach(AltitudeBand.allCases) { band in
let count = counts[band] ?? 0
Button {
selectedAltitudeBand = (selectedAltitudeBand == band) ? nil : band
} label: {
let label = "\(band.rawValue) (\(count))"
if selectedAltitudeBand == band {
Label(label, systemImage: "checkmark")
} else {
Text(label)
}
}
}
if selectedAltitudeBand != nil {
Button("Clear", role: .destructive) { selectedAltitudeBand = nil }
}
} label: {
FilterChipLabel(
label: selectedAltitudeBand?.rawValue ?? "Altitude",
systemImage: "arrow.up.and.down",
isActive: selectedAltitudeBand != nil
)
}
if !selectedAirlineICAO.isEmpty || !selectedTypeCodes.isEmpty || selectedAltitudeBand != nil || hideOnGround {
Button {
selectedAirlineICAO.removeAll()
selectedTypeCodes.removeAll()
selectedAltitudeBand = nil
hideOnGround = false
} label: {
FilterChipLabel(label: "Reset", systemImage: "arrow.counterclockwise", isActive: false)
}
}
}
.padding(.horizontal, 12)
}
}
.padding(.horizontal, 12)
.padding(.top, 8)
.padding(.bottom, 8)
.background(.ultraThinMaterial)
}
// MARK: - Bottom status bar (count, refresh, gear)
private var bottomStatusBar: some View {
VStack(spacing: 6) {
if let error {
Text(error)
.font(.caption)
.foregroundStyle(.white)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(FlightTheme.cancelled, in: Capsule())
}
HStack(spacing: 12) {
if isLoading {
ProgressView().tint(.white)
} else {
Image(systemName: "antenna.radiowaves.left.and.right")
.foregroundStyle(.white)
}
Text(countLabel)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.white)
if let last = lastFetchAt {
Text("· updated \(relativeTime(last))")
.font(.caption)
.foregroundStyle(.white.opacity(0.8))
}
Spacer()
Button {
Task { await refreshNow() }
} label: {
Image(systemName: "arrow.clockwise")
.foregroundStyle(.white)
}
.disabled(isLoading)
.opacity(isLoading ? 0.3 : 1)
Button {
activeSheet = .settings
} label: {
Image(systemName: "gearshape")
.foregroundStyle(.white)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color.black.opacity(0.7), in: Capsule())
}
.padding(.horizontal, 12)
.padding(.bottom, 8)
}
// MARK: - Derived
/// Quick read accessor used by the rendering layer. Always returns the
/// cached snapshot; rebuilds happen via the .onChange handlers above.
private var filteredAircraft: [LiveAircraft] { cachedFilteredAircraft }
/// Footer text. Renders "Showing N of M" when the zoom cap is
/// clipping the visible set, else "N aircraft".
private var countLabel: String {
let shown = cachedFilteredAircraft.count
if filteredTotal > shown {
return "Showing \(shown) of \(filteredTotal)"
}
return "\(shown) aircraft"
}
private func rebuildFilteredAircraft() {
let s = searchText.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
let airlines = selectedAirlineICAO
let types = selectedTypeCodes
let band = selectedAltitudeBand
let hideGround = hideOnGround
let filtered = aircraft.filter { ac in
if hideGround && ac.onGround { return false }
if !airlines.isEmpty {
guard let code = ac.airlineICAO, airlines.contains(code) else { return false }
}
if !types.isEmpty {
guard let tc = ac.typeCode, types.contains(tc) else { return false }
}
if let band {
guard let alt = ac.altitudeFeet, band.contains(alt) else { return false }
}
if !s.isEmpty {
let cs = ac.trimmedCallsign?.uppercased() ?? ""
if !cs.contains(s) { return false }
}
return true
}
filteredTotal = filtered.count
// Cap policy: any active filter bypasses the cap so the user
// always sees every match for their query. With no filter, we
// tie the visible count to the zoom level so a zoomed-out view
// doesn't spew 800+ pins onto the map.
let hasFilter = !airlines.isEmpty || !types.isEmpty || band != nil || hideGround || !s.isEmpty
let cap = Self.capForSpan(visibleRegion?.span)
if hasFilter || filtered.count <= cap {
cachedFilteredAircraft = filtered
return
}
// Cap by distance to map center most users care about what's
// overhead first. Squared distance is fine for sorting.
if let center = visibleRegion?.center {
cachedFilteredAircraft = filtered
.map { ($0, Self.squaredDistance($0.coordinate, to: center)) }
.sorted { $0.1 < $1.1 }
.prefix(cap)
.map { $0.0 }
} else {
cachedFilteredAircraft = Array(filtered.prefix(cap))
}
}
/// Target visible count for a given map span. Empirically tuned so
/// the map doesn't feel sparse when zoomed out yet stays smooth.
/// Below ~2° (city/metro) we don't cap at all.
private static func capForSpan(_ span: MKCoordinateSpan?) -> Int {
guard let span else { return 150 }
let maxDelta = max(span.latitudeDelta, span.longitudeDelta)
switch maxDelta {
case ..<2: return .max
case ..<8: return 100
case ..<25: return 150
default: return 200
}
}
private static func squaredDistance(_ a: CLLocationCoordinate2D, to b: CLLocationCoordinate2D) -> Double {
let dLat = a.latitude - b.latitude
let dLon = a.longitude - b.longitude
return dLat * dLat + dLon * dLon
}
// MARK: - Region persistence
private struct SavedRegion: Codable {
let lat: Double
let lon: Double
let latDelta: Double
let lonDelta: Double
}
private static let savedRegionKey = "live_flights.saved_region"
private static func loadSavedRegion() -> MKCoordinateRegion? {
guard let data = UserDefaults.standard.data(forKey: savedRegionKey),
let saved = try? JSONDecoder().decode(SavedRegion.self, from: data)
else { return nil }
return MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: saved.lat, longitude: saved.lon),
span: MKCoordinateSpan(latitudeDelta: saved.latDelta, longitudeDelta: saved.lonDelta)
)
}
private static func saveRegion(_ r: MKCoordinateRegion) {
let payload = SavedRegion(
lat: r.center.latitude,
lon: r.center.longitude,
latDelta: r.span.latitudeDelta,
lonDelta: r.span.longitudeDelta
)
if let data = try? JSONEncoder().encode(payload) {
UserDefaults.standard.set(data, forKey: savedRegionKey)
}
}
struct AirlineFilterItem: Hashable {
let icao: String
let name: String
let count: Int
var label: String { "\(name) (\(count))" }
}
struct TypeFilterItem: Hashable {
let code: String
let label: String // e.g. "Boeing 737-800 · B738"
let count: Int
}
/// Rebuilds the cached filter items. Called from a .task tied to the
/// aircraft array so it doesn't run on every body re-render.
private func rebuildFilterItems() {
var airlines: [String: Int] = [:]
var types: [String: Int] = [:]
for ac in aircraft {
if let code = ac.airlineICAO {
airlines[code, default: 0] += 1
}
if let tc = ac.typeCode {
types[tc, default: 0] += 1
}
}
cachedAirlineItems = airlines.map { (icao, count) in
AirlineFilterItem(
icao: icao,
name: AircraftRegistry.shared.displayName(icao: icao),
count: count
)
}.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
cachedTypeItems = types.map { (code, count) in
let friendly = AircraftDatabase.shared.displayName(forTypeCode: code)
// If the friendly name differs from the raw code, show both;
// otherwise just the code so we don't render "B738 · B738".
let label = friendly == code ? code : "\(friendly) · \(code)"
return TypeFilterItem(code: code, label: label, count: count)
}.sorted { $0.label.localizedCaseInsensitiveCompare($1.label) == .orderedAscending }
}
/// Counts of how many aircraft fall in each altitude band drives the
/// altitude filter menu labels.
private var altitudeBandCounts: [AltitudeBand: Int] {
var counts: [AltitudeBand: Int] = [:]
for ac in aircraft {
guard let ft = ac.altitudeFeet else { continue }
for band in AltitudeBand.allCases where band.contains(ft) {
counts[band, default: 0] += 1
}
}
return counts
}
// MARK: - Fetch
/// Single long-lived auto-refresh loop. Runs for the lifetime of the
/// view (cancelled by SwiftUI when the tab disappears). Replaces the
/// old .task(id:) cascade, which restarted the timer every time
/// `isLoading` flipped and produced the "constantly refreshing"
/// symptom.
private func autoRefreshLoop() async {
if visibleRegion == nil {
// Initial region cascade:
// 1. Restore the last region the user saw, if we have one
// 2. Otherwise fall back to a continental US view
// Either way we kick off a one-shot location request in
// parallel. If the user grants location *and* hasn't panned
// by the time the fix lands, we animate to a city-level
// view centered on them.
let initial = Self.loadSavedRegion() ?? MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: 39.5, longitude: -98.0),
span: MKCoordinateSpan(latitudeDelta: 30, longitudeDelta: 50)
)
position = .region(initial)
visibleRegion = initial
initialRegionCenter = initial.center
Task {
if let coord = await LocationService.shared.requestOneShotLocation(),
!userHasInteracted {
let userRegion = MKCoordinateRegion(
center: coord,
span: MKCoordinateSpan(latitudeDelta: 0.6, longitudeDelta: 0.6)
)
withAnimation(.easeInOut(duration: 0.6)) {
position = .region(userRegion)
}
visibleRegion = userRegion
initialRegionCenter = userRegion.center
Self.saveRegion(userRegion)
}
}
}
await refreshNow()
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: UInt64(Self.refreshInterval * 1_000_000_000))
await refreshNow()
}
}
/// Called on map camera change. Only fires a fresh fetch if the new
/// bounding box actually moved by a meaningful amount micro-camera
/// settlements caused by annotation re-renders would otherwise
/// hammer OpenSky.
private func refreshIfRegionChanged() async {
guard let r = visibleRegion else { return }
let bb = boundingBox(of: r)
if let last = lastFetchedBoundingBox {
let centerDelta = max(
abs((bb.latMin + bb.latMax) / 2 - (last.latMin + last.latMax) / 2),
abs((bb.lonMin + bb.lonMax) / 2 - (last.lonMin + last.lonMax) / 2)
)
let widthRatio = (bb.lonMax - bb.lonMin) / max(0.001, last.lonMax - last.lonMin)
// Center moved less than 15% of the box width, AND box didn't
// zoom by more than 20% skip.
let halfWidth = (last.lonMax - last.lonMin) / 2
if centerDelta < halfWidth * 0.15, widthRatio > 0.8, widthRatio < 1.2 {
return
}
}
await refreshNow()
}
private func refreshNow() async {
guard !isLoading, let r = visibleRegion else { return }
isLoading = true
defer { isLoading = false }
let bb = boundingBox(of: r)
// Primary: FR24. Their feed includes ASDE-X + MLAT and reliably
// returns ground aircraft at major airports OpenSky's free tier
// does not, which was the root cause of "no SWA jets at DAL".
// We fall through to OpenSky only when FR24 hard-errors (rare).
if let results = try? await fr24.states(
latMin: bb.latMin, lonMin: bb.lonMin, latMax: bb.latMax, lonMax: bb.lonMax
) {
commitResults(results, bb: bb)
return
}
// Fallback: OpenSky. Same shape, missing the inline route data
// FR24 carries (departure/arrival/flight#), so the detail sheet
// route-resolver picks up the slack.
do {
let results = try await openSky.states(
latMin: bb.latMin, lonMin: bb.lonMin, latMax: bb.latMax, lonMax: bb.lonMax
)
commitResults(results, bb: bb)
} catch {
self.error = (error as? OpenSkyClient.ClientError)?.errorDescription
?? error.localizedDescription
if case OpenSkyClient.ClientError.throttled = error {
try? await Task.sleep(nanoseconds: 60 * 1_000_000_000)
}
}
}
/// Commit a fresh aircraft list to state. Suppresses SwiftUI's
/// implicit crossfade when annotations swap so the map doesn't
/// flicker every 15 seconds.
private func commitResults(_ results: [LiveAircraft], bb: (latMin: Double, lonMin: Double, latMax: Double, lonMax: Double)) {
var tx = Transaction()
tx.disablesAnimations = true
withTransaction(tx) {
aircraft = results
}
lastFetchAt = Date()
lastFetchedBoundingBox = bb
error = nil
}
private func boundingBox(of r: MKCoordinateRegion) -> (latMin: Double, lonMin: Double, latMax: Double, lonMax: Double) {
let lat = r.center.latitude
let lon = r.center.longitude
let dLat = r.span.latitudeDelta / 2
let dLon = r.span.longitudeDelta / 2
return (
latMin: max(-90, lat - dLat),
lonMin: max(-180, lon - dLon),
latMax: min( 90, lat + dLat),
lonMax: min( 180, lon + dLon)
)
}
// MARK: - Search / selection helpers
private func centerOnSearchMatch() {
let s = searchText.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
guard let match = aircraft.first(where: {
($0.trimmedCallsign?.uppercased() ?? "").contains(s)
}) else { return }
activeSheet = .aircraft(match)
position = .region(MKCoordinateRegion(
center: match.coordinate,
span: MKCoordinateSpan(latitudeDelta: 4, longitudeDelta: 6)
))
}
private func toggle<T: Hashable>(_ set: inout Set<T>, _ value: T) {
if set.contains(value) { set.remove(value) } else { set.insert(value) }
}
private func relativeTime(_ d: Date) -> String {
let secs = Int(Date().timeIntervalSince(d))
// Snap to coarse buckets so the footer text doesn't tick every
// second (which would force the footer subtree to re-render
// every body pass that happens to land on a different second).
if secs < 5 { return "just now" }
if secs < 30 { return "<30s ago" }
if secs < 60 { return "<1m ago" }
return "\(secs / 60)m ago"
}
}
// MARK: - Aircraft pin
/// Per-aircraft map pin. Kept deliberately minimal every annotation is
/// a SwiftUI view in the map's content tree, so view-tree depth × N pins
/// directly affects scroll/pan performance. Equatable so SwiftUI can
/// skip diffing identical pins on re-renders.
private struct AircraftPin: View, Equatable {
let tint: Color
let headingMinus45: Double
let isSelected: Bool
var body: some View {
Image(systemName: "airplane")
.font(.system(size: 14, weight: .bold))
.foregroundStyle(.white)
.padding(6)
.background(Circle().fill(tint))
.overlay(
Circle()
.stroke(.white, lineWidth: isSelected ? 2.5 : 0)
)
.rotationEffect(.degrees(headingMinus45))
.scaleEffect(isSelected ? 1.3 : 1)
.animation(nil, value: tint)
.contentShape(Rectangle())
}
static func == (lhs: AircraftPin, rhs: AircraftPin) -> Bool {
lhs.tint == rhs.tint
&& lhs.headingMinus45 == rhs.headingMinus45
&& lhs.isSelected == rhs.isSelected
}
}
private func aircraftTint(for ac: LiveAircraft) -> Color {
if ac.onGround { return FlightTheme.textTertiary }
switch ac.verticalState {
case .climbing: return FlightTheme.onTime
case .descending: return FlightTheme.delayed
case .level: return FlightTheme.accent
}
}
// MARK: - Filter chips
private struct FilterChip: View {
let label: String
let systemImage: String
let isActive: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
FilterChipLabel(label: label, systemImage: systemImage, isActive: isActive)
}
.buttonStyle(.plain)
}
}
private struct FilterChipLabel: View {
let label: String
let systemImage: String
let isActive: Bool
var body: some View {
HStack(spacing: 6) {
Image(systemName: systemImage)
.font(.caption)
Text(label)
.font(.caption.weight(.semibold))
}
.foregroundStyle(isActive ? .white : FlightTheme.textPrimary)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(
Capsule()
.fill(isActive ? FlightTheme.accent : FlightTheme.cardBackground)
)
.shadow(color: FlightTheme.cardShadow, radius: 4, y: 1)
}
}
+161
View File
@@ -0,0 +1,161 @@
import SwiftUI
/// Settings screen for the Live Flights tab. Currently just OpenSky
/// account credentials used to bump the request quota from anonymous's
/// ~100/day to the authenticated 4000/day.
struct OpenSkySettingsView: View {
@Environment(\.dismiss) private var dismiss
@State private var username: String = ""
@State private var password: String = ""
@State private var isSaving: Bool = false
@State private var isAuthed: Bool = false
@State private var saveError: String?
@State private var saveSuccess: Bool = false
var body: some View {
NavigationStack {
Form {
Section {
if isAuthed {
HStack {
Image(systemName: "checkmark.seal.fill")
.foregroundStyle(FlightTheme.onTime)
Text("Signed in as \(username)")
.font(.subheadline.weight(.semibold))
}
Button(role: .destructive) {
OpenSkyCredentials.shared.clear()
username = ""
password = ""
isAuthed = false
saveSuccess = false
saveError = nil
} label: {
Text("Sign out")
}
} else {
TextField("Username", text: $username)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
SecureField("Password", text: $password)
Button {
saveCredentials()
} label: {
HStack {
if isSaving { ProgressView() }
Text(isSaving ? "Saving…" : "Save")
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
}
.disabled(isSaving || username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| password.isEmpty)
}
} header: {
Text("OpenSky Network")
} footer: {
Text(footerText)
.font(.caption)
}
if let saveError {
Section {
Text(saveError)
.font(.caption)
.foregroundStyle(FlightTheme.cancelled)
}
}
if saveSuccess {
Section {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(FlightTheme.onTime)
Text("Credentials saved — quota raised to 4,000/day.")
.font(.caption)
}
}
}
}
.navigationTitle("Live Flights Settings")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button { dismiss() } label: {
Image(systemName: "xmark")
.font(.subheadline.weight(.semibold))
}
}
}
.onAppear { loadExisting() }
}
}
private var footerText: String {
if isAuthed {
return "Quota: ~4,000 requests/day. Sign out to go back to anonymous."
}
return "Anonymous access is capped at ~100 requests/day per IP. Sign in to a free OpenSky account (opensky-network.org/register) to raise the cap to ~4,000/day. Credentials are stored in the iOS Keychain."
}
private func loadExisting() {
if let creds = OpenSkyCredentials.shared.load() {
username = creds.username
password = "" // never expose the stored password back to the UI
isAuthed = true
}
}
private func saveCredentials() {
let u = username.trimmingCharacters(in: .whitespacesAndNewlines)
let p = password
guard !u.isEmpty, !p.isEmpty else { return }
isSaving = true
saveError = nil
saveSuccess = false
Task {
// Sanity-check the credentials by hitting the /states/all
// endpoint once and watching for HTTP 401. If they're bad,
// we won't save them.
let ok = await verify(username: u, password: p)
await MainActor.run {
isSaving = false
if ok {
OpenSkyCredentials.shared.save(username: u, password: p)
isAuthed = true
saveSuccess = true
password = ""
} else {
saveError = "Could not authenticate with OpenSky. Double-check the username and password."
}
}
}
}
/// Send a tiny request to /states/all with the candidate creds to
/// see whether OpenSky accepts them (401 bad credentials).
private func verify(username: String, password: String) async -> Bool {
// 1° x 1° box near MSP tiny payload.
var comps = URLComponents(string: "https://opensky-network.org/api/states/all")!
comps.queryItems = [
URLQueryItem(name: "lamin", value: "44.5"),
URLQueryItem(name: "lomin", value: "-93.5"),
URLQueryItem(name: "lamax", value: "45.5"),
URLQueryItem(name: "lomax", value: "-92.5")
]
guard let url = comps.url else { return false }
var req = URLRequest(url: url)
let raw = "\(username):\(password)"
if let data = raw.data(using: .utf8) {
req.setValue("Basic \(data.base64EncodedString())", forHTTPHeaderField: "Authorization")
}
do {
let (_, response) = try await URLSession.shared.data(for: req)
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
return status == 200
} catch {
return false
}
}
}
+280
View File
@@ -0,0 +1,280 @@
import SwiftUI
// MARK: - Hero stat card
//
// Full-bleed colored card with one big number + label + optional
// subtitle. The variant determines the background treatment (orange,
// navy, gold, or a photo). Shared by HistoryView, PassportView,
// AircraftStatsView, and YearInReviewView so cards feel consistent
// wherever they appear.
struct HeroStatCard<Footer: View>: View {
let label: String
let value: String
let subtitle: String?
let variant: Variant
let onForeground: Color
@ViewBuilder var footer: () -> Footer
enum Variant {
case orange
case navy
case gold
case green
case photo(URL?)
}
init(
label: String,
value: String,
subtitle: String? = nil,
variant: Variant,
onForeground: Color = .white,
@ViewBuilder footer: @escaping () -> Footer = { EmptyView() }
) {
self.label = label
self.value = value
self.subtitle = subtitle
self.variant = variant
self.onForeground = onForeground
self.footer = footer
}
var body: some View {
ZStack(alignment: .topLeading) {
background
VStack(alignment: .leading, spacing: 8) {
Text(label)
.font(HistoryStyle.label(11))
.tracking(1.6)
.textCase(.uppercase)
.foregroundStyle(onForeground.opacity(0.7))
Text(value)
.font(HistoryStyle.displayNumber(46))
.foregroundStyle(onForeground)
.lineLimit(1)
.minimumScaleFactor(0.55)
if let subtitle {
Text(subtitle)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(onForeground.opacity(0.82))
}
Spacer(minLength: 0)
footer()
}
.padding(20)
}
.frame(minHeight: 152)
.clipShape(RoundedRectangle(cornerRadius: 22))
}
@ViewBuilder
private var background: some View {
switch variant {
case .orange:
HistoryStyle.heroOrangeGradient
case .navy:
HistoryStyle.heroNavyGradient
case .gold:
HistoryStyle.heroGoldGradient
case .green:
HistoryStyle.heroGreenGradient
case .photo(let url):
ZStack {
Color.black
if let url {
AsyncImage(url: url) { phase in
switch phase {
case .success(let img):
img.resizable().aspectRatio(contentMode: .fill)
default:
Color.black.opacity(0.6)
}
}
}
LinearGradient(
colors: [Color.black.opacity(0.0), Color.black.opacity(0.85)],
startPoint: .top, endPoint: .bottom
)
}
}
}
}
// MARK: - Year tab strip
//
// Horizontal scroll of `ALL · 2026 · 2025 · 2024 ...`. Tapping a year
// updates a binding the parent view filters against. Active year is
// pill-highlighted in runway orange.
struct YearTabStrip: View {
let years: [Int]
@Binding var selection: Int?
@Environment(\.colorScheme) private var scheme
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
ScrollViewReader { proxy in
HStack(spacing: 8) {
tab(label: "ALL", id: -1, selected: selection == nil) {
selection = nil
}
.id("ALL")
ForEach(years, id: \.self) { y in
tab(label: String(y), id: y, selected: selection == y) {
selection = y
}
.id(y)
}
}
.padding(.horizontal, 16)
.onAppear {
if let sel = selection {
withAnimation { proxy.scrollTo(sel, anchor: .center) }
}
}
}
}
}
private func tab(label: String, id: Int, selected: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(label)
.font(.system(size: 13, weight: .bold).monospacedDigit())
.tracking(0.6)
.foregroundStyle(selected ? .white : HistoryStyle.inkSecondary(scheme))
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(
Capsule()
.fill(selected ? HistoryStyle.runwayOrange : HistoryStyle.card(scheme))
)
}
.buttonStyle(.plain)
}
}
// MARK: - Passport stamp badge
//
// Faux rubber-stamp circular badge used on cards to add flavor (e.g.
// "VERIFIED", date stamps).
struct PassportStamp: View {
let text: String
let color: Color
var body: some View {
Circle()
.stroke(color, lineWidth: 1.2)
.frame(width: 56, height: 56)
.overlay(
Text(text)
.font(.system(size: 10, weight: .black))
.tracking(1.5)
.foregroundStyle(color)
.multilineTextAlignment(.center)
.padding(6)
)
.rotationEffect(.degrees(-6))
.opacity(0.85)
}
}
// MARK: - OCR-passport flex footer
//
// The deeply unserious passport-bottom OCR text Flighty uses. We
// generate ours from the user's display name + a synthetic issue
// date. Pure flavor, fully optional.
struct OCRPassportFooter: View {
let owner: String // "TARTT, GARY"
let issued: Date
@Environment(\.colorScheme) private var scheme
var body: some View {
VStack(spacing: 2) {
line(prefix: "P<USA<", trailing: nameLine)
line(prefix: "ISSUED<", trailing: tailLine)
}
.font(HistoryStyle.ocrFont)
.foregroundStyle(HistoryStyle.inkSecondary(scheme).opacity(0.9))
.padding(.vertical, 12)
.padding(.horizontal, 14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 8)
.stroke(HistoryStyle.inkSecondary(scheme).opacity(0.3), style: .init(lineWidth: 0.5, dash: [3, 3]))
)
}
private func line(prefix: String, trailing: String) -> some View {
HStack(spacing: 0) {
Text(prefix + trailing)
.lineLimit(1)
.truncationMode(.tail)
Spacer(minLength: 0)
}
}
private var nameLine: String {
let upper = owner.uppercased()
let padded = (upper + String(repeating: "<", count: 40))
return String(padded.prefix(40)) + "<<<<<<<<"
}
private var tailLine: String {
let f = DateFormatter()
f.dateFormat = "ddMMMyy"
f.locale = Locale(identifier: "en_US_POSIX")
let date = f.string(from: issued).uppercased()
return date + "<<MEMBER<<@FLIGHTAPP.COM<<<<<<<<<<<<<<<<<<<<<<"
}
}
// MARK: - Big stat numbers row
struct StatColumn: View {
let label: String
let value: String
var subtitle: String? = nil
@Environment(\.colorScheme) private var scheme
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(label)
.font(HistoryStyle.label(10))
.tracking(1.3)
.textCase(.uppercase)
.foregroundStyle(HistoryStyle.inkSecondary(scheme))
Text(value)
.font(HistoryStyle.displayNumber(28))
.foregroundStyle(HistoryStyle.ink(scheme))
if let subtitle {
Text(subtitle)
.font(.system(size: 11))
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
// MARK: - Section header
struct HistorySectionLabel: View {
let text: String
@Environment(\.colorScheme) private var scheme
init(_ text: String) { self.text = text }
var body: some View {
Text(text)
.font(HistoryStyle.label(11))
.tracking(1.6)
.textCase(.uppercase)
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
}
}
+209
View File
@@ -0,0 +1,209 @@
import SwiftUI
/// The Passport screen. Replaces the old "Lifetime Stats" sheet
/// stacked colored hero cards, each one feature-sized for a single
/// stat, year tabs at the top to re-scope, OCR-passport flex footer
/// at the bottom. Pure read-only.
struct PassportView: View {
let stats: StatsEngine
let allFlights: [LoggedFlight]
let database: AirportDatabase
let store: FlightHistoryStore
@Binding var selectedYear: Int?
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var scheme
var body: some View {
ScrollView {
VStack(spacing: 14) {
header
YearTabStrip(years: yearsList, selection: $selectedYear)
.padding(.vertical, 4)
cards
OCRPassportFooter(owner: "TARTT GARY", issued: stats.flights.first?.flightDate ?? Date())
.padding(.horizontal, 16)
.padding(.top, 12)
Spacer(minLength: 60)
}
.padding(.vertical, 4)
}
.background(HistoryStyle.background(scheme).ignoresSafeArea())
.navigationTitle("")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button { dismiss() } label: { Image(systemName: "xmark") }
}
}
}
private var header: some View {
VStack(spacing: 4) {
Text(selectedYear == nil ? "ALL TIME" : String(selectedYear!))
.font(.system(size: 12, weight: .heavy))
.tracking(2.5)
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
Text("PASSPORT")
.font(.system(size: 40, weight: .black))
.tracking(-0.5)
.foregroundStyle(HistoryStyle.ink(scheme))
Rectangle()
.fill(HistoryStyle.runwayOrange)
.frame(width: 38, height: 3)
.padding(.top, 4)
}
.frame(maxWidth: .infinity)
.padding(.top, 8)
.padding(.bottom, 12)
}
private var yearsList: [Int] {
let cal = Calendar.current
return Array(Set(allFlights.map { cal.component(.year, from: $0.flightDate) })).sorted(by: >)
}
/// Stats for the current scope either lifetime or one year.
private var scopedStats: StatsEngine {
guard let y = selectedYear else { return stats }
let filtered = allFlights.filter { Calendar.current.component(.year, from: $0.flightDate) == y }
return StatsEngine(store: store, database: database, flights: filtered)
}
@ViewBuilder
private var cards: some View {
let s = scopedStats
VStack(spacing: 12) {
HeroStatCard(
label: "FLIGHTS",
value: numberString(s.totalFlights),
subtitle: "across \(s.uniqueAirports) airports",
variant: .orange
) {
HStack(spacing: 16) {
StatColumn(label: "Airlines", value: "\(s.uniqueAirlines)")
StatColumn(label: "Aircraft", value: "\(s.uniqueAircraftTypes)")
StatColumn(label: "Countries", value: "\(s.uniqueCountries)")
}
.padding(.top, 4)
}
HeroStatCard(
label: "DISTANCE",
value: s.shortDistance + " mi",
subtitle: equatorComparison(miles: s.totalMiles),
variant: .navy
) { EmptyView() }
HeroStatCard(
label: "TIME ALOFT",
value: hoursAloftDisplay(s.totalMinutes),
subtitle: timeAloftSubtitle(s.totalMinutes),
variant: .gold,
onForeground: .white
) { EmptyView() }
if let top = s.topRoute {
HeroStatCard(
label: "TOP ROUTE",
value: top.label.replacingOccurrences(of: "", with: ""),
subtitle: "\(top.count) trips",
variant: .green
) { EmptyView() }
}
if let topAirline = s.topAirline {
let name = AircraftRegistry.shared.lookup(icao: topAirline.icao)?.name ?? topAirline.icao
HeroStatCard(
label: "TOP AIRLINE",
value: name,
subtitle: "\(topAirline.count) flights",
variant: .orange
) { EmptyView() }
}
if let longest = s.longestFlight,
let miles = store.distanceMiles(for: longest) {
HeroStatCard(
label: "LONGEST FLIGHT",
value: "\(longest.departureIATA)\(longest.arrivalIATA)",
subtitle: "\(numberString(miles)) mi · \(shortDate(longest.flightDate))",
variant: .navy
) { EmptyView() }
}
repeatedTailsCard(s)
}
.padding(.horizontal, 16)
}
@ViewBuilder
private func repeatedTailsCard(_ s: StatsEngine) -> some View {
let tails = s.repeatedTails.prefix(5)
if !tails.isEmpty {
VStack(alignment: .leading, spacing: 12) {
HistorySectionLabel("Airframes you've repeated")
.foregroundStyle(HistoryStyle.inkSecondary(scheme))
VStack(spacing: 0) {
ForEach(Array(tails.enumerated()), id: \.offset) { index, item in
HStack {
Text(item.reg)
.font(.system(size: 14, weight: .bold).monospaced())
.foregroundStyle(HistoryStyle.ink(scheme))
Spacer()
Text("\(item.count)×")
.font(.system(size: 14, weight: .black).monospacedDigit())
.foregroundStyle(HistoryStyle.runwayOrange)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
if index < tails.count - 1 {
Rectangle()
.fill(HistoryStyle.hairline(scheme))
.frame(height: 0.5)
.padding(.horizontal, 16)
}
}
}
}
.padding(.vertical, 14)
.background(HistoryStyle.card(scheme), in: RoundedRectangle(cornerRadius: 22))
}
}
// MARK: - Helpers
private func numberString(_ n: Int) -> String {
let f = NumberFormatter()
f.numberStyle = .decimal
return f.string(from: NSNumber(value: n)) ?? "\(n)"
}
private func equatorComparison(miles: Int) -> String {
let equator = 24_901
let ratio = Double(miles) / Double(equator)
if miles == 0 { return "" }
if ratio < 0.05 { return "miles flown" }
if ratio < 1 { return String(format: "%.0f%% of the way around earth", ratio * 100) }
return String(format: "%.1f× around the equator", ratio)
}
private func hoursAloftDisplay(_ minutes: Int) -> String {
let days = minutes / (60 * 24)
let hours = (minutes % (60 * 24)) / 60
if days > 0 {
return "\(days)d \(hours)h"
}
return "\(hours)h"
}
private func timeAloftSubtitle(_ minutes: Int) -> String {
if minutes <= 0 { return "" }
return "\(numberString(minutes / 60)) total hours airborne"
}
private func shortDate(_ d: Date) -> String {
let f = DateFormatter()
f.dateFormat = "MMM d, yyyy"
return f.string(from: d)
}
}
+260
View File
@@ -0,0 +1,260 @@
import SwiftUI
import SwiftData
/// Top-level tab container.
///
/// Tab 1: the existing search / connection / where-to-go home screen.
/// Tab 2: the live flight tracker (map + filters + tap-to-detail).
/// Tab 3: personal flight history (logbook + stats + map).
///
/// Also subscribes to WalletPassObserver so that adding a boarding
/// pass to Apple Wallet pops the add-flight sheet over whatever tab
/// the user is on.
struct RootView: View {
let database: AirportDatabase
let loadService: AirlineLoadService
let routeExplorer: RouteExplorerClient
let openSky: OpenSkyClient
let fr24: FR24Client
let flightAware: FlightAwareScheduleClient
@State private var selectedTab: Tab = .search
@StateObject private var wallet = WalletPassObserver.shared
@StateObject private var integrityMonitor = DataIntegrityMonitor.shared
@State private var bannerDismissed = false
@State private var saveBannerDismissedCount: Int = 0
@State private var walletPrefill: AddFlightView.Prefill?
/// URL-scheme prefill (from the Share Extension or any external
/// invocation of `flights://import?...`).
@State private var urlPrefill: AddFlightView.Prefill?
@Environment(\.modelContext) private var modelContext
enum Tab: Hashable { case search, live, history, settings }
private var showIntegrityBanner: Bool {
integrityMonitor.hasFailures && !bannerDismissed
}
/// Save-failure banner stays up until either the user dismisses the
/// *current* count or new failures arrive after dismissal. We compare
/// the current save-failure count to the snapshot at dismiss-time so
/// a brand-new failure re-shows the banner.
private var showSaveBanner: Bool {
integrityMonitor.saveFailures.count > saveBannerDismissedCount
}
var body: some View {
tabs
.overlay(alignment: .top) {
VStack(spacing: 0) {
if showSaveBanner {
saveFailureBanner
.transition(.move(edge: .top).combined(with: .opacity))
}
if showIntegrityBanner {
integrityBanner
.transition(.move(edge: .top).combined(with: .opacity))
}
}
}
.animation(.easeInOut(duration: 0.2), value: showIntegrityBanner)
.animation(.easeInOut(duration: 0.2), value: showSaveBanner)
}
private var integrityBanner: some View {
HStack(spacing: 8) {
Text("⚠️ Some reference data didn't load")
.font(.footnote.weight(.semibold))
.foregroundStyle(.black)
.lineLimit(2)
.accessibilityLabel("Some reference data did not load")
Spacer(minLength: 4)
Button {
bannerDismissed = true
} label: {
Image(systemName: "xmark")
.font(.footnote.weight(.bold))
.foregroundStyle(.black)
.padding(6)
.contentShape(Rectangle())
}
.accessibilityLabel("Dismiss banner")
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.frame(maxWidth: .infinity)
.background(FlightTheme.delayed)
}
/// Red banner shown when a SwiftData save throws. Distinct from the
/// yellow decode-failure banner because the action is different the
/// user needs to know their *edit* didn't persist (and so anything
/// they typed may be lost if they background the app).
private var saveFailureBanner: some View {
let latest = integrityMonitor.saveFailures.last ?? ""
return HStack(spacing: 8) {
VStack(alignment: .leading, spacing: 2) {
Text("❌ Your last edit didn't save")
.font(.footnote.weight(.bold))
.foregroundStyle(.white)
if !latest.isEmpty {
Text(latest)
.font(.caption2)
.foregroundStyle(.white.opacity(0.9))
.lineLimit(1)
.truncationMode(.middle)
}
}
.accessibilityLabel("Your last edit did not save. \(latest)")
Spacer(minLength: 4)
Button {
saveBannerDismissedCount = integrityMonitor.saveFailures.count
} label: {
Image(systemName: "xmark")
.font(.footnote.weight(.bold))
.foregroundStyle(.white)
.padding(6)
.contentShape(Rectangle())
}
.accessibilityLabel("Dismiss save-failure banner")
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.frame(maxWidth: .infinity)
.background(FlightTheme.cancelled)
}
private var tabs: some View {
TabView(selection: $selectedTab) {
RoutePlannerView(
database: database,
client: routeExplorer,
flightAware: flightAware,
loadService: loadService
)
.tabItem {
Label("Search", systemImage: "magnifyingglass")
}
.tag(Tab.search)
NavigationStack {
LiveFlightsView(
openSky: openSky,
fr24: fr24,
routeExplorer: routeExplorer,
database: database
)
.navigationTitle("Live Flights")
.navigationBarTitleDisplayMode(.inline)
}
.tabItem {
Label("Live", systemImage: "antenna.radiowaves.left.and.right")
}
.tag(Tab.live)
NavigationStack {
HistoryView(
database: database,
routeExplorer: routeExplorer,
openSky: openSky
)
}
.tabItem {
Label("History", systemImage: "book.closed")
}
.tag(Tab.history)
SettingsView()
.tabItem {
Label("Settings", systemImage: "gear")
}
.tag(Tab.settings)
}
.tint(FlightTheme.accent)
.task {
// Defer PKPassLibrary initialization until the first task
// run, so app launch isn't blocked by it.
wallet.start()
}
.onChange(of: wallet.pendingPass) { _, pass in
// A new boarding pass landed in Wallet surface the
// add-flight sheet pre-populated from it.
guard let pass else { return }
walletPrefill = AddFlightView.Prefill(
flightDate: pass.flightDate,
carrierICAO: nil,
carrierIATA: pass.carrierIATA,
flightNumber: pass.flightNumber,
departureIATA: pass.departureIATA,
arrivalIATA: pass.arrivalIATA,
scheduledDeparture: pass.flightDate,
scheduledArrival: nil,
aircraftType: nil,
registration: nil,
icao24: nil,
source: "wallet"
)
}
.sheet(item: $walletPrefill) { prefill in
let store = FlightHistoryStore(context: modelContext, airportDatabase: database)
AddFlightView(
routeExplorer: routeExplorer,
database: database,
store: store,
prefill: prefill
)
.onDisappear { wallet.clearPending() }
}
.sheet(item: $urlPrefill) { prefill in
let store = FlightHistoryStore(context: modelContext, airportDatabase: database)
AddFlightView(
routeExplorer: routeExplorer,
database: database,
store: store,
prefill: prefill
)
}
.onOpenURL { url in
// Safari bookmarklet flights://routeexplorer-token?token=&exp=&cookie=
// The token store handles the parse + persistence; we just
// pop a confirmation if it took effect.
if url.scheme == "flights", url.host == "routeexplorer-token" {
let accepted = RouteExplorerTokenStore.shared.ingest(url: url)
if accepted {
selectedTab = .settings
}
return
}
// Share Extension hands us a URL like:
// flights://import?carrier=WN&num=7&dep=DAL&arr=HOU&date=1779892800
guard url.scheme == "flights", url.host == "import" else { return }
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
let q = components?.queryItems ?? []
func val(_ k: String) -> String? { q.first { $0.name == k }?.value }
let dateInterval = val("date").flatMap(TimeInterval.init)
let prefill = AddFlightView.Prefill(
flightDate: dateInterval.map { Date(timeIntervalSince1970: $0) } ?? Date(),
carrierICAO: nil,
carrierIATA: val("carrier"),
flightNumber: val("num"),
departureIATA: val("dep"),
arrivalIATA: val("arr"),
scheduledDeparture: nil,
scheduledArrival: nil,
aircraftType: nil,
registration: nil,
icao24: nil,
source: "mail-share"
)
selectedTab = .history
urlPrefill = prefill
}
}
}
extension AddFlightView.Prefill: Identifiable {
public var id: String {
// Stable enough pass-prompted prefills are one-at-a-time.
"\(flightDate.timeIntervalSince1970)-\(carrierIATA ?? "")\(flightNumber ?? "")"
}
}
@@ -0,0 +1,36 @@
import SwiftUI
import SafariServices
/// Embeds Safari's full engine including Apple's Private Access Token
/// plumbing inside the app. WKWebView in third-party apps can't pass
/// Cloudflare Turnstile (the PAT issuance pipeline gates on browser-app
/// status); SFSafariViewController is the only system-provided in-app
/// browser that does. Cookies persist across launches and share Safari's
/// cookie jar, so Turnstile clearance survives.
///
/// We expose this view both from Settings Tools (full-screen browse) and
/// from the Search tab (when the user wants multi-stop / where-can-I-go,
/// neither of which we replicate via FlightAware).
struct RouteExplorerBrowserView: UIViewControllerRepresentable {
let url: URL
init(url: URL = URL(string: "https://route-explorer.com/")!) {
self.url = url
}
func makeUIViewController(context: Context) -> SFSafariViewController {
let config = SFSafariViewController.Configuration()
// Keep the URL bar visible it doubles as a trust indicator that
// we're really on route-explorer.com.
config.barCollapsingEnabled = false
config.entersReaderIfAvailable = false
let vc = SFSafariViewController(url: url, configuration: config)
vc.preferredControlTintColor = .systemBlue
// Page sheet-style dismiss feels natural inside a navigation flow.
vc.dismissButtonStyle = .done
DiagnosticLogger.shared.log("REBR", "open", ["url": url.absoluteString])
return vc
}
func updateUIViewController(_ vc: SFSafariViewController, context: Context) {}
}
+166
View File
@@ -0,0 +1,166 @@
import SwiftUI
import WebKit
/// Visible WKWebView that loads route-explorer.com so Cloudflare Turnstile
/// has a real-looking browser session to fingerprint. Cookies persist in
/// `WKWebsiteDataStore.default()` the same store `WebViewFetcher` uses
/// so once the user clears the gate, every subsequent `/api/flight-search`
/// call carries `am_clearance` automatically.
///
/// The sheet polls `/api/token` from inside the WebView once per second.
/// When it returns 200 (clearance achieved), the sheet auto-dismisses.
struct RouteExplorerGateSheet: View {
/// Set to true when /api/token returns 200 from inside the WebView.
@State private var cleared = false
@State private var statusLine = "Loading route-explorer…"
@State private var attempts = 0
@Environment(\.dismiss) private var dismiss
init() {
DiagnosticLogger.shared.log("GATE", "sheetOpened", [:])
}
var body: some View {
NavigationStack {
ZStack(alignment: .top) {
GateWebView(
onTokenStatus: { status in
attempts += 1
DiagnosticLogger.shared.log("GATE", "probe", [
"tick": attempts,
"status": status,
])
if status == 200 {
statusLine = "Cleared ✓"
cleared = true
DiagnosticLogger.shared.log("GATE", "cleared", [
"afterTicks": attempts,
])
// Brief beat so the user sees the success,
// then dismiss back into the search.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
dismiss()
}
} else {
statusLine = "Verifying… (probe #\(attempts), HTTP \(status))"
}
}
)
VStack(spacing: 0) {
HStack {
Image(systemName: cleared
? "checkmark.seal.fill"
: "shield.lefthalf.filled")
.foregroundStyle(cleared ? .green : .orange)
Text(statusLine)
.font(.footnote.monospaced())
.lineLimit(1)
.truncationMode(.middle)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.regularMaterial)
}
}
.navigationTitle("Verify route-explorer")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Skip") { dismiss() }
}
}
}
.interactiveDismissDisabled(false)
}
}
// MARK: - UIViewRepresentable
private struct GateWebView: UIViewRepresentable {
let onTokenStatus: (Int) -> Void
func makeCoordinator() -> Coordinator {
Coordinator(onTokenStatus: onTokenStatus)
}
func makeUIView(context: Context) -> WKWebView {
let config = WKWebViewConfiguration()
config.websiteDataStore = .default()
let view = WKWebView(frame: .zero, configuration: config)
view.navigationDelegate = context.coordinator
view.customUserAgent =
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) " +
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 " +
"Mobile/15E148 Safari/604.1"
view.load(URLRequest(url: URL(string: "https://route-explorer.com/")!))
context.coordinator.attach(view)
return view
}
func updateUIView(_ uiView: WKWebView, context: Context) {}
final class Coordinator: NSObject, WKNavigationDelegate {
let onTokenStatus: (Int) -> Void
private weak var webView: WKWebView?
private var pollTimer: Timer?
private var isPolling = false
init(onTokenStatus: @escaping (Int) -> Void) {
self.onTokenStatus = onTokenStatus
}
func attach(_ webView: WKWebView) {
self.webView = webView
}
deinit {
pollTimer?.invalidate()
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
startPolling()
}
private func startPolling() {
guard pollTimer == nil else { return }
pollTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
guard let self else { return }
self.probeToken()
}
}
private func probeToken() {
guard let webView, !isPolling else { return }
isPolling = true
let js = """
return await new Promise((res) => {
fetch('/api/token', { credentials: 'include' })
.then(r => res(r.status))
.catch(() => res(-1));
});
"""
Task { @MainActor in
let value = try? await webView.callAsyncJavaScript(js, contentWorld: .page)
let status = (value as? Int) ?? -1
self.onTokenStatus(status)
// Snapshot cookies on the shared data store so we can
// tell whether `rex_clearance` ever landed.
let cookies = await WKWebsiteDataStore.default()
.httpCookieStore.allCookies()
let reCookies = cookies.filter { $0.domain.contains("route-explorer.com") }
DiagnosticLogger.shared.log("GATE", "cookieSnapshot", [
"count": reCookies.count,
"names": reCookies.map { $0.name }.sorted().joined(separator: ","),
"hasRexClearance": reCookies.contains { $0.name == "rex_clearance" },
])
if status == 200 {
self.pollTimer?.invalidate()
self.pollTimer = nil
}
self.isPolling = false
}
}
}
}
+230
View File
@@ -0,0 +1,230 @@
import SwiftUI
import UIKit
/// Settings Tools "Connect route-explorer". Walks the user through
/// the Safari-bookmarklet flow that mints a route-explorer `/api/token`
/// in Safari (where Cloudflare Turnstile passes silently because Safari
/// holds the `com.apple.developer.web-browser` entitlement and is
/// eligible for Apple Private Access Tokens), captures it, and hands it
/// back to this app via the `flights://routeexplorer-token` URL scheme.
///
/// One-time setup steps shown to the user:
/// 1. Copy the bookmarklet JS (single button).
/// 2. In Safari, navigate to https://route-explorer.com/ open the
/// bookmarks editor (book icon Edit) tap "Add Bookmark" with
/// a recognizable name, then edit the bookmark's URL and paste the
/// JS over the http URL.
/// 3. Anytime later: open route-explorer.com in Safari, tap the
/// bookmarklet app comes to the foreground with a fresh token.
///
/// Daily-use steps (after setup):
/// Tap "Open route-explorer.com" to launch Safari at the right URL.
/// Tap the saved bookmarklet in Safari.
/// Return to the app token state at the top of this screen now
/// shows the new expiry.
struct RouteExplorerSetupView: View {
@StateObject private var store = RouteExplorerTokenStore.shared
@State private var didCopy: Bool = false
@State private var nowTick: Date = .init()
@State private var browserURL: URL?
private let tickTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
List {
browserSection
statusSection
actionsSection
instructionsSection
bookmarkletSection
advancedSection
}
.listStyle(.insetGrouped)
.navigationTitle("Connect route-explorer")
.navigationBarTitleDisplayMode(.inline)
.onReceive(tickTimer) { now in
nowTick = now
}
.fullScreenCover(item: $browserURL) { url in
NavigationStack {
RouteExplorerBrowserView(url: url)
.ignoresSafeArea()
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") { browserURL = nil }
}
}
}
}
}
// MARK: - In-app browser
/// Primary action: open route-explorer.com inside the app using
/// SFSafariViewController. This bypasses the bookmarklet entirely
/// the user just searches in the embedded browser.
private var browserSection: some View {
Section {
Button {
browserURL = URL(string: "https://route-explorer.com/")
} label: {
Label("Open route-explorer in-app", systemImage: "globe")
.font(.body.weight(.semibold))
}
} header: {
Text("Search")
} footer: {
Text("Opens route-explorer.com inside an embedded Safari browser. Multi-stop search, where-can-I-go, every feature — works because Safari (not WKWebView) passes Cloudflare Turnstile.")
}
}
// MARK: - Status
private var statusSection: some View {
Section {
HStack {
Image(systemName: store.isValid ? "checkmark.seal.fill" : "exclamationmark.triangle.fill")
.foregroundStyle(store.isValid ? .green : .orange)
VStack(alignment: .leading, spacing: 2) {
Text(store.isValid ? "Token active" : "No valid token").font(.subheadline.weight(.semibold))
if store.isValid {
Text("Expires in \(Self.formatRemaining(store.timeRemaining))")
.font(.caption).foregroundStyle(.secondary)
} else {
Text("Run the bookmarklet in Safari to refresh.")
.font(.caption).foregroundStyle(.secondary)
}
}
}
} header: {
Text("Token state")
}
}
// MARK: - Actions
private var actionsSection: some View {
Section {
Button {
if let url = URL(string: "https://route-explorer.com/") {
UIApplication.shared.open(url)
}
} label: {
Label("Open route-explorer.com in Safari", systemImage: "safari")
}
Button {
copyBookmarklet()
} label: {
Label(didCopy ? "Copied!" : "Copy bookmarklet to clipboard",
systemImage: didCopy ? "doc.on.doc.fill" : "doc.on.doc")
}
if store.isValid {
Button(role: .destructive) {
store.clear()
} label: {
Label("Clear stored token", systemImage: "trash")
}
}
}
}
// MARK: - Instructions
private var instructionsSection: some View {
Section {
stepRow(num: 1, text: "Tap **Copy bookmarklet to clipboard** above.")
stepRow(num: 2, text: "Tap **Open route-explorer.com in Safari**.")
stepRow(num: 3, text: "In Safari, tap the share button → **Add Bookmark**. Save it as e.g. \"Flights Token\".")
stepRow(num: 4, text: "Tap the bookmarks icon (book) → Edit → tap the new bookmark → replace its URL with the clipboard contents → Save.")
stepRow(num: 5, text: "Each refresh: open route-explorer.com in Safari → tap **Bookmarks → Flights Token**. The app will pop up with a fresh token.")
} header: {
Text("One-time setup")
} footer: {
Text("Tokens expire every ~30 minutes. Re-run the bookmarklet from step 5 anytime to refresh.")
}
}
private var bookmarkletSection: some View {
Section {
ScrollView(.horizontal, showsIndicators: false) {
Text(bookmarkletJS)
.font(.caption.monospaced())
.padding(.vertical, 4)
}
} header: {
Text("Bookmarklet JS")
} footer: {
Text("Reads route-explorer's /api/token (Safari has the Turnstile clearance cookie already), then redirects to flights:// with the token attached.")
}
}
private var advancedSection: some View {
Section {
Button("Test the URL scheme") {
testURLScheme()
}
.disabled(!store.isValid)
if let token = store.token {
HStack {
Text("Token").foregroundStyle(.secondary)
Spacer()
Text(String(token.prefix(12)) + "")
.font(.caption.monospaced())
}
}
if let cookie = store.capturedCookieHeader, !cookie.isEmpty {
Text("Captured cookies: \(cookie.prefix(80))")
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
} header: {
Text("Advanced")
}
}
// MARK: - Helpers
private func stepRow(num: Int, text: String) -> some View {
HStack(alignment: .top, spacing: 12) {
Text("\(num)")
.font(.footnote.weight(.bold).monospacedDigit())
.frame(width: 22, height: 22)
.background(Color.accentColor.opacity(0.15), in: Circle())
.foregroundStyle(.tint)
Text(.init(text))
.font(.footnote)
Spacer()
}
}
private func copyBookmarklet() {
UIPasteboard.general.string = bookmarkletJS
didCopy = true
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { didCopy = false }
}
private func testURLScheme() {
// Simulate Safari's redirect to verify the handler chain is alive.
let url = URL(string:
"flights://routeexplorer-token?token=TEST_TOKEN_FROM_DIAGNOSTIC&exp=\(Int(Date().timeIntervalSince1970 + 60))"
)!
UIApplication.shared.open(url)
}
private static func formatRemaining(_ seconds: TimeInterval) -> String {
let total = Int(seconds)
let m = total / 60
let s = total % 60
if m > 0 { return "\(m)m \(s)s" }
return "\(s)s"
}
/// Bookmarklet JS. Single-line `javascript:` URL the user pastes
/// into a Safari bookmark. Reads the token from the route-explorer
/// API (Safari has clearance), grabs any visible cookies, then
/// jumps back into our app via the custom URL scheme.
private var bookmarkletJS: String {
"""
javascript:(function(){fetch('/api/token',{credentials:'include'}).then(function(r){return r.json();}).then(function(t){var c=encodeURIComponent(document.cookie||'');var tok=encodeURIComponent(t.token||'');var exp=Math.floor(Date.now()/1000)+1800;window.location='flights://routeexplorer-token?token='+tok+'&exp='+exp+'&cookie='+c;}).catch(function(e){alert('route-explorer token fetch failed: '+e);});})();
"""
}
}
+635 -140
View File
@@ -1,144 +1,370 @@
import SwiftUI
/// Feature (a): pick origin + destination, find direct *and* multi-stop
/// itineraries via route-explorer.com `/route` with `maxStops`.
/// Home tab. One unified search.
///
/// - With a destination set: route-explorer `/route` returns directs + 1/2-stop
/// connections; results render as `ConnectionRow`s.
/// - With destination blank: route-explorer `/departures` (maxStops:0) returns
/// every flight leaving the origin; results render as compact `DepartureLegRow`s
/// filtered by the chosen time window.
///
/// Either way, tapping a result opens `ConnectionLoadDetailView`, which fans
/// load fetches across each leg in parallel and offers per-leg drill-down to
/// `FlightLoadDetailView` for waitlists / passenger lists.
struct RoutePlannerView: View {
let database: AirportDatabase
/// Retained for the "Where can I go?" path (no destination), which
/// still needs the route-explorer `/departures` endpoint. Direct
/// searches (destination set) now flow through ``flightAware``.
let client: RouteExplorerClient
/// Direct-flight schedule lookup via FlightAware. No Cloudflare
/// Turnstile, no auth used whenever a destination is set.
let flightAware: FlightAwareScheduleClient
let loadService: AirlineLoadService
// MARK: - Inputs
@State private var origin: MapAirport?
@State private var destination: MapAirport?
@State private var date: Date = Date()
// Connection-mode controls (visible only when destination is set)
@State private var maxStops: Int = 1
@State private var sortBy: RouteSortOption = .departureTime
@State private var connectionSort: RouteSortOption = .departureEarliest
@State private var includeInterline: Bool = false
// "Where can I go?" controls (visible only when destination is blank)
@State private var windowHours: Int = 6
@State private var referenceDate: Date = Date()
@State private var departureSort: RouteSortOption = .departureEarliest
// MARK: - Search state
@State private var isLoading: Bool = false
@State private var error: String?
@State private var connections: [RouteConnection] = []
@State private var appendix: RouteAppendix?
@State private var selectedFlight: FlightSchedule?
@State private var selectedDepCode: String = ""
@State private var selectedArrCode: String = ""
@State private var selectedDate: Date = Date()
@State private var pendingSheet: ConnectionLoadRequest?
/// Set to true when a search hits the route-explorer clearance gate
/// (`/api/token` 403 `reason:"clearance"`). Drives presentation of
/// `RouteExplorerGateSheet`; on its dismiss we automatically re-run
/// the search.
@State private var showClearanceGate: Bool = false
/// Set to a URL when the user taps "Open in route-explorer" pops a
/// fullscreen ``RouteExplorerBrowserView`` (SFSafariViewController)
/// so they can use the original site directly inside the app.
@State private var routeExplorerBrowserURL: URL?
private var hasDestination: Bool { destination != nil }
private var canSearch: Bool { origin != nil }
// MARK: - Body
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: FlightTheme.sectionSpacing) {
searchForm
resultsHeader
resultsList
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: FlightTheme.sectionSpacing) {
airportsCard
dateCard
if hasDestination {
connectionControls
} else {
whereCanIGoControls
}
searchButton
openInRouteExplorerButton
sortBar
resultsHeader
resultsList
}
.padding(.horizontal)
.padding(.vertical, 12)
}
.background(FlightTheme.background.ignoresSafeArea())
.navigationTitle("Flights")
.sheet(item: $pendingSheet) { req in
ConnectionLoadDetailView(
connection: req.connection,
appendix: req.appendix,
database: database,
loadService: loadService
)
}
.sheet(isPresented: $showClearanceGate) {
// Once user passes Turnstile (cookie lands in the shared
// WKWebsiteDataStore), the sheet auto-dismisses. We then
// re-fire the search, which now goes through cleanly.
RouteExplorerGateSheet()
.onDisappear {
Task { await runSearch() }
}
}
.fullScreenCover(item: $routeExplorerBrowserURL) { url in
NavigationStack {
RouteExplorerBrowserView(url: url)
.ignoresSafeArea()
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") { routeExplorerBrowserURL = nil }
}
}
}
}
.padding(.horizontal)
.padding(.vertical, 12)
}
.background(FlightTheme.background.ignoresSafeArea())
.navigationTitle("Connections")
.navigationBarTitleDisplayMode(.inline)
.sheet(item: $selectedFlight) { flight in
FlightLoadDetailView(
schedule: flight,
departureCode: selectedDepCode,
arrivalCode: selectedArrCode,
date: selectedDate,
loadService: loadService
}
// MARK: - Airports + date
private var airportsCard: some View {
VStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 12) {
Label {
Text("FROM")
.font(FlightTheme.label())
.foregroundStyle(.secondary)
.tracking(1)
} icon: {
Image(systemName: "airplane.departure")
.font(.caption)
.foregroundStyle(.secondary)
}
IATAAirportPicker(
label: "Origin (IATA or city)",
selection: $origin,
database: database
)
}
.padding(FlightTheme.cardPadding)
Divider().padding(.horizontal, FlightTheme.cardPadding)
VStack(alignment: .leading, spacing: 12) {
Label {
Text("TO (OPTIONAL)")
.font(FlightTheme.label())
.foregroundStyle(.secondary)
.tracking(1)
} icon: {
Image(systemName: "mappin.and.ellipse")
.font(.caption)
.foregroundStyle(.secondary)
}
IATAAirportPicker(
label: "Leave blank for \"where can I go?\"",
selection: $destination,
database: database
)
}
.padding(FlightTheme.cardPadding)
}
.background(FlightTheme.cardBackground)
.clipShape(RoundedRectangle(cornerRadius: FlightTheme.cardCornerRadius))
.shadow(color: FlightTheme.cardShadow, radius: 8, y: 2)
}
private var dateCard: some View {
HStack(spacing: 10) {
Image(systemName: "calendar")
.foregroundStyle(FlightTheme.accent)
.font(.body)
DatePicker(
hasDestination ? "Travel Date" : "Day to search",
selection: $date,
displayedComponents: .date
)
.labelsHidden()
.datePickerStyle(.compact)
.tint(FlightTheme.accent)
Spacer()
}
.flightCard()
}
// MARK: - Mode-specific controls
private var connectionControls: some View {
VStack(alignment: .leading, spacing: 10) {
Text("MAX STOPS")
.font(FlightTheme.label())
.foregroundStyle(.secondary)
.tracking(1)
Picker("Max stops", selection: $maxStops) {
Text("Direct").tag(0)
Text("1 stop").tag(1)
Text("2 stops").tag(2)
}
.pickerStyle(.segmented)
Toggle(isOn: $includeInterline) {
Text("Interline carriers only").font(.subheadline)
}
.padding(.top, 4)
.tint(FlightTheme.accent)
}
.flightCard()
}
private var whereCanIGoControls: some View {
VStack(alignment: .leading, spacing: 10) {
Text("DEPARTING WITHIN")
.font(FlightTheme.label())
.foregroundStyle(.secondary)
.tracking(1)
Picker("Window", selection: $windowHours) {
Text("2h").tag(2)
Text("4h").tag(4)
Text("6h").tag(6)
Text("12h").tag(12)
Text("24h").tag(24)
}
.pickerStyle(.segmented)
HStack(spacing: 10) {
Image(systemName: "clock")
.foregroundStyle(FlightTheme.accent)
.font(.body)
DatePicker(
"From",
selection: $referenceDate,
displayedComponents: [.date, .hourAndMinute]
)
.labelsHidden()
.datePickerStyle(.compact)
.tint(FlightTheme.accent)
Spacer()
Button("Now") {
referenceDate = Date()
}
.font(.caption.weight(.semibold))
.buttonStyle(.borderedProminent)
.tint(FlightTheme.accent.opacity(0.2))
.foregroundStyle(FlightTheme.accent)
}
.padding(.top, 6)
}
.flightCard()
}
// MARK: - Search button
/// Toolbar-style button under the main Search action that pops the
/// embedded route-explorer browser. The only viable path to
/// multi-stop / where-can-I-go-with-times, since our in-app WKWebView
/// can't pass Turnstile but SFSafariViewController can.
private var openInRouteExplorerButton: some View {
Button {
routeExplorerBrowserURL = makeRouteExplorerURL()
} label: {
HStack(spacing: 6) {
Image(systemName: "globe")
Text("Open in route-explorer")
.font(.footnote.weight(.semibold))
}
.foregroundStyle(FlightTheme.accent)
.padding(.vertical, 8)
.frame(maxWidth: .infinity)
.background(
RoundedRectangle(cornerRadius: 10)
.stroke(FlightTheme.accent.opacity(0.4), lineWidth: 1)
)
}
}
// MARK: - Search form
/// Build the deep link into route-explorer.com using whatever fields
/// the user has filled. Falls back to the homepage if the user
/// hasn't picked an origin yet.
private func makeRouteExplorerURL() -> URL {
guard let origin else {
return URL(string: "https://route-explorer.com/")!
}
let df = DateFormatter()
df.dateFormat = "yyyy-MM-dd"
df.timeZone = TimeZone(identifier: "UTC")
var comps = URLComponents(string: "https://route-explorer.com/")!
var items: [URLQueryItem] = [
URLQueryItem(name: "from", value: origin.iata),
URLQueryItem(name: "date", value: df.string(from: date)),
]
if let destination {
items.append(URLQueryItem(name: "to", value: destination.iata))
}
comps.queryItems = items
return comps.url ?? URL(string: "https://route-explorer.com/")!
}
private var searchForm: some View {
VStack(spacing: 12) {
VStack(alignment: .leading, spacing: 12) {
Label {
Text("FROM").font(FlightTheme.label()).tracking(1)
.foregroundStyle(.secondary)
} icon: {
Image(systemName: "airplane.departure").font(.caption).foregroundStyle(.secondary)
private var searchButton: some View {
Button {
Task { await runSearch() }
} label: {
HStack {
if isLoading {
ProgressView().tint(.white)
} else {
Image(systemName: hasDestination ? "magnifyingglass" : "questionmark.diamond")
}
IATAAirportPicker(label: "Origin (IATA or city)", selection: $origin, database: database)
Label {
Text("TO").font(FlightTheme.label()).tracking(1).foregroundStyle(.secondary)
} icon: {
Image(systemName: "mappin.and.ellipse").font(.caption).foregroundStyle(.secondary)
}
IATAAirportPicker(label: "Destination (IATA or city)", selection: $destination, database: database)
Text(searchButtonText).fontWeight(.bold)
}
.flightCard()
HStack(spacing: 10) {
Image(systemName: "calendar").foregroundStyle(FlightTheme.accent)
DatePicker("Travel Date", selection: $date, displayedComponents: .date)
.labelsHidden()
.datePickerStyle(.compact)
.tint(FlightTheme.accent)
Spacer()
}
.flightCard()
VStack(alignment: .leading, spacing: 10) {
Text("MAX STOPS")
.font(FlightTheme.label())
.foregroundStyle(.secondary)
.tracking(1)
Picker("Max stops", selection: $maxStops) {
Text("Direct").tag(0)
Text("1 stop").tag(1)
Text("2 stops").tag(2)
}
.pickerStyle(.segmented)
Text("SORT BY")
.font(FlightTheme.label())
.foregroundStyle(.secondary)
.tracking(1)
.padding(.top, 4)
Picker("Sort by", selection: $sortBy) {
ForEach(RouteSortOption.allCases, id: \.self) { option in
Text(option.label).tag(option)
}
}
.pickerStyle(.segmented)
Toggle(isOn: $includeInterline) {
Text("Interline carriers only")
.font(.subheadline)
}
.padding(.top, 4)
.tint(FlightTheme.accent)
}
.flightCard()
Button {
Task { await runSearch() }
} label: {
HStack {
if isLoading {
ProgressView().tint(.white)
} else {
Image(systemName: "magnifyingglass")
}
Text(isLoading ? "Searching..." : "Search Routes")
.fontWeight(.bold)
}
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: 50)
.background(
LinearGradient(
colors: [FlightTheme.accent, FlightTheme.accentLight],
startPoint: .leading,
endPoint: .trailing
)
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: 50)
.background(
LinearGradient(
colors: [FlightTheme.accent, FlightTheme.accentLight],
startPoint: .leading,
endPoint: .trailing
)
.clipShape(RoundedRectangle(cornerRadius: 12))
)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.disabled(!canSearch || isLoading)
.opacity(canSearch && !isLoading ? 1.0 : 0.5)
}
private var searchButtonText: String {
if isLoading { return "Loading..." }
return hasDestination ? "Search Routes" : "Where can I go?"
}
// MARK: - Sort bar
/// SORT BY picker, slotted between the search button and the results.
/// Hidden until there's something to reorder so the empty home isn't
/// cluttered with a control that doesn't apply yet.
@ViewBuilder
private var sortBar: some View {
if hasDestination, !sortedConnections.isEmpty {
sortPicker(
options: RouteSortOption.connectionOptions,
selection: $connectionSort
)
} else if !hasDestination, !filteredFlights.isEmpty {
sortPicker(
options: RouteSortOption.departureOptions,
selection: $departureSort
)
}
}
private func sortPicker(
options: [RouteSortOption],
selection: Binding<RouteSortOption>
) -> some View {
HStack(spacing: 8) {
Text("SORT BY")
.font(FlightTheme.label())
.foregroundStyle(.secondary)
.tracking(1)
Spacer()
Picker("Sort by", selection: selection) {
ForEach(options, id: \.self) { option in
Text(option.label).tag(option)
}
}
.disabled(!canSearch || isLoading)
.opacity(canSearch && !isLoading ? 1.0 : 0.5)
.pickerStyle(.menu)
.tint(FlightTheme.accent)
}
}
@@ -148,7 +374,7 @@ struct RoutePlannerView: View {
private var resultsHeader: some View {
if let error {
ContentUnavailableView {
Label("Error", systemImage: "exclamationmark.triangle")
Label("No results", systemImage: "exclamationmark.triangle")
} description: {
Text(error)
} actions: {
@@ -158,62 +384,331 @@ struct RoutePlannerView: View {
.buttonStyle(.borderedProminent)
.tint(FlightTheme.accent)
}
} else if !connections.isEmpty {
} else if hasDestination, !sortedConnections.isEmpty {
HStack {
Text("\(connections.count) itinerar\(connections.count == 1 ? "y" : "ies")")
Text("\(sortedConnections.count) itinerar\(sortedConnections.count == 1 ? "y" : "ies")")
.font(.subheadline.weight(.semibold))
.foregroundStyle(FlightTheme.textPrimary)
Spacer()
let pastDropped = connections.count - sortedConnections.count
if pastDropped > 0 {
Text("\(pastDropped) already departed")
.font(.caption)
.foregroundStyle(FlightTheme.textSecondary)
}
}
} else if !hasDestination, !filteredFlights.isEmpty {
HStack {
Text("\(filteredFlights.count) departure\(filteredFlights.count == 1 ? "" : "s")")
.font(.subheadline.weight(.semibold))
.foregroundStyle(FlightTheme.textPrimary)
Spacer()
Text("next \(windowHours)h")
.font(.caption)
.foregroundStyle(FlightTheme.textSecondary)
}
}
}
/// Connection-mode results: drop any connection whose first leg has
/// already departed (the API doesn't accept a from-time floor it
/// just returns the earliest 500 of the calendar day, which on a
/// same-day search is mostly already-past flights), then re-sort
/// per the user's pick.
private var sortedConnections: [RouteConnection] {
let now = Date()
return connections
.filter { $0.firstDeparture > now }
.sorted(by: connectionSort)
}
@ViewBuilder
private var resultsList: some View {
ForEach(connections) { connection in
ConnectionRow(connection: connection, appendix: appendix) { leg in
openLegDetail(leg)
if hasDestination {
ForEach(sortedConnections) { connection in
ConnectionRow(
connection: connection,
appendix: appendix,
database: database
) { _ in
openConnection(connection)
}
}
} else {
ForEach(filteredFlights, id: \.id) { leg in
Button {
openSingleLeg(leg)
} label: {
DepartureLegRow(
leg: leg,
appendix: appendix,
database: database,
referenceDate: referenceDate
)
}
.buttonStyle(.plain)
}
}
}
// MARK: - Helpers
private var canSearch: Bool {
origin != nil && destination != nil
/// Where-can-I-go results: flatten connections (each is a single leg
/// since maxStops:0), filter to the chosen window, and apply the
/// user's chosen sort.
private var filteredFlights: [RouteFlight] {
let windowEnd = referenceDate.addingTimeInterval(TimeInterval(windowHours * 3600))
return connections
.flatMap { $0.flights }
.filter { leg in
let dep = leg.departure.dateTime
return dep >= referenceDate && dep <= windowEnd
}
.sorted(by: departureSort)
}
// MARK: - Search action
private func runSearch() async {
guard let origin, let destination else { return }
guard let origin else { return }
isLoading = true
error = nil
connections = []
appendix = nil
do {
let result = try await client.searchRoutes(
from: origin.iata,
to: destination.iata,
date: date,
maxStops: maxStops,
includeInterline: includeInterline,
sortBy: sortBy,
limit: 100
)
self.connections = result.connections
self.appendix = result.appendix
if result.connections.isEmpty {
self.error = "No routes found from \(origin.iata) to \(destination.iata) on this date."
if let destination {
// Direct mode FlightAware. Connection-finding (multi-stop)
// is intentionally out of scope here: FlightAware exposes
// per-flight schedule, not joined itineraries, and replacing
// the route-explorer multi-stop solver isn't a v1 goal.
// The maxStops segmented picker is retained in the UI but
// the search itself is direct-only.
let result = try await flightAware.searchDirectFlights(
from: origin.iata,
to: destination.iata,
date: date
)
self.connections = result.connections
self.appendix = result.appendix
let now = Date()
let futureCount = result.connections.filter { $0.firstDeparture > now }.count
if result.connections.isEmpty {
self.error = "No direct flights found from \(origin.iata) to \(destination.iata) on this date. FlightAware only publishes schedules within ~48 hours of departure — try a date closer to today."
} else if futureCount == 0 {
self.error = "All direct flights from \(origin.iata) to \(destination.iata) on this date have already departed."
}
} else {
// Where-can-I-go mode /departures, plus a follow-up call
// for the next calendar day if the window crosses midnight.
let windowEnd = referenceDate.addingTimeInterval(TimeInterval(windowHours * 3600))
var allConnections: [RouteConnection] = []
var capturedAppendix: RouteAppendix?
let day1 = try await client.searchDepartures(
from: origin.iata,
date: referenceDate,
maxStops: 0,
limit: 200
)
allConnections.append(contentsOf: day1.connections)
capturedAppendix = day1.appendix
let cal = Calendar.current
if !cal.isDate(referenceDate, inSameDayAs: windowEnd) {
let day2 = try await client.searchDepartures(
from: origin.iata,
date: windowEnd,
maxStops: 0,
limit: 200
)
allConnections.append(contentsOf: day2.connections)
if capturedAppendix == nil { capturedAppendix = day2.appendix }
}
self.connections = allConnections
self.appendix = capturedAppendix
if filteredFlights.isEmpty {
self.error = "Nothing leaving \(origin.iata) in the next \(windowHours)h."
}
}
} catch RouteExplorerClient.ClientError.needsTokenRefresh {
// Token expired or never captured. The setup screen lives
// in Settings Tools; tell the user how to refresh.
isLoading = false
self.error = "Route-explorer token expired. Open Settings → Tools → Connect route-explorer, then tap the bookmarklet in Safari to refresh."
return
} catch RouteExplorerClient.ClientError.needsClearance {
// Legacy gate-clearance path no longer reachable in
// production (we removed the WKWebView fetch). Treat as
// a token-refresh prompt for consistency.
isLoading = false
self.error = "Route-explorer needs a fresh token. Open Settings → Tools → Connect route-explorer."
return
} catch let err as FlightAwareScheduleClient.ClientError {
self.error = err.errorDescription
} catch {
self.error = (error as? RouteExplorerClient.ClientError)?.errorDescription ?? error.localizedDescription
self.error = (error as? RouteExplorerClient.ClientError)?.errorDescription
?? error.localizedDescription
}
isLoading = false
}
private func openLegDetail(_ leg: RouteFlight) {
selectedDepCode = leg.departure.airportIata
selectedArrCode = leg.arrival.airportIata
selectedDate = leg.departure.dateTime
selectedFlight = leg.toFlightSchedule(appendix: appendix, on: leg.departure.dateTime)
// MARK: - Tap routing
/// Tap a connection (multi-stop or direct) present its full detail.
private func openConnection(_ connection: RouteConnection) {
pendingSheet = ConnectionLoadRequest(connection: connection, appendix: appendix)
}
/// Tap a single Where-can-I-go leg wrap it in a one-flight connection
/// so ConnectionLoadDetailView can render it the same way.
private func openSingleLeg(_ leg: RouteFlight) {
let single = RouteConnection(
durationMinutes: leg.durationMinutes,
score: 0,
flights: [leg]
)
pendingSheet = ConnectionLoadRequest(connection: single, appendix: appendix)
}
}
// MARK: - Departure leg row (Where-can-I-go mode results)
/// Compact card for a single departure in the Where-can-I-go results list.
/// IATA + airport name, time-of-day with a colored countdown, capacity pills.
/// Tapping opens the same `ConnectionLoadDetailView` as connection rows.
private struct DepartureLegRow: View {
let leg: RouteFlight
let appendix: RouteAppendix?
let database: AirportDatabase
let referenceDate: Date
private static let timeFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "HH:mm"
return f
}()
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top, spacing: 10) {
VStack(alignment: .leading, spacing: 2) {
// verbatim: prevents SwiftUI from running the Int through
// locale formatting and rendering "AA 6,380" with a comma.
Text(verbatim: "\(leg.carrierIata) \(leg.flightNumber)")
.font(.subheadline.weight(.bold))
.foregroundStyle(FlightTheme.textPrimary)
Text(airlineName)
.font(.caption)
.foregroundStyle(FlightTheme.textSecondary)
.lineLimit(1)
.truncationMode(.tail)
}
Spacer(minLength: 8)
VStack(alignment: .trailing, spacing: 2) {
Text(Self.timeFormatter.string(from: leg.departure.dateTime))
.font(.subheadline.weight(.semibold).monospaced())
.foregroundStyle(FlightTheme.textPrimary)
Text(leavesIn)
.font(.caption2)
.foregroundStyle(leavesInColor)
}
.fixedSize(horizontal: true, vertical: false)
}
HStack(spacing: 12) {
Text(leg.departure.airportIata)
.font(FlightTheme.airportCode(22))
.foregroundStyle(FlightTheme.textPrimary)
Image(systemName: "airplane")
.font(.footnote)
.foregroundStyle(FlightTheme.textTertiary)
.rotationEffect(.degrees(-45))
Text(leg.arrival.airportIata)
.font(FlightTheme.airportCode(22))
.foregroundStyle(FlightTheme.textPrimary)
Spacer(minLength: 0)
}
HStack(spacing: 8) {
Text("\(airportName(for: leg.departure.airportIata))\(airportName(for: leg.arrival.airportIata))")
.font(.caption)
.foregroundStyle(FlightTheme.textSecondary)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 8)
if let aircraft = aircraftLabel {
Text(aircraft)
.font(FlightTheme.label(11))
.foregroundStyle(FlightTheme.textSecondary)
.lineLimit(1)
.truncationMode(.tail)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color(.quaternarySystemFill), in: Capsule())
}
}
HStack(spacing: 8) {
if let total = leg.totalSeats {
metaPill("\(total) seats")
}
if let f = leg.classes?.first?.seats, f > 0 { metaPill("\(f)") }
if let j = leg.classes?.business?.seats, j > 0 { metaPill("\(j)") }
if let w = leg.classes?.premiumEconomy?.seats, w > 0 { metaPill("\(w)") }
if let y = leg.classes?.economy?.seats, y > 0 { metaPill("\(y)") }
Spacer()
Image(systemName: "chevron.right")
.font(.caption2)
.foregroundStyle(FlightTheme.textTertiary)
}
}
.flightCard()
}
private func metaPill(_ text: String) -> some View {
Text(text)
.font(.caption2.monospaced())
.foregroundStyle(FlightTheme.textSecondary)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(FlightTheme.accent.opacity(0.10), in: Capsule())
}
private var airlineName: String {
appendix?.airline(iata: leg.carrierIata)?.name ?? leg.carrierIata
}
private var aircraftLabel: String? {
guard let iata = leg.equipmentIata else { return nil }
return appendix?.equipment(iata: iata)?.name ?? iata
}
/// Bundled DB first (clean city names), then route-explorer appendix.
private func airportName(for iata: String) -> String {
if let m = database.airport(byIATA: iata) { return m.name }
if let n = appendix?.airport(iata: iata)?.cityName, !n.isEmpty { return n }
if let n = appendix?.airport(iata: iata)?.name, !n.isEmpty { return n }
return iata
}
private var leavesIn: String {
let mins = Int(leg.departure.dateTime.timeIntervalSince(referenceDate) / 60)
if mins < 0 { return "departed" }
if mins < 60 { return "in \(mins)m" }
let h = mins / 60
let m = mins % 60
if m == 0 { return "in \(h)h" }
return "in \(h)h \(m)m"
}
private var leavesInColor: Color {
let mins = Int(leg.departure.dateTime.timeIntervalSince(referenceDate) / 60)
switch mins {
case ..<30: return FlightTheme.cancelled // hurry
case 30..<90: return FlightTheme.delayed // soon
default: return FlightTheme.textSecondary
}
}
}
+421
View File
@@ -0,0 +1,421 @@
import SwiftUI
/// "How does this work?" surfaces every data source the app uses so
/// the user can judge what's live, what's curated, what's stale, and
/// what's hand-typed. Replaces a generic "About" screen with a
/// provenance-first layout: each card cites the actual source and pulls
/// the freshness date from the data file's `_meta` block when one exists.
///
/// Anything user-facing that doesn't have a clean cite-able source
/// (TSA baselines, the cascade-via-rotation fallback card) is labelled
/// honestly here so users know what they're looking at.
struct SettingsView: View {
@State private var btsMeta: BTSMetadata?
@State private var appVersion: String = ""
@State private var appBuild: String = ""
var body: some View {
NavigationStack {
List {
introSection
toolsSection
liveSection
curatedSection
historicalSection
personalSection
aboutSection
}
.listStyle(.insetGrouped)
.scrollContentBackground(.hidden)
.background(FlightTheme.background.ignoresSafeArea())
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.large)
.task {
await loadMetadata()
loadAppVersion()
}
}
}
// MARK: - Tools
/// Section that hosts the nonrev-reference tools that used to live
/// in their own tabs. Pulled under Settings so the tab bar stays
/// focused on the three working surfaces (Search, Live, History).
private var toolsSection: some View {
Section {
NavigationLink {
HubLoadsView()
} label: {
toolRow(icon: "chart.bar.xaxis",
title: "Hub load heatmap",
subtitle: "BTS-derived load tightness per hub")
}
NavigationLink {
RouteExplorerSetupView()
} label: {
toolRow(icon: "key.horizontal.fill",
title: "Connect route-explorer",
subtitle: "Bookmarklet token refresh — restores Search")
}
NavigationLink {
TurnstileDebugView()
} label: {
toolRow(icon: "shield.lefthalf.filled",
title: "Turnstile diagnostics",
subtitle: "Live WKWebView gate — cookies, probe, console")
}
NavigationLink {
DiagnosticsView()
} label: {
toolRow(icon: "doc.text.magnifyingglass",
title: "Diagnostics logs",
subtitle: "Full trace — share via AirDrop / email")
}
} header: {
sectionHeader("Tools")
}
}
private func toolRow(icon: String, title: String, subtitle: String) -> some View {
HStack(spacing: 12) {
Image(systemName: icon)
.foregroundStyle(FlightTheme.accent)
.frame(width: 28)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.footnote.weight(.semibold))
.foregroundStyle(FlightTheme.textPrimary)
Text(subtitle)
.font(.caption2)
.foregroundStyle(FlightTheme.textSecondary)
}
}
}
// MARK: - Sections
private var introSection: some View {
Section {
VStack(alignment: .leading, spacing: 8) {
Text("Three kinds of data")
.font(.subheadline.weight(.semibold))
.foregroundStyle(FlightTheme.textPrimary)
Text("**Live** — fetched fresh whenever you tap or refresh.\n**Reference** — curated tables shipped in the app; updated on app releases.\n**Yours** — flight history you log, stored on-device and synced to your iCloud.")
.font(.footnote)
.foregroundStyle(FlightTheme.textSecondary)
}
.padding(.vertical, 4)
}
}
// MARK: Live data
private var liveSection: some View {
Section {
sourceRow(
icon: "antenna.radiowaves.left.and.right",
title: "Live aircraft positions",
source: "Flightradar24 (primary) + OpenSky Network (fallback)",
detail: "Refreshes every 15 seconds while the Live tab is open, plus immediately when you bring the app back to the foreground. ADS-B feeds carry a 515 minute delay by design — what you see is the most recent fix each network has published.",
links: [
("Flightradar24", URL(string: "https://www.flightradar24.com/")!),
("OpenSky", URL(string: "https://opensky-network.org/")!),
],
isLive: true
)
sourceRow(
icon: "cloud.sun",
title: "Weather forecast",
source: "Open-Meteo",
detail: "Free, key-less public weather API. We fetch a 3-day hourly forecast for the departure and arrival airports and sample the hour closest to the flight's scheduled time. Risk band combines precipitation probability, weather code (thunderstorms), wind, and visibility.",
links: [("Open-Meteo", URL(string: "https://open-meteo.com/")!)],
isLive: true
)
sourceRow(
icon: "camera",
title: "Aircraft photos",
source: "planespotters.net",
detail: "Per-tail-number photos when the registration is known. The site is contributor-maintained — most recent photo per airframe is what surfaces, which is why special liveries appear naturally (photographers chase them first).",
links: [("planespotters.net", URL(string: "https://www.planespotters.net/")!)],
isLive: true
)
sourceRow(
icon: "magnifyingglass",
title: "Flight schedules (sister-flight finder)",
source: "FlightConnections",
detail: "When you open the Live detail sheet, the \"Other options today\" list queries FlightConnections for every flight on the route that day. The Search tab originally used route-explorer.com but their backend now requires a Cloudflare browser-verification step that we can't pass from a native app, so connection finding is currently out of reach without a backend proxy.",
links: [("FlightConnections", URL(string: "https://www.flightconnections.com/")!)],
isLive: true
)
} header: {
sectionHeader("Live data")
} footer: {
Text("Live data is fetched fresh and isn't cached past the moment it was used.")
.font(.caption2)
.foregroundStyle(FlightTheme.textTertiary)
}
}
// MARK: Curated / bundled reference data
private var curatedSection: some View {
Section {
sourceRow(
icon: "airplane",
title: "Aircraft seat counts",
source: "Each carrier's published fleet page",
detail: "Per-carrier per-aircraft-type seat counts (e.g. AA's 737-800 vs WN's 737-800 — different cabin layouts, different totals). Powers the equipment-swap card. Citation URL per entry.",
links: [],
isLive: false
)
sourceRow(
icon: "globe.americas",
title: "Airport + airline database",
source: "OpenFlights-derived bundle",
detail: "~4,500 airports (IATA, name, lat/lng) and 1,000+ airlines (IATA, ICAO, name) ship inside the app. Used everywhere — autocomplete, map, timezone resolution, route lookups.",
links: [("OpenFlights", URL(string: "https://openflights.org/")!)],
isLive: false
)
} header: {
sectionHeader("Reference data (curated, shipped with the app)")
} footer: {
Text("Curated data only updates when you install a new build of the app. Every entry has a `_meta` block in the bundled JSON tracking when it was last verified.")
.font(.caption2)
.foregroundStyle(FlightTheme.textTertiary)
}
}
// MARK: Historical / statistical data
private var historicalSection: some View {
Section {
sourceRow(
icon: "chart.line.uptrend.xyaxis",
title: "Load factor + on-time history",
source: btsSourceLabel,
detail: btsDetailString,
links: btsMeta?.sourceURLs.compactMap { URL(string: $0) }.map { ($0.host ?? "source", $0) } ?? [],
isLive: false
)
sourceRow(
icon: "chart.bar.xaxis",
title: "Hub load heatmap",
source: "Derived from BTS bundle (above)",
detail: "Per-airport aggregated load factor, weighted by route volume. Tap the chart icon on the Jumpseats tab to browse all hubs sorted tightest-first.",
links: [],
isLive: false
)
} header: {
sectionHeader("Historical data")
} footer: {
Text("BTS publishes data about 23 months behind real-time. A new app update is needed to ship a newer sample period.")
.font(.caption2)
.foregroundStyle(FlightTheme.textTertiary)
}
}
// MARK: Personal data
private var personalSection: some View {
Section {
sourceRow(
icon: "book.closed",
title: "Your flight history",
source: "Local SwiftData + iCloud (private DB)",
detail: "Every flight you log lives on this device and syncs to your iCloud account. We don't run a server, we don't have analytics, and we never see your data. Logging in on a second device pulls everything from CloudKit automatically.",
links: [],
isLive: false
)
sourceRow(
icon: "figure.stand.line.dotted.figure.stand",
title: "Standby outcomes",
source: "You",
detail: "When you tap a flight in History and pick \"Standby — Made\" or \"Standby — Bumped\", we record it alongside the flight. The Standby Stats card on History and the per-route success rate are computed entirely from your own log.",
links: [],
isLive: false
)
sourceRow(
icon: "exclamationmark.triangle",
title: "Save failures",
source: "DataIntegrityMonitor (in-app)",
detail: "If a SwiftData save throws (rare — disk full, CloudKit conflict), a red banner appears at the top of the screen telling you which operation failed. Dismissing the banner doesn't retry the save — it just hides the warning. Best practice: take a screenshot of any failed edit, restart the app, and re-enter it.",
links: [],
isLive: false
)
} header: {
sectionHeader("Your data")
} footer: {
Text("No analytics, no telemetry, no third-party servers in the flight-history loop. The only network calls are to the public sources listed above.")
.font(.caption2)
.foregroundStyle(FlightTheme.textTertiary)
}
}
// MARK: About
private var aboutSection: some View {
Section {
HStack {
Image(systemName: "info.circle")
.foregroundStyle(FlightTheme.accent)
.frame(width: 28)
Text("Version")
.font(.footnote)
Spacer()
Text("\(appVersion) (\(appBuild))")
.font(.footnote.monospaced())
.foregroundStyle(FlightTheme.textSecondary)
}
HStack {
Image(systemName: "exclamationmark.bubble")
.foregroundStyle(FlightTheme.accent)
.frame(width: 28)
Text("Reporting issues")
.font(.footnote)
Spacer()
Text("Save the failed screen and reach out directly")
.font(.caption)
.foregroundStyle(FlightTheme.textSecondary)
.multilineTextAlignment(.trailing)
}
} header: {
sectionHeader("About")
}
}
// MARK: - Row builders
@ViewBuilder
private func sourceRow(
icon: String,
title: String,
source: String,
detail: String,
links: [(String, URL)],
isLive: Bool,
warning: String? = nil
) -> some View {
DisclosureGroup {
VStack(alignment: .leading, spacing: 10) {
Text(detail)
.font(.footnote)
.foregroundStyle(FlightTheme.textSecondary)
.fixedSize(horizontal: false, vertical: true)
if let warning {
HStack(alignment: .top, spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.caption2)
.foregroundStyle(FlightTheme.cancelled)
Text(warning)
.font(.caption2.weight(.semibold))
.foregroundStyle(FlightTheme.cancelled)
}
.padding(.top, 2)
}
if !links.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text("SOURCES")
.font(.caption2.weight(.heavy))
.tracking(0.6)
.foregroundStyle(FlightTheme.textTertiary)
ForEach(links, id: \.1) { name, url in
Link(destination: url) {
HStack(spacing: 6) {
Image(systemName: "arrow.up.right.square")
.font(.caption2)
Text(name)
.font(.caption.weight(.medium))
}
.foregroundStyle(FlightTheme.accent)
}
}
}
.padding(.top, 4)
}
}
.padding(.top, 4)
} label: {
HStack(spacing: 12) {
Image(systemName: icon)
.foregroundStyle(FlightTheme.accent)
.frame(width: 28)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.footnote.weight(.semibold))
.foregroundStyle(FlightTheme.textPrimary)
Text(source)
.font(.caption2)
.foregroundStyle(FlightTheme.textSecondary)
.lineLimit(2)
}
Spacer(minLength: 4)
liveTag(isLive: isLive)
}
}
}
private func liveTag(isLive: Bool) -> some View {
Text(isLive ? "LIVE" : "REF")
.font(.caption2.weight(.heavy))
.tracking(0.6)
.foregroundStyle(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(isLive ? FlightTheme.onTime : FlightTheme.textTertiary)
.clipShape(Capsule())
}
private func sectionHeader(_ text: String) -> some View {
Text(text)
.font(.footnote.weight(.bold))
.tracking(0.5)
.foregroundStyle(FlightTheme.textSecondary)
}
// MARK: - Dynamic content helpers
private var btsSourceLabel: String {
guard let meta = btsMeta else {
return "US DOT Bureau of Transportation Statistics"
}
return "US DOT BTS — \(meta.sourcePeriod) (\(meta.recordCount) records)"
}
private var btsDetailString: String {
guard let meta = btsMeta else {
return "On-time percentages, average delay, cancellation rate, and average load factor per (carrier, flight number, origin, destination). Sourced from the DOT's Reporting Carrier On-Time Performance + T-100 Domestic Segment public datasets."
}
let carriers = meta.carriers.joined(separator: ", ")
return """
On-time percentages, average delay, cancellation rate, and average load factor per (carrier, flight number, origin, destination). Sourced from the DOT's Reporting Carrier On-Time Performance + T-100 Domestic Segment public datasets.
Sample period: \(meta.sourcePeriod) — generated \(meta.downloadedAt). Filter: routes with at least \(meta.minFlightsFilter) operated flights.
Carriers covered: \(carriers).
\(meta.notes)
"""
}
// MARK: - Loaders
private func loadMetadata() async {
let bts = await BTSDataStore.shared.metadata()
await MainActor.run {
self.btsMeta = bts
}
}
private func loadAppVersion() {
let info = Bundle.main.infoDictionary ?? [:]
appVersion = info["CFBundleShortVersionString"] as? String ?? ""
appBuild = info["CFBundleVersion"] as? String ?? ""
}
}
#Preview {
SettingsView()
}
+127
View File
@@ -0,0 +1,127 @@
import SwiftUI
/// Design system scoped to the History tab. The rest of the app uses
/// `FlightTheme`; the redesigned passport-style history uses its own
/// warm aerospace palette so the visual identity doesn't bleed into
/// Search or Live.
///
/// Palette commitment:
/// - Runway orange as identity color (vivid, hi-vis, aviation-coded)
/// - Midnight navy as the dark surface
/// - Warm cream paper as the light surface (passport-stock feel)
/// - Stamp green + foil gold as accents on dressed elements
enum HistoryStyle {
// MARK: - Palette
static let runwayOrange = Color(red: 1.00, green: 0.34, blue: 0.13) // #FF5722
static let runwayOrangeDeep = Color(red: 0.85, green: 0.27, blue: 0.07) // #D9461A
static let runwayOrangeSoft = Color(red: 1.00, green: 0.55, blue: 0.35) // #FF8C59
static let midnightNavy = Color(red: 0.04, green: 0.08, blue: 0.14) // #0A1424
static let inkNavy = Color(red: 0.08, green: 0.14, blue: 0.25) // #142440
static let nightSky = Color(red: 0.06, green: 0.12, blue: 0.22) // #0F1E38
static let creamPaper = Color(red: 0.96, green: 0.93, blue: 0.85) // #F4ECD8
static let creamPaperDeep = Color(red: 0.91, green: 0.87, blue: 0.77) // #E8DDC4
static let creamPaperSoft = Color(red: 0.98, green: 0.96, blue: 0.91) // #FAF5E8
static let stampGreen = Color(red: 0.18, green: 0.35, blue: 0.24) // #2D5A3D
static let goldFoil = Color(red: 0.78, green: 0.66, blue: 0.32) // #C8A951
// MARK: - Adaptive surfaces (dark/light aware)
/// Top-level page background.
static func background(_ scheme: ColorScheme) -> Color {
scheme == .dark ? midnightNavy : creamPaper
}
/// Standard card / panel.
static func card(_ scheme: ColorScheme) -> Color {
scheme == .dark ? inkNavy : creamPaperSoft
}
/// Secondary card (less prominence).
static func cardSubtle(_ scheme: ColorScheme) -> Color {
scheme == .dark ? nightSky : creamPaperDeep
}
/// Primary text color on the background.
static func ink(_ scheme: ColorScheme) -> Color {
scheme == .dark ? Color(red: 0.96, green: 0.93, blue: 0.85) : Color(red: 0.06, green: 0.10, blue: 0.18)
}
static func inkSecondary(_ scheme: ColorScheme) -> Color {
scheme == .dark ? Color.white.opacity(0.65) : Color.black.opacity(0.55)
}
static func inkTertiary(_ scheme: ColorScheme) -> Color {
scheme == .dark ? Color.white.opacity(0.4) : Color.black.opacity(0.35)
}
static func hairline(_ scheme: ColorScheme) -> Color {
scheme == .dark ? Color.white.opacity(0.08) : Color.black.opacity(0.08)
}
// MARK: - Hero card gradients
static let heroOrangeGradient = LinearGradient(
colors: [runwayOrange, runwayOrangeDeep],
startPoint: .topLeading, endPoint: .bottomTrailing
)
static let heroNavyGradient = LinearGradient(
colors: [inkNavy, midnightNavy],
startPoint: .topLeading, endPoint: .bottomTrailing
)
static let heroGoldGradient = LinearGradient(
colors: [goldFoil, Color(red: 0.55, green: 0.46, blue: 0.20)],
startPoint: .top, endPoint: .bottom
)
static let heroGreenGradient = LinearGradient(
colors: [stampGreen, Color(red: 0.10, green: 0.22, blue: 0.15)],
startPoint: .topLeading, endPoint: .bottomTrailing
)
// MARK: - Typography
/// Display weight used for hero numbers like "47,200 mi".
static func displayNumber(_ size: CGFloat) -> Font {
.system(size: size, weight: .heavy, design: .default)
.monospacedDigit()
}
static func label(_ size: CGFloat = 11) -> Font {
.system(size: size, weight: .semibold, design: .default)
}
/// OCR-passport flavor text font monospaced, slightly condensed feel.
static let ocrFont: Font = .system(size: 11, weight: .regular, design: .monospaced)
static let cardTitleFont: Font = .system(size: 13, weight: .semibold, design: .default)
}
// MARK: - View modifiers
extension View {
/// Bevel-style card chrome used across history surfaces.
func historyCard(_ scheme: ColorScheme, padding: CGFloat = 16, cornerRadius: CGFloat = 18) -> some View {
self
.padding(padding)
.background(HistoryStyle.card(scheme), in: RoundedRectangle(cornerRadius: cornerRadius))
.overlay(
RoundedRectangle(cornerRadius: cornerRadius)
.stroke(HistoryStyle.hairline(scheme), lineWidth: 0.5)
)
}
/// Tracking + uppercase wrapping for section labels and "FLIGHTS" etc.
func historyLabel() -> some View {
self
.font(HistoryStyle.label())
.tracking(1.2)
.textCase(.uppercase)
}
}
+501
View File
@@ -0,0 +1,501 @@
import SwiftUI
import WebKit
/// Diagnostic harness for the route-explorer Cloudflare Turnstile gate.
/// Surfaces every signal that's hidden inside the production
/// ``RouteExplorerGateSheet``:
///
/// * The WKWebView itself (visible & interactive so we can see if
/// the Turnstile widget even renders).
/// * Live cookie dump for `route-explorer.com` from the shared
/// `WKWebsiteDataStore.default()`.
/// * `/api/token` probe each tick both via in-page `fetch()` and via
/// URLSession-with-replayed-cookies, so we can spot the case where
/// the WebView gets cleared but URLSession still gets 403.
/// * Console messages (`console.log` etc.) bridged through a
/// `WKScriptMessageHandler` so JS errors aren't invisible.
/// * Knobs to flip the variables we think might matter (UA flavour,
/// pre-warm, webdriver override) without rebuilding.
///
/// Goal: identify *which* of three failure modes we're in
/// A. Turnstile widget doesn't render in WKWebView at all.
/// B. Widget renders, can be solved, cookie lands but URLSession
/// replay still fails (cookie scope / TLS fingerprint issue).
/// C. Widget renders and the cookie correctly carries into both the
/// in-page probe AND URLSession replay meaning gate sheet should
/// already work and the bug is elsewhere.
struct TurnstileDebugView: View {
// MARK: - Knobs
enum UAFlavour: String, CaseIterable, Identifiable {
case iOSSafari17 = "iOS Safari 17.5"
case iOSSafari18 = "iOS Safari 18.5"
case macSafari = "Mac Safari 17.5"
case webViewDefault = "WKWebView default"
var id: String { rawValue }
var ua: String? {
switch self {
case .iOSSafari17:
return "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) "
+ "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 "
+ "Mobile/15E148 Safari/604.1"
case .iOSSafari18:
return "Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) "
+ "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 "
+ "Mobile/15E148 Safari/604.1"
case .macSafari:
return "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) "
+ "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 "
+ "Safari/605.1.15"
case .webViewDefault:
return nil
}
}
}
@State private var ua: UAFlavour = .iOSSafari17
@State private var injectWebdriverOverride: Bool = true
@State private var injectLanguageHeader: Bool = true
@State private var prewarmHomepage: Bool = false
@State private var status: String = "Idle"
@State private var cookies: [String: String] = [:]
@State private var lastTokenStatus: Int = -2
@State private var lastURLSessionStatus: Int = -2
@State private var consoleLines: [String] = []
@State private var probeTick: Int = 0
@State private var seed: Int = 0 // bumping this rebuilds the WebView
/// Every state-change writes a single line to ~/Documents/turnstile.log
/// inside the app's container so the CLI harness can `tail -F` it from
/// outside the sim. Format: `ISO8601\tevent\tkey=value\t...`.
@State private var logFileURL: URL? = {
guard let docs = FileManager.default.urls(
for: .documentDirectory, in: .userDomainMask
).first else { return nil }
let url = docs.appendingPathComponent("turnstile.log")
// Truncate on each fresh session so we don't read stale history.
try? "".write(to: url, atomically: true, encoding: .utf8)
return url
}()
private func appendLog(_ line: String) {
guard let url = logFileURL else { return }
let ts = ISO8601DateFormatter().string(from: Date())
let entry = "\(ts)\t\(line)\n"
if let data = entry.data(using: .utf8) {
if let handle = try? FileHandle(forWritingTo: url) {
_ = try? handle.seekToEnd()
try? handle.write(contentsOf: data)
try? handle.close()
} else {
try? data.write(to: url)
}
}
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
knobsCard
statusCard
cookieCard
consoleCard
webViewCard
replayCard
}
.padding()
}
.navigationTitle("Turnstile Diagnostics")
.navigationBarTitleDisplayMode(.inline)
}
// MARK: - Cards
private var knobsCard: some View {
VStack(alignment: .leading, spacing: 12) {
Text("KNOBS").font(.caption.weight(.heavy)).foregroundStyle(.secondary)
Picker("User agent", selection: $ua) {
ForEach(UAFlavour.allCases) { Text($0.rawValue).tag($0) }
}
Toggle("Override navigator.webdriver = undefined", isOn: $injectWebdriverOverride)
.font(.footnote)
Toggle("Inject Accept-Language: en-US,en;q=0.9", isOn: $injectLanguageHeader)
.font(.footnote)
Toggle("Pre-warm apple.com before route-explorer.com", isOn: $prewarmHomepage)
.font(.footnote)
Button("Rebuild WebView with current knobs") {
consoleLines = []
cookies = [:]
status = "Rebuilding…"
seed += 1
}
.buttonStyle(.borderedProminent)
Button("Wipe route-explorer cookies (data store)") {
Task { await wipeCookies() }
}
.buttonStyle(.bordered)
}
.padding()
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10))
}
private var statusCard: some View {
VStack(alignment: .leading, spacing: 6) {
Text("STATUS").font(.caption.weight(.heavy)).foregroundStyle(.secondary)
HStack {
Image(systemName: lastTokenStatus == 200 ? "checkmark.seal.fill" : "shield.lefthalf.filled")
.foregroundStyle(lastTokenStatus == 200 ? .green : .orange)
Text(status).font(.footnote.monospaced())
}
HStack {
tag("WebView /api/token", value: tokenLabel(lastTokenStatus))
tag("URLSession /api/token", value: tokenLabel(lastURLSessionStatus))
}
Text("probe #\(probeTick)").font(.caption2).foregroundStyle(.secondary)
}
.padding()
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10))
}
private var cookieCard: some View {
VStack(alignment: .leading, spacing: 6) {
Text("COOKIES (route-explorer.com)").font(.caption.weight(.heavy)).foregroundStyle(.secondary)
if cookies.isEmpty {
Text("none").font(.footnote.monospaced()).foregroundStyle(.secondary)
} else {
ForEach(cookies.keys.sorted(), id: \.self) { name in
HStack(alignment: .firstTextBaseline) {
Text(name).font(.caption.monospaced().weight(.semibold))
Spacer()
Text(cookies[name]?.prefix(40).description ?? "")
.font(.caption2.monospaced()).foregroundStyle(.secondary)
.lineLimit(1).truncationMode(.middle)
}
}
}
}
.padding()
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10))
}
private var consoleCard: some View {
VStack(alignment: .leading, spacing: 6) {
Text("JS CONSOLE / NAV LOG").font(.caption.weight(.heavy)).foregroundStyle(.secondary)
if consoleLines.isEmpty {
Text("no messages yet").font(.footnote.monospaced()).foregroundStyle(.secondary)
} else {
ForEach(consoleLines.suffix(14), id: \.self) { line in
Text(line).font(.caption2.monospaced()).foregroundStyle(.secondary)
.lineLimit(2).truncationMode(.tail)
}
}
}
.padding()
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10))
}
private var webViewCard: some View {
VStack(alignment: .leading, spacing: 6) {
Text("WEBVIEW").font(.caption.weight(.heavy)).foregroundStyle(.secondary)
Text("Solve Turnstile in this view if it renders. The card auto-polls.")
.font(.caption).foregroundStyle(.secondary)
DebugTurnstileWebView(
seed: seed,
ua: ua.ua,
injectWebdriverOverride: injectWebdriverOverride,
injectLanguageHeader: injectLanguageHeader,
prewarmHomepage: prewarmHomepage,
onTokenStatus: { status, tick in
self.lastTokenStatus = status
self.probeTick = tick
self.status = "WebView /api/token → \(self.tokenLabel(status)) (probe #\(tick))"
self.appendLog("probe\ttick=\(tick)\twebview_status=\(status)")
},
onCookies: { dict in
self.cookies = dict
let summary = dict.keys.sorted().joined(separator: ",")
self.appendLog("cookies\tcount=\(dict.count)\tnames=\(summary)")
},
onConsole: { msg in
self.consoleLines.append(msg)
self.appendLog("console\t\(msg.prefix(200))")
}
)
.frame(height: 480)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
.padding()
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10))
}
private var replayCard: some View {
VStack(alignment: .leading, spacing: 6) {
Text("URLSESSION REPLAY").font(.caption.weight(.heavy)).foregroundStyle(.secondary)
Text("Hits /api/token via URLSession (not the WebView), copying the current WKWebsiteDataStore cookies into HTTPCookieStorage. Tells us if a cleared cookie travels.")
.font(.caption).foregroundStyle(.secondary)
Button("Run URLSession replay") {
Task { await runURLSessionReplay() }
}
.buttonStyle(.borderedProminent)
}
.padding()
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10))
}
// MARK: - Actions
private func wipeCookies() async {
let store = WKWebsiteDataStore.default()
let types: Set<String> = [WKWebsiteDataTypeCookies, WKWebsiteDataTypeLocalStorage,
WKWebsiteDataTypeSessionStorage]
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
store.removeData(ofTypes: types, modifiedSince: .distantPast) {
cont.resume()
}
}
cookies = [:]
status = "Cookies wiped"
}
private func runURLSessionReplay() async {
let store = WKWebsiteDataStore.default()
let wkCookies = await store.httpCookieStore.allCookies()
let storage = HTTPCookieStorage()
for c in wkCookies where c.domain.contains("route-explorer.com") {
storage.setCookie(c)
}
let config = URLSessionConfiguration.default
config.httpCookieStorage = storage
config.httpCookieAcceptPolicy = .always
if injectLanguageHeader {
config.httpAdditionalHeaders = ["Accept-Language": "en-US,en;q=0.9"]
}
let session = URLSession(configuration: config)
var req = URLRequest(url: URL(string: "https://route-explorer.com/api/token")!)
if let ua = ua.ua { req.setValue(ua, forHTTPHeaderField: "User-Agent") }
req.setValue("application/json", forHTTPHeaderField: "Accept")
req.setValue("https://route-explorer.com/", forHTTPHeaderField: "Referer")
req.setValue("https://route-explorer.com", forHTTPHeaderField: "Origin")
do {
let (data, response) = try await session.data(for: req)
let http = response as? HTTPURLResponse
lastURLSessionStatus = http?.statusCode ?? -1
let body = String(data: data, encoding: .utf8) ?? ""
consoleLines.append("URLSession replay → \(lastURLSessionStatus): \(body.prefix(160))")
} catch {
lastURLSessionStatus = -1
consoleLines.append("URLSession replay → error: \(error.localizedDescription)")
}
}
// MARK: - Helpers
private func tokenLabel(_ s: Int) -> String {
switch s {
case -2: return ""
case -1: return "err"
default: return "\(s)"
}
}
private func tag(_ label: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(label).font(.caption2).foregroundStyle(.secondary)
Text(value).font(.footnote.monospaced().weight(.bold))
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
// MARK: - WKWebView host
private struct DebugTurnstileWebView: UIViewRepresentable {
let seed: Int
let ua: String?
let injectWebdriverOverride: Bool
let injectLanguageHeader: Bool
let prewarmHomepage: Bool
let onTokenStatus: (Int, Int) -> Void
let onCookies: ([String: String]) -> Void
let onConsole: (String) -> Void
func makeCoordinator() -> Coordinator {
Coordinator(onTokenStatus: onTokenStatus,
onCookies: onCookies,
onConsole: onConsole)
}
func makeUIView(context: Context) -> WKWebView {
rebuild(context: context)
}
func updateUIView(_ uiView: WKWebView, context: Context) {
// Seed changing wipe and re-create. We can't mutate the WKWebView
// in place because some config knobs (data store, content controller)
// are immutable post-init.
if context.coordinator.lastSeed != seed {
context.coordinator.lastSeed = seed
context.coordinator.detach()
context.coordinator.attach(rebuild(context: context))
}
}
private func rebuild(context: Context) -> WKWebView {
let config = WKWebViewConfiguration()
config.websiteDataStore = .default()
let contentController = WKUserContentController()
// Bridge console.log coordinator
let consoleBridge = """
(function() {
const orig = window.console;
const send = (lvl, args) => {
try {
window.webkit.messageHandlers.console.postMessage(
lvl + ": " + Array.from(args).map(a =>
(typeof a === 'object' ? JSON.stringify(a) : String(a))
).join(' ')
);
} catch (e) {}
};
['log','info','warn','error','debug'].forEach(lvl => {
const f = orig[lvl];
orig[lvl] = function(...args) { send(lvl, args); return f.apply(orig, args); };
});
})();
"""
contentController.addUserScript(WKUserScript(
source: consoleBridge,
injectionTime: .atDocumentStart,
forMainFrameOnly: false
))
if injectWebdriverOverride {
let override = """
Object.defineProperty(navigator, 'webdriver', {get: () => undefined});
"""
contentController.addUserScript(WKUserScript(
source: override,
injectionTime: .atDocumentStart,
forMainFrameOnly: false
))
}
contentController.add(context.coordinator, name: "console")
config.userContentController = contentController
let webView = WKWebView(frame: .zero, configuration: config)
webView.customUserAgent = ua
webView.navigationDelegate = context.coordinator
context.coordinator.attach(webView)
var req = URLRequest(url: URL(string:
prewarmHomepage ? "https://www.apple.com/" : "https://route-explorer.com/"
)!)
if injectLanguageHeader {
req.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language")
}
context.coordinator.targetURL = URL(string: "https://route-explorer.com/")!
context.coordinator.prewarming = prewarmHomepage
webView.load(req)
return webView
}
final class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler {
var lastSeed: Int = 0
var prewarming: Bool = false
var targetURL: URL?
private weak var webView: WKWebView?
private var pollTimer: Timer?
private var pollingInFlight = false
private var tick = 0
let onTokenStatus: (Int, Int) -> Void
let onCookies: ([String: String]) -> Void
let onConsole: (String) -> Void
init(onTokenStatus: @escaping (Int, Int) -> Void,
onCookies: @escaping ([String: String]) -> Void,
onConsole: @escaping (String) -> Void) {
self.onTokenStatus = onTokenStatus
self.onCookies = onCookies
self.onConsole = onConsole
}
func attach(_ webView: WKWebView) {
self.webView = webView
}
func detach() {
pollTimer?.invalidate()
pollTimer = nil
webView?.stopLoading()
webView = nil
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
onConsole("nav: didFinish url=\(webView.url?.absoluteString ?? "?")")
// If we pre-warmed apple.com, jump to route-explorer now.
if prewarming, let target = targetURL, webView.url?.host?.contains("apple.com") == true {
prewarming = false
webView.load(URLRequest(url: target))
return
}
startPolling()
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
onConsole("nav: didFail \(error.localizedDescription)")
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
onConsole("nav: didFailProvisional \(error.localizedDescription)")
}
// MARK: WKScriptMessageHandler
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
if message.name == "console", let body = message.body as? String {
onConsole("js: \(body.prefix(180))")
}
}
// MARK: Polling
private func startPolling() {
guard pollTimer == nil else { return }
pollTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: true) { [weak self] _ in
Task { @MainActor in self?.probe() }
}
// First probe immediately.
Task { @MainActor in self.probe() }
}
@MainActor
private func probe() {
guard let webView, !pollingInFlight else { return }
pollingInFlight = true
tick += 1
let myTick = tick
let js = """
return await new Promise((res) => {
fetch('/api/token', { credentials: 'include' })
.then(r => r.text().then(t => res({status: r.status, body: t})))
.catch(e => res({status: -1, body: String(e)}));
});
"""
Task {
let result = try? await webView.callAsyncJavaScript(
js, contentWorld: .page
)
let dict = result as? [String: Any]
let status = dict?["status"] as? Int ?? -1
self.onTokenStatus(status, myTick)
// Refresh cookie snapshot
let wkCookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
var snapshot: [String: String] = [:]
for c in wkCookies where c.domain.contains("route-explorer.com") {
snapshot[c.name] = c.value
}
self.onCookies(snapshot)
self.pollingInFlight = false
}
}
}
}
-348
View File
@@ -1,348 +0,0 @@
import SwiftUI
/// Feature (b): "Where tf do I go" pick an airport and see all departures
/// in the next N hours, ranked by departure time.
struct WhereToGoView: View {
let database: AirportDatabase
let client: RouteExplorerClient
let loadService: AirlineLoadService
@State private var origin: MapAirport?
@State private var windowHours: Int = 6
@State private var referenceDate: Date = Date()
@State private var isLoading: Bool = false
@State private var error: String?
@State private var connections: [RouteConnection] = []
@State private var appendix: RouteAppendix?
@State private var selectedFlight: FlightSchedule?
@State private var selectedDepCode: String = ""
@State private var selectedArrCode: String = ""
@State private var selectedDate: Date = Date()
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: FlightTheme.sectionSpacing) {
pickerForm
resultsHeader
resultsList
}
.padding(.horizontal)
.padding(.vertical, 12)
}
.background(FlightTheme.background.ignoresSafeArea())
.navigationTitle("Where can I go?")
.navigationBarTitleDisplayMode(.inline)
.sheet(item: $selectedFlight) { flight in
FlightLoadDetailView(
schedule: flight,
departureCode: selectedDepCode,
arrivalCode: selectedArrCode,
date: selectedDate,
loadService: loadService
)
}
}
// MARK: - Picker form
private var pickerForm: some View {
VStack(spacing: 12) {
VStack(alignment: .leading, spacing: 12) {
Label {
Text("FROM").font(FlightTheme.label()).tracking(1)
.foregroundStyle(.secondary)
} icon: {
Image(systemName: "airplane.departure").font(.caption).foregroundStyle(.secondary)
}
IATAAirportPicker(label: "Airport (IATA or city)", selection: $origin, database: database)
}
.flightCard()
VStack(alignment: .leading, spacing: 10) {
Text("DEPARTING WITHIN")
.font(FlightTheme.label())
.foregroundStyle(.secondary)
.tracking(1)
Picker("Window", selection: $windowHours) {
Text("2h").tag(2)
Text("4h").tag(4)
Text("6h").tag(6)
Text("12h").tag(12)
Text("24h").tag(24)
}
.pickerStyle(.segmented)
HStack(spacing: 10) {
Image(systemName: "calendar")
.foregroundStyle(FlightTheme.accent)
.font(.body)
DatePicker("From", selection: $referenceDate, displayedComponents: [.date, .hourAndMinute])
.labelsHidden()
.datePickerStyle(.compact)
.tint(FlightTheme.accent)
Spacer()
Button("Now") {
referenceDate = Date()
}
.font(.caption.weight(.semibold))
.buttonStyle(.borderedProminent)
.tint(FlightTheme.accent.opacity(0.2))
.foregroundStyle(FlightTheme.accent)
}
.padding(.top, 6)
}
.flightCard()
Button {
Task { await runSearch() }
} label: {
HStack {
if isLoading {
ProgressView().tint(.white)
} else {
Image(systemName: "questionmark.diamond")
}
Text(isLoading ? "Loading..." : "Where can I go?")
.fontWeight(.bold)
}
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: 50)
.background(
LinearGradient(
colors: [FlightTheme.accent, FlightTheme.accentLight],
startPoint: .leading,
endPoint: .trailing
)
)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.disabled(origin == nil || isLoading)
.opacity((origin != nil && !isLoading) ? 1.0 : 0.5)
}
}
// MARK: - Results
@ViewBuilder
private var resultsHeader: some View {
if let error {
ContentUnavailableView {
Label("Error", systemImage: "exclamationmark.triangle")
} description: {
Text(error)
} actions: {
Button("Retry") {
Task { await runSearch() }
}
.buttonStyle(.borderedProminent)
.tint(FlightTheme.accent)
}
} else if !filteredFlights.isEmpty {
HStack {
Text("\(filteredFlights.count) departure\(filteredFlights.count == 1 ? "" : "s")")
.font(.subheadline.weight(.semibold))
.foregroundStyle(FlightTheme.textPrimary)
Spacer()
Text(windowDescription)
.font(.caption)
.foregroundStyle(FlightTheme.textSecondary)
}
}
}
@ViewBuilder
private var resultsList: some View {
ForEach(filteredFlights, id: \.id) { leg in
Button {
openLegDetail(leg)
} label: {
DepartureLegRow(leg: leg, appendix: appendix, referenceDate: referenceDate)
}
.buttonStyle(.plain)
}
}
// MARK: - Filtering
/// Flatten connections (each is a single leg here since we requested
/// /departures with maxStops:0) and filter by departure-time window.
private var filteredFlights: [RouteFlight] {
let windowEnd = referenceDate.addingTimeInterval(TimeInterval(windowHours * 3600))
let allLegs = connections.flatMap { $0.flights }
return allLegs
.filter { leg in
let dep = leg.departure.dateTime
return dep >= referenceDate && dep <= windowEnd
}
.sorted { $0.departure.dateTime < $1.departure.dateTime }
}
private var windowDescription: String {
"next \(windowHours)h"
}
private func runSearch() async {
guard let origin else { return }
isLoading = true
error = nil
connections = []
appendix = nil
do {
// /departures returns one connection per single-leg flight when
// maxStops:0. We pass the calendar date that includes our window;
// if the window crosses midnight we'll fall back to also fetching
// the next day in a follow-up call.
let windowEnd = referenceDate.addingTimeInterval(TimeInterval(windowHours * 3600))
var allConnections: [RouteConnection] = []
var capturedAppendix: RouteAppendix?
let day1 = try await client.searchDepartures(from: origin.iata, date: referenceDate, maxStops: 0, limit: 200)
allConnections.append(contentsOf: day1.connections)
capturedAppendix = day1.appendix
// Cross-midnight: fetch next day too.
let cal = Calendar.current
if !cal.isDate(referenceDate, inSameDayAs: windowEnd) {
let day2 = try await client.searchDepartures(from: origin.iata, date: windowEnd, maxStops: 0, limit: 200)
allConnections.append(contentsOf: day2.connections)
if capturedAppendix == nil { capturedAppendix = day2.appendix }
}
self.connections = allConnections
self.appendix = capturedAppendix
if filteredFlights.isEmpty {
self.error = "Nothing leaving \(origin.iata) in the next \(windowHours)h."
}
} catch {
self.error = (error as? RouteExplorerClient.ClientError)?.errorDescription ?? error.localizedDescription
}
isLoading = false
}
private func openLegDetail(_ leg: RouteFlight) {
selectedDepCode = leg.departure.airportIata
selectedArrCode = leg.arrival.airportIata
selectedDate = leg.departure.dateTime
selectedFlight = leg.toFlightSchedule(appendix: appendix, on: leg.departure.dateTime)
}
}
// MARK: - Departure leg row
private struct DepartureLegRow: View {
let leg: RouteFlight
let appendix: RouteAppendix?
let referenceDate: Date
private static let timeFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "HH:mm"
return f
}()
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .center, spacing: 10) {
VStack(alignment: .leading, spacing: 2) {
Text("\(leg.carrierIata) \(leg.flightNumber)")
.font(.subheadline.weight(.bold))
.foregroundStyle(FlightTheme.textPrimary)
Text(airlineName)
.font(.caption)
.foregroundStyle(FlightTheme.textSecondary)
.lineLimit(1)
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(Self.timeFormatter.string(from: leg.departure.dateTime))
.font(.subheadline.weight(.semibold).monospaced())
.foregroundStyle(FlightTheme.textPrimary)
Text(leavesIn)
.font(.caption2)
.foregroundStyle(leavesInColor)
}
}
HStack(spacing: 8) {
Text(leg.departure.airportIata)
.font(FlightTheme.airportCode(20))
Image(systemName: "airplane")
.font(.caption)
.foregroundStyle(FlightTheme.textTertiary)
.rotationEffect(.degrees(-45))
Text(leg.arrival.airportIata)
.font(FlightTheme.airportCode(20))
Spacer()
if let aircraft = aircraftLabel {
Text(aircraft)
.font(FlightTheme.label(11))
.foregroundStyle(FlightTheme.textSecondary)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color(.quaternarySystemFill), in: Capsule())
}
}
HStack(spacing: 8) {
if let total = leg.totalSeats {
metaPill("\(total) seats")
}
if let f = leg.classes?.first?.seats, f > 0 { metaPill("\(f)") }
if let j = leg.classes?.business?.seats, j > 0 { metaPill("\(j)") }
if let w = leg.classes?.premiumEconomy?.seats, w > 0 { metaPill("\(w)") }
if let y = leg.classes?.economy?.seats, y > 0 { metaPill("\(y)") }
Spacer()
Image(systemName: "chevron.right")
.font(.caption2)
.foregroundStyle(FlightTheme.textTertiary)
}
}
.flightCard()
}
private func metaPill(_ text: String) -> some View {
Text(text)
.font(.caption2.monospaced())
.foregroundStyle(FlightTheme.textSecondary)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(FlightTheme.accent.opacity(0.10), in: Capsule())
}
private var airlineName: String {
appendix?.airline(iata: leg.carrierIata)?.name ?? leg.carrierIata
}
private var aircraftLabel: String? {
guard let iata = leg.equipmentIata else { return nil }
return appendix?.equipment(iata: iata)?.name ?? iata
}
private var leavesIn: String {
let mins = Int(leg.departure.dateTime.timeIntervalSince(referenceDate) / 60)
if mins < 0 { return "departed" }
if mins < 60 { return "in \(mins)m" }
let h = mins / 60
let m = mins % 60
if m == 0 { return "in \(h)h" }
return "in \(h)h \(m)m"
}
private var leavesInColor: Color {
let mins = Int(leg.departure.dateTime.timeIntervalSince(referenceDate) / 60)
switch mins {
case ..<30: return FlightTheme.cancelled // hurry
case 30..<90: return FlightTheme.delayed // soon
default: return FlightTheme.textSecondary
}
}
}
+211
View File
@@ -0,0 +1,211 @@
import SwiftUI
/// Year in Review horizontal-paged deck of share-ready hero cards
/// for the chosen year. Each card is a full-screen composition: huge
/// stat number, small subtitle, footer brand mark.
struct YearInReviewView: View {
let stats: StatsEngine
let year: Int
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var scheme
var body: some View {
let yearFlights = stats.flights(for: year)
let yearStats = StatsEngine(store: stats.store, database: stats.database, flights: yearFlights)
NavigationStack {
TabView {
coverCard(year: year, flights: yearFlights.count)
if yearStats.totalMiles > 0 {
distanceCard(yearStats)
}
airportsCard(yearStats)
hoursCard(yearStats)
if let top = yearStats.topAirline {
topAirlineCard(top)
}
if let route = yearStats.topRoute {
topRouteCard(route)
}
if let longest = yearStats.longestFlight {
longestCard(longest, yearStats: yearStats)
}
}
.tabViewStyle(.page(indexDisplayMode: .always))
.background(HistoryStyle.midnightNavy.ignoresSafeArea())
.navigationTitle("\(year) Year in Flight")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button { dismiss() } label: { Image(systemName: "xmark") }
}
}
}
}
// MARK: - Card variants
private func coverCard(year: Int, flights: Int) -> some View {
HeroComposition(background: HistoryStyle.heroOrangeGradient) {
VStack(spacing: 12) {
Spacer()
Text("\(year)")
.font(.system(size: 140, weight: .black).monospacedDigit())
.foregroundStyle(.white)
Text("YEAR IN FLIGHT")
.font(.system(size: 18, weight: .heavy))
.tracking(2.5)
.foregroundStyle(.white.opacity(0.85))
Spacer()
Text("\(flights) flights logged")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white.opacity(0.7))
}
}
}
private func distanceCard(_ s: StatsEngine) -> some View {
HeroComposition(background: HistoryStyle.heroNavyGradient) {
cardBody(
eyebrow: "DISTANCE",
hero: s.shortDistance,
heroAccent: "mi",
subtitle: equatorBlurb(miles: s.totalMiles)
)
}
}
private func airportsCard(_ s: StatsEngine) -> some View {
HeroComposition(background: HistoryStyle.heroGreenGradient) {
cardBody(
eyebrow: "PASSPORT STAMPS",
hero: "\(s.uniqueAirports)",
heroAccent: "airports",
subtitle: "\(s.uniqueCountries) countries"
)
}
}
private func hoursCard(_ s: StatsEngine) -> some View {
HeroComposition(background: HistoryStyle.heroGoldGradient) {
cardBody(
eyebrow: "TIME ALOFT",
hero: hoursDisplay(s.totalMinutes),
heroAccent: "",
subtitle: "\(s.totalMinutes / 60) hours airborne"
)
}
}
private func topAirlineCard(_ top: (icao: String, count: Int)) -> some View {
let name = AircraftRegistry.shared.lookup(icao: top.icao)?.name ?? top.icao
return HeroComposition(background: HistoryStyle.heroOrangeGradient) {
cardBody(
eyebrow: "TOP AIRLINE",
hero: name,
heroAccent: "",
subtitle: "\(top.count) flights"
)
}
}
private func topRouteCard(_ route: (label: String, count: Int)) -> some View {
HeroComposition(background: HistoryStyle.heroNavyGradient) {
cardBody(
eyebrow: "TOP ROUTE",
hero: route.label.replacingOccurrences(of: "", with: ""),
heroAccent: "",
subtitle: "\(route.count) trips"
)
}
}
private func longestCard(_ longest: LoggedFlight, yearStats: StatsEngine) -> some View {
let miles = yearStats.store.distanceMiles(for: longest) ?? 0
return HeroComposition(background: HistoryStyle.heroGreenGradient) {
cardBody(
eyebrow: "ENDURANCE RECORD",
hero: "\(longest.departureIATA)\(longest.arrivalIATA)",
heroAccent: "",
subtitle: "\(numberString(miles)) miles"
)
}
}
// MARK: - Card body template
private func cardBody(eyebrow: String, hero: String, heroAccent: String, subtitle: String) -> some View {
VStack(spacing: 16) {
Spacer()
Text(eyebrow)
.font(.system(size: 14, weight: .heavy))
.tracking(3)
.foregroundStyle(.white.opacity(0.8))
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(hero)
.font(.system(size: 64, weight: .black).monospacedDigit())
.foregroundStyle(.white)
.lineLimit(2)
.minimumScaleFactor(0.4)
.multilineTextAlignment(.center)
if !heroAccent.isEmpty {
Text(heroAccent)
.font(.system(size: 22, weight: .heavy))
.foregroundStyle(.white.opacity(0.7))
}
}
Text(subtitle)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white.opacity(0.85))
.multilineTextAlignment(.center)
Spacer()
Text("FLIGHTS · \(year)")
.font(.system(size: 10, weight: .heavy))
.tracking(2)
.foregroundStyle(.white.opacity(0.55))
}
.padding(20)
.multilineTextAlignment(.center)
}
// MARK: - Helpers
private func equatorBlurb(miles: Int) -> String {
let equator = 24_901
let ratio = Double(miles) / Double(equator)
if ratio < 0.05 { return "miles flown" }
if ratio < 1 { return String(format: "%.0f%% of earth's equator", ratio * 100) }
return String(format: "%.1f× around the equator", ratio)
}
private func hoursDisplay(_ minutes: Int) -> String {
let days = minutes / (60 * 24)
let hours = (minutes % (60 * 24)) / 60
if days > 0 { return "\(days)d \(hours)h" }
return "\(hours)h"
}
private func numberString(_ n: Int) -> String {
let f = NumberFormatter(); f.numberStyle = .decimal
return f.string(from: NSNumber(value: n)) ?? "\(n)"
}
}
/// Card frame for the Year in Review deck generic background +
/// foreground content. Used to keep page-to-page motion + sizing
/// consistent.
private struct HeroComposition<Content: View>: View {
let background: LinearGradient
@ViewBuilder var content: () -> Content
var body: some View {
ZStack {
background
content()
}
.clipShape(RoundedRectangle(cornerRadius: 28))
.padding(.horizontal, 24)
.padding(.vertical, 32)
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+43
View File
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Flights</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsText</key>
<true/>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
<integer>3</integer>
<key>NSExtensionActivationSupportsAttachmentsWithMaxCount</key>
<integer>10</integer>
</dict>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
</dict>
</dict>
</plist>
@@ -0,0 +1,189 @@
import UIKit
import Social
import UniformTypeIdentifiers
/// Mail (and any other text/URL source) Share Extension. Parses
/// flight info out of the shared content using the same regex
/// patterns as the calendar importer, writes the result to an App
/// Group UserDefaults entry under `pendingMailShare`, and dismisses.
///
/// The main app reads that entry on next foreground (via
/// PendingShareWatcher) and pops the AddFlightView prefilled with
/// whatever we parsed.
final class ShareViewController: SLComposeServiceViewController {
private var parsed: ParsedFlight?
private var allText: String = ""
struct ParsedFlight {
let flightDate: Date
let carrierIATA: String?
let flightNumber: String?
let departureIATA: String?
let arrivalIATA: String?
}
override func viewDidLoad() {
super.viewDidLoad()
title = "Add to Flights"
placeholder = "Optional note"
loadSharedItems()
}
private func loadSharedItems() {
guard let extensionItems = extensionContext?.inputItems as? [NSExtensionItem] else { return }
let group = DispatchGroup()
var accumulated = ""
for item in extensionItems {
// Mail surfaces both the subject line (as the contentText)
// and the body (as attachments). We absorb both.
if let content = item.attributedContentText?.string, !content.isEmpty {
accumulated += " " + content
}
for provider in item.attachments ?? [] {
if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {
group.enter()
provider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { item, _ in
defer { group.leave() }
if let s = item as? String { accumulated += " " + s }
}
}
if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
group.enter()
provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { item, _ in
defer { group.leave() }
if let u = item as? URL { accumulated += " " + u.absoluteString }
}
}
}
}
group.notify(queue: .main) { [weak self] in
guard let self else { return }
self.allText = accumulated
self.parsed = Self.parseFlight(from: accumulated)
self.validateContent()
}
}
override func isContentValid() -> Bool {
return parsed != nil
}
override func didSelectPost() {
guard let parsed else {
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
return
}
// Hand the parsed flight off to the host app via a custom URL
// scheme. Share Extensions can't call UIApplication.shared
// directly, but we can walk the responder chain to find one
// that implements `openURL:` and invoke it. iOS still routes
// it through the host app correctly.
var comps = URLComponents()
comps.scheme = "flights"
comps.host = "import"
var items: [URLQueryItem] = [
URLQueryItem(name: "date", value: String(parsed.flightDate.timeIntervalSince1970))
]
if let c = parsed.carrierIATA { items.append(.init(name: "carrier", value: c)) }
if let f = parsed.flightNumber { items.append(.init(name: "num", value: f)) }
if let d = parsed.departureIATA { items.append(.init(name: "dep", value: d)) }
if let a = parsed.arrivalIATA { items.append(.init(name: "arr", value: a)) }
comps.queryItems = items
if let url = comps.url {
openURLInHost(url)
}
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
}
/// Walk the responder chain looking for an object that implements
/// `openURL:`. UIApplication is one. Invoking it from a share
/// extension launches the host app via its registered URL scheme.
private func openURLInHost(_ url: URL) {
var responder: UIResponder? = self
let selector = NSSelectorFromString("openURL:")
while responder != nil {
if responder!.responds(to: selector) {
_ = responder!.perform(selector, with: url)
return
}
responder = responder?.next
}
}
override func configurationItems() -> [Any]! {
return []
}
// MARK: - Parser
private static func parseFlight(from text: String) -> ParsedFlight? {
guard let flightMatch = matchFlight(in: text) else { return nil }
let route = matchRoute(in: text)
let date = matchDate(in: text) ?? Date()
return ParsedFlight(
flightDate: date,
carrierIATA: flightMatch.carrier,
flightNumber: flightMatch.number,
departureIATA: route?.from,
arrivalIATA: route?.to
)
}
private static func matchFlight(in s: String) -> (carrier: String, number: String)? {
let pattern = "([A-Z]{2,3})\\s*([0-9]{1,4})"
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
let nsRange = NSRange(s.startIndex..., in: s)
let denylist: Set<String> = ["AM", "PM", "ET", "PT", "CT", "MT", "US", "UK", "TO", "AS"]
for m in regex.matches(in: s, range: nsRange) where m.numberOfRanges == 3 {
guard let cRange = Range(m.range(at: 1), in: s),
let nRange = Range(m.range(at: 2), in: s) else { continue }
let carrier = String(s[cRange])
if denylist.contains(carrier) { continue }
return (carrier, String(s[nRange]))
}
return nil
}
private static func matchRoute(in s: String) -> (from: String, to: String)? {
let pattern = "([A-Z]{3})\\s*(?:[-→>]|to)\\s*([A-Z]{3})"
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
let nsRange = NSRange(s.startIndex..., in: s)
guard let m = regex.firstMatch(in: s, range: nsRange), m.numberOfRanges == 3,
let fRange = Range(m.range(at: 1), in: s),
let tRange = Range(m.range(at: 2), in: s) else { return nil }
return (String(s[fRange]), String(s[tRange]))
}
private static func matchDate(in s: String) -> Date? {
// ISO-ish: "May 27, 2026" / "27 May 2026" / "2026-05-27"
let formatters: [String] = [
"MMMM d, yyyy",
"MMM d, yyyy",
"d MMMM yyyy",
"d MMM yyyy",
"yyyy-MM-dd",
"MM/dd/yyyy"
]
// Try matching against any substring with each formatter.
for fmt in formatters {
let df = DateFormatter()
df.dateFormat = fmt
df.locale = Locale(identifier: "en_US_POSIX")
// Slide a window through the text; for date formats with
// word months we need substrings starting with a month.
let words = s.split(whereSeparator: { !$0.isLetter && !$0.isNumber && $0 != "-" && $0 != "/" && $0 != "," }).map(String.init)
for i in 0..<words.count {
for end in min(i + 4, words.count)...(min(i + 4, words.count)) {
let candidate = words[i..<end].joined(separator: " ")
if let date = df.date(from: candidate) {
return date
}
}
}
}
return nil
}
}
@@ -0,0 +1,134 @@
import XCTest
import SwiftData
@testable import Flights
/// Unit tests for `AirframeHistoryStore`.
///
/// We exercise the store against an in-memory `ModelContainer` seeded
/// with `LoggedFlight` rows that vary by tail number, route, and date.
/// All assertions reference the documented `AirframeStats` contract.
@MainActor
final class AirframeHistoryStoreTests: XCTestCase {
private var container: ModelContainer!
private var context: ModelContext!
private var store: AirframeHistoryStore!
override func setUpWithError() throws {
try super.setUpWithError()
let schema = Schema([LoggedFlight.self])
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
container = try ModelContainer(for: schema, configurations: config)
context = ModelContext(container)
store = AirframeHistoryStore()
}
override func tearDownWithError() throws {
store = nil
context = nil
container = nil
try super.tearDownWithError()
}
// MARK: - Helpers
private static let epoch = Date(timeIntervalSince1970: 1_700_000_000)
private func date(_ dayOffset: Int) -> Date {
Self.epoch.addingTimeInterval(TimeInterval(dayOffset) * 86_400)
}
@discardableResult
private func insert(
registration: String?,
origin: String,
dest: String,
flightDate: Date
) -> LoggedFlight {
let flight = LoggedFlight(
flightDate: flightDate,
departureIATA: origin,
arrivalIATA: dest,
registration: registration
)
context.insert(flight)
return flight
}
// MARK: - Tests
/// Empty store empty stats sentinel.
func test_stats_emptyContext_returnsEmpty() {
let stats = store.stats(forTail: "N281WN", context: context)
XCTAssertEqual(stats.totalFlights, 0)
XCTAssertTrue(stats.routes.isEmpty)
XCTAssertNil(stats.firstSeen)
XCTAssertNil(stats.lastSeen)
XCTAssertNil(stats.mostCommonRoute)
}
/// 3 flights on the same tail across 2 distinct routes verify the
/// aggregate counts and the "DALHOU (2 of 3)" most-common-route
/// formatting.
func test_stats_threeFlightsTwoRoutes_aggregatesCorrectly() {
insert(registration: "N281WN", origin: "DAL", dest: "HOU", flightDate: date(0))
insert(registration: "N281WN", origin: "DAL", dest: "HOU", flightDate: date(5))
insert(registration: "N281WN", origin: "DAL", dest: "LAS", flightDate: date(10))
// Other-tail noise must not be counted.
insert(registration: "N999AA", origin: "DAL", dest: "HOU", flightDate: date(2))
let stats = store.stats(forTail: "N281WN", context: context)
XCTAssertEqual(stats.totalFlights, 3)
XCTAssertEqual(Set(stats.routes), Set(["DAL→HOU", "DAL→LAS"]))
XCTAssertEqual(stats.routes.count, 2)
XCTAssertEqual(stats.firstSeen, date(0))
XCTAssertEqual(stats.lastSeen, date(10))
XCTAssertEqual(stats.mostCommonRoute, "DAL→HOU (2 of 3)")
}
/// Lookup tail must be normalized to uppercase passing "n281wn"
/// matches a stored "N281WN".
func test_stats_lookupIsCaseInsensitive() {
insert(registration: "N281WN", origin: "DAL", dest: "HOU", flightDate: date(0))
let stats = store.stats(forTail: "n281wn", context: context)
XCTAssertEqual(stats.totalFlights, 1)
XCTAssertEqual(stats.mostCommonRoute, "DAL→HOU (1 of 1)")
}
/// The store should still report stats for a single-flight tail. The
/// History UI hides the section in that case, but the underlying
/// store contract returns the real count.
func test_stats_singleFlight_returnsTotalOne() {
insert(registration: "N281WN", origin: "DAL", dest: "HOU", flightDate: date(0))
let stats = store.stats(forTail: "N281WN", context: context)
XCTAssertEqual(stats.totalFlights, 1)
XCTAssertEqual(stats.routes, ["DAL→HOU"])
XCTAssertEqual(stats.firstSeen, date(0))
XCTAssertEqual(stats.lastSeen, date(0))
XCTAssertEqual(stats.mostCommonRoute, "DAL→HOU (1 of 1)")
}
/// Mixed-case stored registration: a record persisted with lowercase
/// "n281wn" must still be discoverable when callers ask for
/// "N281WN". Today the fast-path #Predicate misses (it compares
/// exact bytes against the uppercased query) and the fallback
/// table-scan recovers it. After Phase 3 fixes registration
/// normalisation at write-time (or switches to a case-insensitive
/// predicate), the fast path will hit but this test should still
/// pass either way.
func test_stats_lowercaseStoredRegistration_isFoundViaFallback() {
insert(registration: "n281wn", origin: "DAL", dest: "HOU", flightDate: date(0))
let stats = store.stats(forTail: "N281WN", context: context)
XCTAssertEqual(stats.totalFlights, 1)
XCTAssertEqual(stats.routes, ["DAL→HOU"])
XCTAssertEqual(stats.mostCommonRoute, "DAL→HOU (1 of 1)")
}
}
@@ -0,0 +1,282 @@
import XCTest
@testable import Flights
/// Integration tests for the airline load fetchers in `AirlineLoadService`.
///
/// These tests hit **live airline APIs**. They will:
/// - Take 10-30s each (network)
/// - Fail loudly when an airline rotates auth, gates on a new app version,
/// or otherwise changes their API shape. That's by design this is the
/// regression net for "does X airline still work?"
///
/// For each carrier, the test:
/// 1. Uses `RouteExplorerClient` to find a real flight on that carrier
/// departing within the next 24 hours from one of its hubs.
/// 2. Calls `AirlineLoadService.fetchLoad(...)` for that specific flight.
/// 3. Asserts the response is meaningful (non-nil and has at least one
/// of: cabins / standby list / upgrade list / seat availability).
///
/// Pre-existing limitations (NOT bugs in these tests):
/// - JSX (XE) uses a WKWebView path and can't run from unit tests on the
/// simulator without a host scene. Skipped with a `XCTSkip`.
/// - Some carriers (notably AA, AS waitlist) only open the load endpoint
/// close to departure. Tests prefer flights leaving < 24h out and skip
/// with a helpful message if nothing's findable.
final class AirlineLoadIntegrationTests: XCTestCase {
// Static so the token cache + URLSession survive across tests in
// a single run, and so the route-explorer rate limit applies once
// per suite rather than per test.
private static let routeExplorer = RouteExplorerClient()
private static let airportDatabase = AirportDatabase()
private static let loadService = AirlineLoadService(airportDatabase: airportDatabase)
private var routeExplorer: RouteExplorerClient { Self.routeExplorer }
private var loadService: AirlineLoadService { Self.loadService }
/// Airlines whose load endpoint deliberately returns only flight
/// status (no seat/standby data). We assert non-nil for these and
/// stop short of the "must have data" check.
private static let statusOnlyAirlines: Set<String> = ["B6", "EK"]
/// Hardcoded daily flights used as fallbacks when route-explorer's
/// `/departures` data doesn't include the carrier we're looking for
/// (notably some international carriers like EK/KE that aren't in
/// route-explorer's schedule feed). Each entry is a well-known daily
/// operation that's been stable over time; if any of these stop
/// operating, update the entry.
///
/// `dayOffset` controls which day's flight to probe:
/// - `0` (today) for carriers whose snapshot window is T-1d to T+0 (AM)
/// - `1` (tomorrow) for carriers whose API only returns future flights
/// (SY's Navitaire availability/search drops already-departed legs)
private static let knownDailyFlights: [String: (flightNumber: String, origin: String, destination: String, dayOffset: Int)] = [
"EK": ("201", "JFK", "DXB", 1), // Emirates JFK Dubai, daily flagship
"KE": ("82", "JFK", "ICN", 1), // Korean Air JFK Incheon, daily
"AM": ("58", "MEX", "MTY", 0), // Aeromexico snapshot only T-1d/T+0
"SY": ("104", "LAS", "MSP", 1), // Sun Country Navitaire shows future only
]
// MARK: - Per-airline tests
func test_AA_americanAirlines() async throws {
try await runAirlineLoadTest(
carrier: "AA",
hubs: ["DFW", "CLT", "PHL", "ORD", "MIA", "PHX"]
)
}
func test_UA_united() async throws {
try await runAirlineLoadTest(
carrier: "UA",
hubs: ["EWR", "IAH", "DEN", "ORD", "SFO", "IAD", "LAX"]
)
}
func test_AS_alaska() async throws {
try await runAirlineLoadTest(
carrier: "AS",
hubs: ["SEA", "PDX", "ANC", "SAN", "LAX"]
)
}
func test_B6_jetBlue() async throws {
try await runAirlineLoadTest(
carrier: "B6",
hubs: ["JFK", "BOS", "FLL", "MCO", "LAX"]
)
}
func test_KE_koreanAir() async throws {
try await runAirlineLoadTest(
carrier: "KE",
hubs: ["ICN", "LAX", "JFK", "SFO", "ATL"]
)
}
func test_EK_emirates() async throws {
try await runAirlineLoadTest(
carrier: "EK",
hubs: ["DXB", "JFK", "LAX", "ORD", "IAD", "SFO", "BOS"]
)
}
func test_AM_aeromexico() async throws {
// Route-explorer doesn't include AM in /departures data, so this
// always falls through to the known-daily fallback (AM0058 MEX-MTY).
try await runAirlineLoadTest(
carrier: "AM",
hubs: ["MEX", "GDL", "MTY", "CUN"]
)
}
func test_SY_sunCountry() async throws {
// Sun Country runs on Navitaire; the load endpoint takes flight
// number + route + date. Route-explorer doesn't cover SY well, so
// most runs hit the known-daily fallback (SY104 LAS-MSP).
try await runAirlineLoadTest(
carrier: "SY",
hubs: ["MSP", "LAS", "MCO", "DEN"]
)
}
func test_XE_jsx() async throws {
// JSX uses a WKWebView path that needs a host scene / main thread.
// Skipped here; manual verification via the app remains.
throw XCTSkip("JSX uses WKWebView and cannot run from a unit-test bundle.")
}
// MARK: - Helpers
/// Pulls departures from `hubs` for `carrier`, picks the first flight
/// leaving in (now, now+24h), and runs the airline-specific fetcher.
/// XCTSkips (rather than fails) if no flight can be found at all
/// that's a route-explorer / schedule problem, not a load-fetcher bug.
private func runAirlineLoadTest(
carrier: String,
hubs: [String],
file: StaticString = #file,
line: UInt = #line
) async throws {
let now = Date()
let cutoff = now.addingTimeInterval(24 * 3600)
var pickedFlight: RouteFlight?
var pickedHub: String?
for hub in hubs {
let candidate = await departuresWithRetry(from: hub, after: now, before: cutoff, carrier: carrier)
if let candidate {
pickedFlight = candidate
pickedHub = hub
break
}
}
// Try the discovered flight first when route-explorer found one.
if let flight = pickedFlight, let hub = pickedHub {
NSLog("[\(carrier)Test] Using \(carrier)\(flight.flightNumber) \(flight.departure.airportIata)\(flight.arrival.airportIata) departing \(flight.departure.dateTime) (hub queried: \(hub))")
let load = await loadService.fetchLoad(
airlineCode: flight.carrierIata,
flightNumber: "\(flight.flightNumber)",
date: flight.departure.dateTime,
origin: flight.departure.airportIata,
destination: flight.arrival.airportIata,
departureTime: nil
)
if load != nil {
let flightLabel = "\(carrier)\(flight.flightNumber) \(flight.departure.airportIata)\(flight.arrival.airportIata)"
try assertLoad(load, carrier: carrier, flightLabel: flightLabel, file: file, line: line)
return
}
NSLog("[\(carrier)Test] Discovered flight returned nil; trying known-daily fallback if available")
}
// Fallback: known-good daily flight. Triggers when route-explorer
// found nothing OR when the discovered flight returned nil (e.g. a
// regional carrier op that isn't in the upstream load system).
// dayOffset in the table controls today-vs-tomorrow based on each
// carrier's snapshot window quirks.
if let known = Self.knownDailyFlights[carrier] {
let probeDate = Date().addingTimeInterval(TimeInterval(known.dayOffset * 86400))
NSLog("[\(carrier)Test] Using known daily \(carrier)\(known.flightNumber) \(known.origin)\(known.destination) +\(known.dayOffset)d")
let load = await loadService.fetchLoad(
airlineCode: carrier,
flightNumber: known.flightNumber,
date: probeDate,
origin: known.origin,
destination: known.destination,
departureTime: nil
)
try assertLoad(load, carrier: carrier, flightLabel: "\(carrier)\(known.flightNumber) \(known.origin)\(known.destination)", file: file, line: line)
return
}
throw XCTSkip("Could not find a working \(carrier) flight in the next 24h from any of: \(hubs.joined(separator: ", "))")
}
/// Shared assertion path for both the dynamic-discovery and
/// hardcoded-fallback test routes.
private func assertLoad(
_ load: FlightLoad?,
carrier: String,
flightLabel: String,
file: StaticString,
line: UInt
) throws {
XCTAssertNotNil(
load,
"\(carrier) load fetcher returned nil for \(flightLabel). "
+ "Check the [\(carrier)] console logs above for the underlying failure mode.",
file: file,
line: line
)
guard let load else { return }
NSLog("[\(carrier)Test] ✅ cabins=\(load.cabins.count) standby=\(load.standbyList.count) upgrade=\(load.upgradeList.count) seatAvail=\(load.seatAvailability.count)")
if Self.statusOnlyAirlines.contains(carrier) {
XCTAssertEqual(load.airlineCode, carrier)
return
}
let hasAnyData = !load.cabins.isEmpty
|| !load.standbyList.isEmpty
|| !load.upgradeList.isEmpty
|| !load.seatAvailability.isEmpty
XCTAssertTrue(
hasAnyData,
"\(carrier) returned a FlightLoad but every collection is empty — "
+ "the endpoint likely succeeded but with no data for this flight, "
+ "or the response shape changed.",
file: file,
line: line
)
}
/// Fetch departures from `hub` and pick the first flight matching
/// `carrier` in the time window. On HTTP 429 (route-explorer rate
/// limit), parse `retryAfter` and retry once after that delay.
private func departuresWithRetry(
from hub: String,
after: Date,
before: Date,
carrier: String,
attemptsRemaining: Int = 2
) async -> RouteFlight? {
do {
let result = try await routeExplorer.searchDepartures(
from: hub, date: after, maxStops: 0, limit: 300
)
let allLegs = result.connections.flatMap { $0.flights }
let inWindow = allLegs.filter { $0.departure.dateTime > after && $0.departure.dateTime <= before }
let carrierMatches = inWindow.filter { $0.carrierIata == carrier }
NSLog("[\(carrier)Test] hub \(hub): legs=\(allLegs.count) inWindow=\(inWindow.count) \(carrier)Matches=\(carrierMatches.count)")
return carrierMatches.first
} catch let RouteExplorerClient.ClientError.requestFailed(status: 429, body: body) {
let retryAfter = parseRetryAfter(body: body) ?? 25
NSLog("[\(carrier)Test] hub \(hub) rate-limited (429), sleeping \(retryAfter)s then retrying (attemptsRemaining=\(attemptsRemaining - 1))")
if attemptsRemaining <= 1 { return nil }
try? await Task.sleep(nanoseconds: UInt64(retryAfter) * 1_000_000_000)
return await departuresWithRetry(from: hub, after: after, before: before, carrier: carrier, attemptsRemaining: attemptsRemaining - 1)
} catch let RouteExplorerClient.ClientError.tokenFetchFailed(status: 429) {
NSLog("[\(carrier)Test] hub \(hub) token rate-limited (429), sleeping 25s then retrying (attemptsRemaining=\(attemptsRemaining - 1))")
if attemptsRemaining <= 1 { return nil }
try? await Task.sleep(nanoseconds: 25 * 1_000_000_000)
return await departuresWithRetry(from: hub, after: after, before: before, carrier: carrier, attemptsRemaining: attemptsRemaining - 1)
} catch {
NSLog("[\(carrier)Test] hub \(hub) lookup failed: \(error)")
return nil
}
}
private func parseRetryAfter(body: String?) -> Int? {
guard let body, let data = body.data(using: .utf8) else { return nil }
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
return json["retryAfter"] as? Int
}
return nil
}
}
@@ -0,0 +1,80 @@
import XCTest
@testable import Flights
/// Coverage for `DataIntegrityMonitor` the shared sink that collects
/// bundled-JSON decode failures so `RootView` can show a banner instead
/// of leaving the user staring at "no data" with no explanation.
///
/// The monitor is `@MainActor` because it's read by SwiftUI views, so
/// every test hop onto the main actor before touching it. Each test also
/// calls `clear()` first because the singleton is process-wide and other
/// loaders may have reported into it during test bring-up.
@MainActor
final class DataIntegrityMonitorTests: XCTestCase {
override func setUp() async throws {
try await super.setUp()
DataIntegrityMonitor.shared.clear()
}
override func tearDown() async throws {
DataIntegrityMonitor.shared.clear()
try await super.tearDown()
}
func test_reportingOneFailure_setsHasFailuresTrue() {
let monitor = DataIntegrityMonitor.shared
XCTAssertFalse(monitor.hasFailures, "monitor should start empty after clear()")
let err = NSError(
domain: "TestDomain",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "bad json"]
)
monitor.report("bts_bundle.json", error: err)
XCTAssertTrue(monitor.hasFailures)
XCTAssertEqual(monitor.failures.count, 1)
XCTAssertTrue(
monitor.failures[0].contains("bts_bundle.json"),
"failure entry should include the resource basename"
)
XCTAssertTrue(
monitor.failures[0].contains("bad json"),
"failure entry should include the localized description"
)
}
func test_reportingTwoFailures_accumulates() {
let monitor = DataIntegrityMonitor.shared
monitor.report(
"jumpseat_rules.json",
error: NSError(domain: "T", code: 1, userInfo: [NSLocalizedDescriptionKey: "missing field"])
)
monitor.report(
"crewbases.json",
error: NSError(domain: "T", code: 2, userInfo: [NSLocalizedDescriptionKey: "trailing comma"])
)
XCTAssertEqual(monitor.failures.count, 2)
XCTAssertTrue(monitor.hasFailures)
XCTAssertTrue(monitor.failures[0].contains("jumpseat_rules.json"))
XCTAssertTrue(monitor.failures[1].contains("crewbases.json"))
}
func test_clear_resetsHasFailures() {
let monitor = DataIntegrityMonitor.shared
monitor.report(
"partner_matrix.json",
error: NSError(domain: "T", code: 1, userInfo: [NSLocalizedDescriptionKey: "broken"])
)
XCTAssertTrue(monitor.hasFailures, "precondition: monitor has at least one failure")
monitor.clear()
XCTAssertFalse(monitor.hasFailures)
XCTAssertEqual(monitor.failures.count, 0)
}
}
@@ -0,0 +1,245 @@
import XCTest
@testable import Flights
// MARK: - Test Doubles
//
// Phase 3 wired the production `AircraftRotationProvider` protocol in
// `Services/DelayCascadePredictor.swift`, so we just consume it here
// rather than re-declaring it.
/// Stub rotation provider: returns whatever segments the test handed in,
/// regardless of which icao24 / lookback is queried.
actor MockRotationProvider: AircraftRotationProvider {
private let segments: [AircraftRotationTracker.RotationSegment]
init(segments: [AircraftRotationTracker.RotationSegment]) {
self.segments = segments
}
func rotation(forICAO24: String, lookbackHours: Int) async -> [AircraftRotationTracker.RotationSegment] {
return segments
}
}
final class DelayCascadePredictorTests: XCTestCase {
// Fixed reference point every test offsets from here so absolute
// wall-clock time doesn't matter.
private let scheduledDeparture = Date(timeIntervalSince1970: 1_750_000_000)
private let departureICAO = "KJFK"
private let carrier = "DL"
private let flightNumber = 1234
// MARK: - Test 1: missing operating aircraft
func test_nilOperatingICAO24_returnsNil() async {
let provider = MockRotationProvider(segments: [
segment(arrivalICAO: "KJFK", arrivalOffsetMin: 60)
])
let predictor = DelayCascadePredictor(tracker: provider)
let result = await predictor.predict(
carrier: carrier,
flightNumber: flightNumber,
scheduledDeparture: scheduledDeparture,
departureICAO: departureICAO,
operatingICAO24: nil
)
XCTAssertNil(result, "No tail assigned → no cascade prediction.")
}
func test_emptyOperatingICAO24_returnsNil() async {
let provider = MockRotationProvider(segments: [
segment(arrivalICAO: "KJFK", arrivalOffsetMin: 60)
])
let predictor = DelayCascadePredictor(tracker: provider)
let result = await predictor.predict(
carrier: carrier,
flightNumber: flightNumber,
scheduledDeparture: scheduledDeparture,
departureICAO: departureICAO,
operatingICAO24: " "
)
XCTAssertNil(result, "Whitespace-only icao24 → no cascade prediction.")
}
// MARK: - Test 2: rotation empty
func test_emptyRotation_returnsNil() async {
let provider = MockRotationProvider(segments: [])
let predictor = DelayCascadePredictor(tracker: provider)
let result = await predictor.predict(
carrier: carrier,
flightNumber: flightNumber,
scheduledDeparture: scheduledDeparture,
departureICAO: departureICAO,
operatingICAO24: "a1b2c3"
)
XCTAssertNil(result, "No upstream segments → no cascade prediction.")
}
// MARK: - Test 3: wrong arrival station
func test_lastSegmentArrivedAtDifferentStation_returnsNil() async {
// Aircraft last landed at KATL but we're operating out of KJFK.
let provider = MockRotationProvider(segments: [
segment(arrivalICAO: "KATL", arrivalOffsetMin: 60)
])
let predictor = DelayCascadePredictor(tracker: provider)
let result = await predictor.predict(
carrier: carrier,
flightNumber: flightNumber,
scheduledDeparture: scheduledDeparture,
departureICAO: departureICAO,
operatingICAO24: "a1b2c3"
)
XCTAssertNil(result, "Aircraft not yet at departure station → no cascade prediction.")
}
func test_lastSegmentArrivalICAOMissing_returnsNil() async {
let provider = MockRotationProvider(segments: [
segment(arrivalICAO: nil, arrivalOffsetMin: 60)
])
let predictor = DelayCascadePredictor(tracker: provider)
let result = await predictor.predict(
carrier: carrier,
flightNumber: flightNumber,
scheduledDeparture: scheduledDeparture,
departureICAO: departureICAO,
operatingICAO24: "a1b2c3"
)
XCTAssertNil(result, "Unknown arrival airport → no cascade prediction.")
}
// MARK: - Test 4: 60-min late upstream, 30 min until scheduled departure ~75 min cascade
func test_upstreamLandsLate_cascadesByExpectedAmount() async {
// Aircraft landed at JFK 30 minutes AFTER scheduled departure
// (arrivalOffsetMin = +30). Add the 45-minute narrowbody turn and
// earliest pushback is 75 min past scheduled departure.
let provider = MockRotationProvider(segments: [
segment(arrivalICAO: "KJFK", arrivalOffsetMin: 30)
])
let predictor = DelayCascadePredictor(tracker: provider)
let result = await predictor.predict(
carrier: carrier,
flightNumber: flightNumber,
scheduledDeparture: scheduledDeparture,
departureICAO: departureICAO,
operatingICAO24: "a1b2c3"
)
guard let prediction = result else {
XCTFail("Expected a cascade prediction, got nil.")
return
}
XCTAssertEqual(prediction.predictedDelayMin, 75, "30 min late arrival + 45 min turn = 75 min cascade.")
XCTAssertNotNil(prediction.upstreamSegment, "Prediction must surface the upstream leg used.")
XCTAssertFalse(prediction.basis.isEmpty, "Basis string must explain the prediction.")
}
// MARK: - Test 5: 5 min late below threshold
func test_upstreamOnlyMildlyLate_returnsNil() async {
// Arrival 50 min BEFORE scheduled departure 5 min after the
// 45-min turn window. Both the raw lateness AND the propagated
// minutes are below the 15-min reporting threshold.
let provider = MockRotationProvider(segments: [
segment(arrivalICAO: "KJFK", arrivalOffsetMin: -50)
])
let predictor = DelayCascadePredictor(tracker: provider)
let result = await predictor.predict(
carrier: carrier,
flightNumber: flightNumber,
scheduledDeparture: scheduledDeparture,
departureICAO: departureICAO,
operatingICAO24: "a1b2c3"
)
XCTAssertNil(result, "Below threshold cascade should not surface.")
}
// MARK: - Test 6: exactly 45 min before scheduled departure turn absorbs
func test_arrivalExactly45MinBeforeScheduled_returnsNil() async {
// Aircraft landed 45 min before scheduled departure. Earliest
// pushback equals scheduled departure propagated 0 no cascade.
let provider = MockRotationProvider(segments: [
segment(arrivalICAO: "KJFK", arrivalOffsetMin: -45)
])
let predictor = DelayCascadePredictor(tracker: provider)
let result = await predictor.predict(
carrier: carrier,
flightNumber: flightNumber,
scheduledDeparture: scheduledDeparture,
departureICAO: departureICAO,
operatingICAO24: "a1b2c3"
)
XCTAssertNil(result, "Turn exactly absorbs upstream lateness → no cascade.")
}
// MARK: - Test 7: confidence > 0.5 once propagatedMinutes >= 30
func test_confidenceCrosses50WhenPropagatedAtLeast30() async {
// Arrival 15 min AFTER scheduled departure 60 min propagated.
// Confidence should comfortably exceed 0.5.
let provider = MockRotationProvider(segments: [
segment(arrivalICAO: "KJFK", arrivalOffsetMin: 15)
])
let predictor = DelayCascadePredictor(tracker: provider)
let result = await predictor.predict(
carrier: carrier,
flightNumber: flightNumber,
scheduledDeparture: scheduledDeparture,
departureICAO: departureICAO,
operatingICAO24: "a1b2c3"
)
guard let prediction = result else {
XCTFail("Expected a cascade prediction, got nil.")
return
}
XCTAssertGreaterThanOrEqual(prediction.predictedDelayMin, 30,
"Sanity check on the cascade size we're scoring.")
XCTAssertGreaterThan(prediction.confidence, 0.5,
"Propagated >= 30 min should produce confidence > 0.5.")
XCTAssertLessThanOrEqual(prediction.confidence, 1.0,
"Confidence should always be a probability.")
}
// MARK: - Helpers
/// Builds a single rotation segment whose arrival time is offset from
/// `scheduledDeparture` by `arrivalOffsetMin` minutes (positive = late
/// vs. scheduled, negative = before scheduled).
private func segment(arrivalICAO: String?,
arrivalOffsetMin: Int,
departureICAO: String? = "KBOS") -> AircraftRotationTracker.RotationSegment {
let arrival = scheduledDeparture.addingTimeInterval(Double(arrivalOffsetMin) * 60)
// Block time of 90 min before arrival exact value doesn't matter
// for the predictor, which only consults arrivalTime.
let departure = arrival.addingTimeInterval(-90 * 60)
return AircraftRotationTracker.RotationSegment(
id: "test-seg-\(arrivalOffsetMin)",
departureICAO: departureICAO,
arrivalICAO: arrivalICAO,
departureTime: departure,
arrivalTime: arrival,
estimatedDelayMin: nil
)
}
}
@@ -0,0 +1,174 @@
import XCTest
@testable import Flights
/// Unit tests for `EquipmentSwapService`.
///
/// These exercise the bundled `aircraft_seats.json` catalog and the public
/// `check(scheduledEquipmentIATA:liveEquipmentICAO:)` entry point. The test
/// target is hosted by Flights.app, so `Bundle.main` resolves to the host
/// bundle and the catalog loads normally.
///
/// NOTE: The current catalog is a generic one-size-fits-carrier map. After
/// Phase 2 the schema becomes per-carrier-per-IATA; the IATA codes used
/// below (`73H`, `7M8`, `73G`, `320`) and ICAO codes (`B738`, `B737`) will
/// remain valid lookups, but these tests will need to be revisited then.
///
/// Seat values referenced (from `Flights/Resources/aircraft_seats.json` defaults):
/// 73G 137 (B737-700)
/// 73H 172 (B737-800)
/// 7M8 172 (B737-MAX 8)
/// 320 150 (A320)
/// ICAO B738 IATA 73H
/// ICAO B737 IATA 73G
final class EquipmentSwapServiceTests: XCTestCase {
// A fresh service per test the actor caches the catalog after first
// load, but we want each case to be independent of ordering.
private func makeService() -> EquipmentSwapService {
EquipmentSwapService()
}
// MARK: - 1. Both nil nil
func test_returnsNil_whenBothScheduledAndLiveAreNil() async {
let service = makeService()
let result = await service.check(
scheduledEquipmentIATA: nil,
liveEquipmentICAO: nil
)
XCTAssertNil(result, "Expected nil when there is nothing to compare.")
}
// MARK: - 2. Only live provided nil (no baseline)
func test_returnsNil_whenOnlyLiveICAOProvided() async {
let service = makeService()
let result = await service.check(
scheduledEquipmentIATA: nil,
liveEquipmentICAO: "B738"
)
XCTAssertNil(
result,
"Without a scheduled baseline there is no meaningful comparison to surface."
)
}
// MARK: - 3. Same equipment (live ICAO maps to scheduled IATA)
func test_returnsNoneSeverity_whenScheduledAndLiveMatch() async {
let service = makeService()
// Scheduled 73H (B737-800, 175) vs live B738 73H (175). Identical.
let result = await service.check(
scheduledEquipmentIATA: "73H",
liveEquipmentICAO: "B738"
)
guard let result else {
XCTFail("Expected a non-nil result for a known equipment pair.")
return
}
XCTAssertEqual(result.seatDelta, 0, "Same aircraft should produce a zero seat delta.")
XCTAssertEqual(result.severity, .none, "Zero delta must read as .none severity.")
XCTAssertEqual(result.scheduledSeats, 172)
XCTAssertEqual(result.liveSeats, 172)
XCTAssertTrue(
result.summary.contains("Same equipment today"),
"Summary should reflect the unchanged equipment. Got: \(result.summary)"
)
}
// MARK: - 4. |delta| in 1...15 .minor
func test_returnsMinorSeverity_whenDeltaIsSmall() async {
let service = makeService()
// Scheduled 320 (A320, 150) vs live B737 73G (137). |delta| = 13.
let result = await service.check(
scheduledEquipmentIATA: "320",
liveEquipmentICAO: "B737"
)
guard let result else {
XCTFail("Expected a non-nil result for a known equipment pair.")
return
}
XCTAssertEqual(result.scheduledSeats, 150)
XCTAssertEqual(result.liveSeats, 137)
XCTAssertEqual(result.seatDelta, -13, "Live aircraft has 13 fewer seats than scheduled.")
XCTAssertEqual(result.severity, .minor, "A 13-seat change must be classified .minor (1...15).")
XCTAssertTrue(
result.summary.contains("Smaller bird today"),
"Negative delta summary should call out the smaller aircraft. Got: \(result.summary)"
)
}
// MARK: - 5. |delta| > 15 .significant
func test_returnsSignificantSeverity_whenDeltaIsLarge() async {
let service = makeService()
// Scheduled 73G (B737-700, 137) vs live B738 73H (172). |delta| = 35.
let result = await service.check(
scheduledEquipmentIATA: "73G",
liveEquipmentICAO: "B738"
)
guard let result else {
XCTFail("Expected a non-nil result for a known equipment pair.")
return
}
XCTAssertEqual(result.scheduledSeats, 137)
XCTAssertEqual(result.liveSeats, 172)
XCTAssertEqual(result.seatDelta, 35, "Live aircraft has 35 more seats than scheduled.")
XCTAssertEqual(result.severity, .significant, "A 35-seat swing exceeds 15 → .significant.")
XCTAssertTrue(
result.summary.contains("Bigger bird today"),
"Positive delta summary should call out the larger aircraft. Got: \(result.summary)"
)
}
// MARK: - 6. ICAO "B738" maps to IATA "73H" (no-swap path through ICAO mapping)
func test_icaoB738_mapsTo_iata73H_asNoSwap() async {
let service = makeService()
// Scheduled was the 73H; live equipment reports as ICAO B738 these
// are the same airframe family. Catalog mapping should collapse them.
let result = await service.check(
scheduledEquipmentIATA: "73H",
liveEquipmentICAO: "B738"
)
guard let result else {
XCTFail("Expected a non-nil result; ICAO B738 should map to IATA 73H.")
return
}
XCTAssertEqual(
result.liveSeats, result.scheduledSeats,
"B738 → 73H mapping must produce equal scheduled/live seat counts."
)
XCTAssertEqual(result.seatDelta, 0)
XCTAssertEqual(result.severity, .none)
XCTAssertEqual(
result.liveName, result.scheduledName,
"The resolved live aircraft name should match the scheduled name (both 73H)."
)
}
// MARK: - 7. Unknown live ICAO liveSeats nil + "live equipment unknown" summary
func test_unknownLiveICAO_returnsNilLiveSeats_andUnknownSummary() async {
let service = makeService()
// "ZZZZ" is not in the ICAO map and is not a valid IATA fallback.
let result = await service.check(
scheduledEquipmentIATA: "73H",
liveEquipmentICAO: "ZZZZ"
)
guard let result else {
XCTFail("Expected a non-nil result — we still have a scheduled baseline.")
return
}
XCTAssertEqual(result.scheduledSeats, 172, "Scheduled 73H still resolves to 172 seats.")
XCTAssertNil(result.liveSeats, "Unknown ICAO must leave liveSeats nil.")
XCTAssertNil(result.liveName, "Unknown ICAO must leave liveName nil.")
XCTAssertNil(result.seatDelta, "Without a live entry there is no delta to compute.")
XCTAssertEqual(result.severity, .none, "Missing live data falls back to .none severity.")
XCTAssertTrue(
result.summary.contains("live equipment unknown"),
"Summary should explicitly say the live equipment is unknown. Got: \(result.summary)"
)
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,142 @@
import XCTest
@testable import Flights
/// Tests for ``FlightAwareScheduleClient``. The two pure-parser entry
/// points (``parseIdents`` and ``extractTrackpollBlob``) are exercised
/// directly against fixture HTML captured from a live request this
/// catches FlightAware schema drift the moment it happens (route.rvt or
/// trackpoll layout changes) instead of finding out via empty search
/// results in production.
///
/// Fixtures live next to this file under `Fixtures/`. They're real
/// HTML pages saved verbatim from FlightAware, not synthetic markup,
/// so the tests assert against the actual shapes the parser sees.
final class FlightAwareScheduleClientTests: XCTestCase {
// MARK: - Fixture loading
/// Reads a file from the `Fixtures/` directory sibling to this test
/// source file. Avoids needing the test target's pbxproj to declare
/// a Resources phase `#filePath` resolves to the real source path
/// at test-run time.
private func loadFixture(_ name: String, file: StaticString = #filePath) throws -> String {
let here = URL(fileURLWithPath: String(describing: file))
let url = here.deletingLastPathComponent()
.appendingPathComponent("Fixtures")
.appendingPathComponent(name)
return try String(contentsOf: url, encoding: .utf8)
}
// MARK: - parseIdents
func test_parseIdents_extractsFlightIdent_fromRouteAnalysisPage() throws {
let html = try loadFixture("DFW_EHAM_route.html")
let idents = FlightAwareScheduleClient.parseIdents(routeHTML: html)
XCTAssertFalse(idents.isEmpty,
"Should find at least one operating ident on DFW->AMS route page.")
XCTAssertTrue(idents.contains("AAL220"),
"AAL220 (AA daily 777-200 DFW->AMS) must surface; got \(idents)")
}
func test_parseIdents_dedupesRepeatedIdents() throws {
let html = try loadFixture("DFW_EHAM_route.html")
let idents = FlightAwareScheduleClient.parseIdents(routeHTML: html)
XCTAssertEqual(idents.count, Set(idents).count,
"Returned idents should be deduped; got \(idents)")
}
func test_parseIdents_returnsEmpty_whenNoRoutesPresent() {
let empty = """
<html><body><table>
<tr><th>Filed Time</th><th>Ident</th></tr>
<tr><td>No data</td></tr>
</table></body></html>
"""
XCTAssertEqual(
FlightAwareScheduleClient.parseIdents(routeHTML: empty),
[],
"Page with no flight rows should produce an empty list, not crash."
)
}
// MARK: - extractTrackpollBlob
func test_extractTrackpollBlob_returnsParseableJSON() throws {
let html = try loadFixture("AAL220_trackpoll.html")
guard let blob = FlightAwareScheduleClient.extractTrackpollBlob(from: html) else {
XCTFail("Should extract trackpollBootstrap from AAL220 page")
return
}
XCTAssertTrue(blob.hasPrefix("{") && blob.hasSuffix("}"),
"Extracted blob should be a JSON object literal")
// Round-trip through JSONDecoder to confirm shape.
XCTAssertNoThrow(
try JSONDecoder().decode(TrackpollBootstrap.self, from: Data(blob.utf8)),
"Extracted JSON should decode against TrackpollBootstrap schema"
)
}
func test_extractTrackpollBlob_returnsNil_whenMarkerMissing() {
let html = "<html><body>no script here</body></html>"
XCTAssertNil(FlightAwareScheduleClient.extractTrackpollBlob(from: html))
}
func test_extractTrackpollBlob_isStringContentAware() {
// A closing brace inside a string literal must NOT terminate the scan.
let html = #"""
<script>var trackpollBootstrap = {"a":"} not the end","b":1};</script>
"""#
let blob = FlightAwareScheduleClient.extractTrackpollBlob(from: html)
XCTAssertEqual(
blob,
#"{"a":"} not the end","b":1}"#,
"Braces inside JSON strings must not break the brace-balance scan."
)
}
// MARK: - ident decomposition
func test_identCarrierICAO_stripsTrailingDigits() {
XCTAssertEqual(FlightAwareScheduleClient.identCarrierICAO("AAL220"), "AAL")
XCTAssertEqual(FlightAwareScheduleClient.identCarrierICAO("BAW296"), "BAW")
XCTAssertEqual(FlightAwareScheduleClient.identCarrierICAO("SWA1"), "SWA")
}
func test_identFlightNumber_extractsTrailingDigits() {
XCTAssertEqual(FlightAwareScheduleClient.identFlightNumber("AAL220"), 220)
XCTAssertEqual(FlightAwareScheduleClient.identFlightNumber("BAW296"), 296)
XCTAssertEqual(FlightAwareScheduleClient.identFlightNumber("SWA1"), 1)
}
func test_airlineIATA_mapsKnownAndReturnsNilForUnknown() {
XCTAssertEqual(FlightAwareScheduleClient.airlineIATA(forICAO: "AAL"), "AA")
XCTAssertEqual(FlightAwareScheduleClient.airlineIATA(forICAO: "KLM"), "KL")
XCTAssertEqual(FlightAwareScheduleClient.airlineIATA(forICAO: "BAW"), "BA")
XCTAssertNil(FlightAwareScheduleClient.airlineIATA(forICAO: "ZZZ"),
"Unknown ICAO should return nil so caller can fall back to the raw prefix.")
}
// MARK: - End-to-end against fixture
func test_endToEnd_AAL220_trackpoll_decodesToScheduledLeg() throws {
let html = try loadFixture("AAL220_trackpoll.html")
guard let blob = FlightAwareScheduleClient.extractTrackpollBlob(from: html) else {
XCTFail("missing trackpoll blob")
return
}
let decoded = try JSONDecoder().decode(TrackpollBootstrap.self, from: Data(blob.utf8))
// The fixture was captured on 2026-06-05; it should contain a DFW->AMS
// leg with a B772 aircraft. We don't assert exact timestamps because
// future updates to the fixture (re-capture) will rotate the dates.
let dfwAmsLegs = decoded.flights.values
.flatMap { $0.activityLog.flights }
.filter { $0.origin.iata == "DFW" && $0.destination.iata == "AMS" }
XCTAssertFalse(dfwAmsLegs.isEmpty,
"AAL220 fixture should contain at least one DFW->AMS leg")
XCTAssertTrue(
dfwAmsLegs.contains { $0.aircraftType == "B772" },
"AAL220 DFW->AMS legs should be operated by B772 per the captured fixture"
)
}
}
@@ -0,0 +1,65 @@
import XCTest
@testable import Flights
/// Tests for the standby tracking fields on the history flight model.
///
/// NOTE: The codebase's history record type is `LoggedFlight` (see
/// `Flights/Models/LoggedFlight.swift`). The task spec referred to it as
/// "HistoryFlight" that name does not exist. These tests therefore
/// target `LoggedFlight`, which is the actual @Model SwiftData type that
/// owns `standbyOutcome` and the computed `wasStandby`.
///
/// Assumption to verify: there is no separate `HistoryFlight` type.
final class HistoryFlightModelTests: XCTestCase {
// MARK: wasStandby
func test_wasStandby_isTrue_whenOutcomeIsStandbyMade() {
let flight = LoggedFlight(departureIATA: "LAX", arrivalIATA: "JFK")
flight.standbyOutcome = "standby-made"
XCTAssertTrue(flight.wasStandby,
"standby-made should count as a standby attempt")
}
func test_wasStandby_isTrue_whenOutcomeIsStandbyBumped() {
let flight = LoggedFlight(departureIATA: "LAX", arrivalIATA: "JFK")
flight.standbyOutcome = "standby-bumped"
XCTAssertTrue(flight.wasStandby,
"standby-bumped should count as a standby attempt")
}
func test_wasStandby_isFalse_whenOutcomeIsConfirmed() {
let flight = LoggedFlight(departureIATA: "LAX", arrivalIATA: "JFK")
flight.standbyOutcome = "confirmed"
XCTAssertFalse(flight.wasStandby,
"confirmed is a positive-space ticket, not standby")
}
func test_wasStandby_isFalse_whenOutcomeIsNil() {
let flight = LoggedFlight(departureIATA: "LAX", arrivalIATA: "JFK")
flight.standbyOutcome = nil
XCTAssertFalse(flight.wasStandby,
"nil outcome (legacy / unmigrated) should not count as standby")
}
// MARK: Default init all new standby fields nil
func test_defaultInit_hasAllStandbyFieldsNil() {
let flight = LoggedFlight()
XCTAssertNil(flight.standbyOutcome,
"standbyOutcome must default to nil for CloudKit migration safety")
XCTAssertNil(flight.standbyAttemptedAt,
"standbyAttemptedAt must default to nil")
XCTAssertNil(flight.standbyClearedAt,
"standbyClearedAt must default to nil")
XCTAssertNil(flight.standbyClass,
"standbyClass must default to nil")
XCTAssertNil(flight.standbyNotes,
"standbyNotes must default to nil")
// And the derived flag follows.
XCTAssertFalse(flight.wasStandby,
"a freshly-constructed record is not a standby attempt")
}
}

Some files were not shown because too many files have changed in this diff Show More