Compare commits
39 Commits
4bd7a74042
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| ba0688a412 | |||
| d122c95342 | |||
| 9612ef558f | |||
| 5c1d7871c6 | |||
| cb981c5380 | |||
| 0c9d02f7d4 | |||
| e5333ff965 | |||
| 2e5cf6b9b3 | |||
| 572e81406d | |||
| f97d5f52ec | |||
| e1b7fd4b0d | |||
| 86582cea4a | |||
| a33a56176d | |||
| d639cdef15 | |||
| 9e1dbfbf90 | |||
| f40b02f68d | |||
| d444a5caac | |||
| 803c812f86 | |||
| 8308d9cf03 | |||
| 847e5c6035 | |||
| a1831d0034 | |||
| 16b874a7ad | |||
| 92bc6ed52e | |||
| 68c60ec087 | |||
| dee6df1ac6 | |||
| de7a70b198 | |||
| 390a158487 | |||
| d6fb73db2c | |||
| a031a1aafd | |||
| ddfcf3e0e4 | |||
| 0550376e3d | |||
| 6b33a104c8 | |||
| 888943deb4 | |||
| 92a69cf16c | |||
| 398862e88b | |||
| 4a939340a2 | |||
| 62729213d7 | |||
| 0c4777216e | |||
| df4a74726c |
+8
-1
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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 (0–7). 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, 1–7, 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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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]"
|
||||
]
|
||||
}
|
||||
@@ -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 3–4 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"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 "DAL→HOU".
|
||||
let routes: [String]
|
||||
let firstSeen: Date?
|
||||
let lastSeen: Date?
|
||||
/// The route the user has flown most often on this airframe,
|
||||
/// formatted "DAL→HOU (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
|
||||
|
||||
/// "DAL→HOU" 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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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. ANC→PANC, HNL→PHNL, MEX→MMMX).
|
||||
/// 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] = [
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
// Sun→7, 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
|
||||
}
|
||||
@@ -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 IATA↔ICAO 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
|
||||
/// IATA→ICAO 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 ICAO↔ICAO and
|
||||
/// IATA↔IATA 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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
/// ~3–30 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
|
||||
}
|
||||
}
|
||||
@@ -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 + ~1–2 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 (DAL→HOU 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 DFW→AMS 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?
|
||||
}
|
||||
@@ -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,6 +2,8 @@ import Foundation
|
||||
|
||||
actor FlightService {
|
||||
|
||||
static let shared = FlightService()
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
private let session: URLSession
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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?]?
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 IATA→ICAO.
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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).")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 DAL→SAN 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
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);});})();
|
||||
"""
|
||||
}
|
||||
}
|
||||
@@ -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·\(f)") }
|
||||
if let j = leg.classes?.business?.seats, j > 0 { metaPill("J·\(j)") }
|
||||
if let w = leg.classes?.premiumEconomy?.seats, w > 0 { metaPill("W·\(w)") }
|
||||
if let y = leg.classes?.economy?.seats, y > 0 { metaPill("Y·\(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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 5–15 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 2–3 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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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·\(f)") }
|
||||
if let j = leg.classes?.business?.seats, j > 0 { metaPill("J·\(j)") }
|
||||
if let w = leg.classes?.premiumEconomy?.seats, w > 0 { metaPill("W·\(w)") }
|
||||
if let y = leg.classes?.economy?.seats, y > 0 { metaPill("Y·\(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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 "DAL→HOU (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
Reference in New Issue
Block a user