diff --git a/SPEC.md b/SPEC.md deleted file mode 100644 index b7730d3..0000000 --- a/SPEC.md +++ /dev/null @@ -1,398 +0,0 @@ -# gastrack spec - -Local gas price cache server with a public API. Sits on terebithia, prefetches -station-level price data along routes for offline iOS use, and exposes a -rate-limited public REST API under Dunkirk Corp. - ---- - -## Data sources - -### GasBuddy (primary) - -Hits GasBuddy's internal GraphQL API directly. No API key or account required. -Returns station name, coordinates, address, and prices (regular/midgrade/premium/diesel) -with a per-price timestamp indicating when a user last reported it. -Unofficial — could break if GasBuddy changes their internals. - -**⚠️ Cloudflare protection:** As of May 2025, GasBuddy added Cloudflare bot -protection. Requests may be intermittently blocked. If blocking becomes -persistent, [FlareSolverr](https://github.com/FlareSolverr/FlareSolverr) can -be run as a sidecar on terebithia to bypass it — the Bun client would proxy -requests through `http://localhost:8191/v1` instead of hitting GasBuddy directly. - -#### Endpoint - -``` -POST https://www.gasbuddy.com/graphql -Content-Type: application/json -User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) ... -Origin: https://www.gasbuddy.com -Referer: https://www.gasbuddy.com/ -``` - -No auth headers required. - -#### Query: `StationsByLocation` - -Used by `/stations/nearby` and `/prefetch/route`. Returns up to ~20 stations -near a coordinate pair. - -```graphql -query StationsByLocation($lat: Float!, $lng: Float!, $fuel: Int) { - stations: stationsByLocation(lat: $lat, lng: $lng, fuel: $fuel) { - results { - id - name - latitude - longitude - address { - line1 - city - state - zip - } - prices(fuel: $fuel) { - nickname # "Regular" | "Midgrade" | "Premium" | "Diesel" - formattedPrice # "$3.29" or null if no recent report - postedTime # ISO 8601 timestamp of last user report - } - } - } -} -``` - -Variables: `{ lat: 39.9556, lng: -83.1763, fuel: 1 }` - -Fuel type codes: `1` = regular unleaded (used for all fetches — prices for -other grades are returned in the same response via the `prices` array). - -#### Query: `GetStation` - -Used for single-station lookups by ID (e.g. enriching a specific station). -Returns cash and credit prices separately. - -```graphql -query GetStation($id: ID!) { - station(id: $id) { - fuels - prices { - cash { postedTime price } - credit { postedTime price } - } - } -} -``` - -Variables: `{ id: "12345" }` — GasBuddy station ID as a string. - -#### Response shape - -```json -{ - "data": { - "stations": { - "results": [ - { - "id": "12345", - "name": "Circle K", - "latitude": 39.9556, - "longitude": -83.1763, - "address": { - "line1": "123 Main St", - "city": "Cedarville", - "state": "OH", - "zip": "45314" - }, - "prices": [ - { "nickname": "Regular", "formattedPrice": "$3.29", "postedTime": "2026-03-26T08:00:00Z" }, - { "nickname": "Midgrade", "formattedPrice": "$3.59", "postedTime": "2026-03-26T07:30:00Z" }, - { "nickname": "Premium", "formattedPrice": "$3.89", "postedTime": null }, - { "nickname": "Diesel", "formattedPrice": null, "postedTime": null } - ] - } - ] - } - } -} -``` - -`formattedPrice` and `postedTime` are `null` when no user has reported that -fuel type recently. Treat null prices as unknown, not zero. - -#### Rate limiting / etiquette - -GasBuddy does not publish rate limits. Based on community reverse engineering: -- Stay under ~1 request per 5 seconds sustained -- Fetches are serialized in the Bun server with a 500ms delay between calls -- Grid cell deduplication prevents re-fetching the same ~5km area within the TTL -- Never call `StationsByLocation` more than once per grid cell per 30 minutes - -### EIA API (baseline) -Free official US government API. Weekly state-level average prices by fuel type. -Used as a sanity check — flag any cached station price that deviates more than -~20% from the current state average as potentially stale. Never used as a -primary price source. - -### Google Places (enrichment, optional) -`fuelOptions` field on Place Details. Used selectively to enrich the top N -cheapest stations along a route with hours, ratings, and a second price -data point for cross-validation. Only called for stations the app will -actually surface to the user, not for bulk fetches. Burns into the $200/month -free credit — monitor usage. - ---- - -## Caching - -SQLite via Bun's built-in driver. Single `gastrack.db` file next to the process. - -### Schema - -```sql -CREATE TABLE stations ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - lat REAL NOT NULL, - lng REAL NOT NULL, - address TEXT, - city TEXT, - state TEXT, - zip TEXT, - prices_json TEXT NOT NULL, -- JSON array of { nickname, formattedPrice, postedTime } - fetched_at INTEGER NOT NULL -- unix ms -); - -CREATE INDEX idx_stations_lat_lng ON stations (lat, lng); - -CREATE TABLE prefetch_cells ( - cell_key TEXT PRIMARY KEY, -- "latGrid:lngGrid" (~5km cells) - fetched_at INTEGER NOT NULL -); - -CREATE TABLE api_keys ( - key TEXT PRIMARY KEY, -- "gt_" prefixed random token - email TEXT, - created_at INTEGER, - last_seen INTEGER -); - -CREATE TABLE rate_limit ( - key TEXT NOT NULL, - window INTEGER NOT NULL, -- floor(unix_ms / 60000), i.e. minute bucket - count INTEGER DEFAULT 0, - PRIMARY KEY (key, window) -); -``` - -### TTLs - -| Context | TTL | -|---|---| -| Live queries (`/stations/nearby`) | 30 minutes | -| Morning prefetch (`/prefetch/route`) | 6 hours | -| Offline reads (`/stations/bbox`) | No expiry — serves stale indefinitely | - -Grid cells (~5km squares) are deduplicated so overlapping route samples -don't double-fetch the same area from GasBuddy. - -### Price change frequency -Real-world gas prices change 3–4 times per week on average. The main -risk window is late afternoon (~3–7pm local) when wholesale markets close -and stations cascade updates. The 6-hour prefetch TTL covers a full driving -day safely. A soft re-fetch can be triggered by the iOS app if it's opened -after 4pm and cached data is from before noon. - ---- - -## Auth model - -Two separate credentials. Neither has elevated data access — the distinction -is purely about which endpoints are accessible. - -### Device secret (`GASTRACK_DEVICE_SECRET`) -A single env var. Baked into the iOS app's Keychain at install time. -Only unlocks `POST /keys/register`. Has no access to data endpoints. -Not stored in the database. - -### User API keys -Minted via `/keys/register`. Stored in the `api_keys` table. -Required on all data endpoints via `Authorization: Bearer `. -All keys are subject to identical rate limits and size caps — there are -no tiers. - ---- - -## Rate limiting - -Sliding window over the `rate_limit` table. Per key, per minute bucket. - -**Limit: 60 requests / hour** - -Implementation: on each request, increment the current minute bucket and -sum the last 60 buckets. Reject with 429 if the sum exceeds the limit. -Expire old buckets (>2 hours) lazily on write. - ---- - -## Request size caps - -Prevent bulk GasBuddy fetches regardless of rate limit status. - -### Bbox endpoints (`/stations/nearby`, `/stations/bbox`) -Max area: **0.5 degree²** (~50×50km at US latitudes). -Reject with 400 if `(max_lat - min_lat) * (max_lng - min_lng) > 0.5`. - -### Route endpoint (`/prefetch/route`) -Three independent checks, all enforced before any fetching: - -| Check | Limit | Rationale | -|---|---|---| -| Raw input points | 500 max | Prevent oversized payloads | -| Total route distance | 200km max | Prevent cross-country routes | -| Generated sample count | 25 max | Direct cap on GasBuddy fetches | - -At the default 8km sample interval, a 200km route generates exactly 25 -samples — the checks are consistent. The sample count check is the critical -one since a dense squiggly route could pass the distance check but still -generate excessive samples. - ---- - -## Endpoints - -All data endpoints require `Authorization: Bearer `. -`/keys/register` requires `Authorization: Bearer `. -`/health` is unauthenticated. - -### `GET /health` -Cache stats. Unauthenticated. Used for a debug screen in the iOS app. - -```json -{ - "ok": true, - "cached_stations": 412, - "oldest_fetch": "2026-03-26T08:00:00.000Z", - "newest_fetch": "2026-03-26T14:32:11.000Z" -} -``` - -### `POST /keys/register` -Mint a new API key. Requires device secret. - -Request: -```json -{ "email": "user@example.com" } -``` - -Response: -```json -{ "key": "gt_abc123..." } -``` - -### `GET /stations/nearby` -Live-first. Fetches from GasBuddy if area isn't cached, otherwise serves cache. -Use for the current-location view. - -Params: `lat`, `lng`, `radius_km` (default 8) -Caps: bbox derived from radius must be ≤ 0.5 deg² - -### `GET /stations/bbox` -Cache-only. Never triggers a GasBuddy fetch. Serves stale data indefinitely. -Use for offline map rendering — pass the MapKit viewport bounds. - -Params: `min_lat`, `min_lng`, `max_lat`, `max_lng` -Caps: area ≤ 0.5 deg² - -### `POST /prefetch/route` -The main morning prefetch call. Samples the route polyline, fetches GasBuddy -for uncached cells, returns all stations in the corridor plus the bounding box -to store for offline use. - -Request: -```json -{ - "points": [[39.95, -83.17], [40.00, -82.99], ...], - "interval_km": 8 -} -``` - -Response: -```json -{ - "bbox": { "minLat": 39.9, "minLng": -83.2, "maxLat": 40.2, "maxLng": -82.7 }, - "samples": 5, - "stations": [...], - "count": 87, - "cached_at": 1743000000000 -} -``` - -### Station object -```typescript -{ - id: string - name: string - lat: number - lng: number - address: string - city: string - state: string - zip: string - fetchedAt: number // unix ms - prices: { - nickname: string // "Regular" | "Midgrade" | "Premium" | "Diesel" - formattedPrice: string | null // "$3.29" or null if unreported - postedTime: string | null // ISO timestamp of last user report - }[] -} -``` - ---- - -## iOS integration - -### Auth -Device secret stored in Keychain at install. User key minted on first launch -via `/keys/register`, also stored in Keychain. User key attached to all -subsequent requests as a Bearer token. - -### Prefetch flow -Triggered once per day via a background task or Shortcuts/Focus automation: -1. Fetch the day's route from MapKit Directions -2. Extract polyline coordinates -3. `POST /prefetch/route` with the coordinate array -4. Store returned `bbox` in UserDefaults -5. Stations are now cached on the server for offline use - -### Offline flow -While driving, pass the current MapKit viewport to `GET /stations/bbox`. -Pure cache read — works with no connectivity to the homelab as long as -the prefetch ran. Falls back gracefully if the server is unreachable -(show last known data with a stale timestamp indicator). - -### Price staleness indicator -Show a warning in the UI if `fetchedAt` is older than 6 hours, or if the -station's reported price deviates more than 20% from the EIA state average -for that day. - ---- - -## Deployment (terebithia) - -Bun process managed by NixOS systemd via `mkService`. Binds to Tailscale -interface only — not exposed on public interfaces. `gastrack.db` persists -next to the process. - -For the public API, put Cloudflare as a reverse proxy in front with the -origin locked to Cloudflare IPs only. The Bun server handles all auth and -rate limiting itself — no Cloudflare-specific features needed. - -Environment variables: -``` -PORT=7878 -GASTRACK_DEVICE_SECRET= -EIA_API_KEY= -GOOGLE_PLACES_KEY= -FLARESOLVERR_URL=http://localhost:8191 # optional, set if Cloudflare blocking becomes persistent -``` \ No newline at end of file diff --git a/api/src/db.ts b/api/src/db.ts index 76c42fa..53fdf3b 100644 --- a/api/src/db.ts +++ b/api/src/db.ts @@ -176,6 +176,20 @@ export function isCellFresh(cellKey: string, ttlMs: number): boolean { return Date.now() - row.fetched_at < ttlMs; } +/// Returns cell keys that are stale (past ttlMs) but were used recently (within maxAgeMs). +export function getStaleCells(ttlMs: number, maxAgeMs: number): string[] { + const db = getDb(); + const now = Date.now(); + const rows = db + .query<{ cell_key: string }, [number, number]>( + `SELECT cell_key FROM prefetch_cells + WHERE fetched_at < ? AND fetched_at > ? + ORDER BY fetched_at ASC LIMIT 50`, + ) + .all(now - ttlMs, now - maxAgeMs); + return rows.map((r) => r.cell_key); +} + export function markCellFetched(cellKey: string): void { const db = getDb(); db.run( diff --git a/api/src/handlers/stations.ts b/api/src/handlers/stations.ts index ff709b8..0eeb725 100644 --- a/api/src/handlers/stations.ts +++ b/api/src/handlers/stations.ts @@ -9,7 +9,7 @@ import { import { fetchStationsByLocation } from "../gasbuddy.ts"; import { latLngToCell } from "../geo.ts"; -const NEARBY_TTL_MS = 2 * 60 * 60 * 1000; // 2 hours +export const NEARBY_TTL_MS = 2 * 60 * 60 * 1000; // 2 hours const MAX_BBOX_AREA = 0.5; export async function handleNearby(req: Request): Promise { @@ -46,11 +46,13 @@ export async function handleNearby(req: Request): Promise { try { const stations = await fetchStationsByLocation(lat, lng); upsertStations(stations); - markCellFetched(cellKey); } catch (e) { console.error("GasBuddy fetch failed:", e); // Fall through to stale cache rather than error } + // Always mark the cell fetched — even on failure — so we back off + // rather than hammering GasBuddy on every request. + markCellFetched(cellKey); } const stations = queryStationsNear(lat, lng, radiusKm); diff --git a/api/src/index.ts b/api/src/index.ts index d994fd2..b6a7050 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -1,11 +1,13 @@ import { serve } from "bun"; -import { getDb } from "./db.ts"; +import { getDb, getStaleCells, markCellFetched, upsertStations } from "./db.ts"; import { handleEIAAverages, refreshEIAAverages } from "./handlers/eia.ts"; import { handleHealth } from "./handlers/health.ts"; import { handleRegisterKey } from "./handlers/keys.ts"; import { logged } from "./logger.ts"; -import { handleBbox, handleNearby } from "./handlers/stations.ts"; +import { handleBbox, handleNearby, NEARBY_TTL_MS } from "./handlers/stations.ts"; import { handlePrefetchRoute } from "./handlers/prefetch.ts"; +import { fetchStationsByLocation } from "./gasbuddy.ts"; +import { cellCenter } from "./geo.ts"; // Initialize DB on startup getDb(); @@ -13,6 +15,27 @@ getDb(); // Kick off EIA refresh in background (won't block startup) refreshEIAAverages(); +// Proactively re-fetch stale cells that were requested in the last 24 hours. +// Runs every 5 minutes; serializes GasBuddy calls with a 500ms delay. +async function proactiveRefresh() { + const stale = getStaleCells(NEARBY_TTL_MS, 24 * 60 * 60 * 1000); + if (stale.length === 0) return; + console.log(`[refresh] ${stale.length} stale cells to refresh`); + for (const cellKey of stale) { + const { lat, lng } = cellCenter(cellKey); + try { + const stations = await fetchStationsByLocation(lat, lng); + upsertStations(stations); + } catch (e) { + console.error(`[refresh] GasBuddy fetch failed for ${cellKey}:`, e); + } + markCellFetched(cellKey); // always update timestamp to enforce backoff + await new Promise((r) => setTimeout(r, 500)); + } +} + +setInterval(proactiveRefresh, 30 * 60 * 1000); // every 30 minutes + const server = serve({ port: process.env.PORT ? parseInt(process.env.PORT, 10) : 7878, diff --git a/gastrack/Services/BackgroundRefreshService.swift b/gastrack/Services/BackgroundRefreshService.swift new file mode 100644 index 0000000..52513d7 --- /dev/null +++ b/gastrack/Services/BackgroundRefreshService.swift @@ -0,0 +1,55 @@ +import CoreLocation +import Foundation + +/// Lightweight refresh logic — called when the app becomes active. +/// No background modes or BGTaskScheduler needed. +enum BackgroundRefreshService { + private static let nearbyKey = "last_nearby_refresh" + private static let routeKey = "last_route_refresh" + private static let nearbyTTL: TimeInterval = 30 * 60 // 30 min + private static let routeTTL: TimeInterval = 6 * 60 * 60 // 6 h + + static func refreshIfNeeded() async { + async let _ = refreshNearbyIfNeeded() + async let _ = refreshRouteIfNeeded() + } + + // MARK: - Nearby + + static func refreshNearbyIfNeeded() async { + let last = UserDefaults.standard.double(forKey: nearbyKey) + guard Date().timeIntervalSince1970 - last > nearbyTTL else { return } + + let lat = UserDefaults.standard.double(forKey: "last_lat") + let lng = UserDefaults.standard.double(forKey: "last_lng") + guard lat != 0 || lng != 0 else { return } + + if let results = try? await APIClient.shared.fetchNearby(lat: lat, lng: lng, radiusKm: 16) { + let center = CLLocationCoordinate2D(latitude: lat, longitude: lng) + await MainActor.run { StationStore.shared.replace(results, near: center, radiusKm: 16) } + UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: nearbyKey) + } + } + + // MARK: - Route + + static func refreshRouteIfNeeded() async { + let last = UserDefaults.standard.double(forKey: routeKey) + guard Date().timeIntervalSince1970 - last > routeTTL else { return } + + guard let json = UserDefaults.standard.string(forKey: "route_points"), + let data = json.data(using: .utf8), + let points = try? JSONDecoder().decode([[Double]].self, from: data), + !points.isEmpty else { return } + + if let response = try? await APIClient.shared.prefetchRoute(points: points) { + await MainActor.run { StationStore.shared.merge(response.stations) } + UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: routeKey) + } + } + + /// Call after a successful manual prefetch to reset the route TTL. + static func markRouteRefreshed() { + UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: routeKey) + } +} diff --git a/gastrack/Services/LocationManager.swift b/gastrack/Services/LocationManager.swift index ffeeb45..7c09991 100644 --- a/gastrack/Services/LocationManager.swift +++ b/gastrack/Services/LocationManager.swift @@ -30,6 +30,8 @@ final class LocationManager: NSObject, ObservableObject, CLLocationManagerDelega nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { guard let loc = locations.last else { return } + UserDefaults.standard.set(loc.coordinate.latitude, forKey: "last_lat") + UserDefaults.standard.set(loc.coordinate.longitude, forKey: "last_lng") Task { @MainActor in self.location = loc } } diff --git a/gastrack/Services/StationStore.swift b/gastrack/Services/StationStore.swift index 632be41..50ab4c7 100644 --- a/gastrack/Services/StationStore.swift +++ b/gastrack/Services/StationStore.swift @@ -85,6 +85,25 @@ final class StationStore: ObservableObject { for s in stations { byId[s.id] = s } } + /// Upserts incoming stations and removes any previously cached stations + /// in the same area that the server no longer returns. + func replace(_ incoming: [Station], near center: CLLocationCoordinate2D, radiusKm: Double) { + let incomingIds = Set(incoming.map { $0.id }) + let latDelta = radiusKm / 111.0 + let lngDelta = radiusKm / (111.0 * cos(center.latitude * .pi / 180)) + let stale = byId.values.filter { s in + abs(s.lat - center.latitude) <= latDelta && + abs(s.lng - center.longitude) <= lngDelta && + !incomingIds.contains(s.id) + } + try? db.write { d in + for s in stale { try Station.deleteOne(d, key: s.id) } + for s in incoming { try s.save(d) } + } + for s in stale { byId.removeValue(forKey: s.id) } + for s in incoming { byId[s.id] = s } + } + func clear() { try? db.write { try Station.deleteAll($0) } byId = [:] diff --git a/gastrack/Views/MapStationsView.swift b/gastrack/Views/MapStationsView.swift index 1dd40e5..db016ee 100644 --- a/gastrack/Views/MapStationsView.swift +++ b/gastrack/Views/MapStationsView.swift @@ -148,7 +148,7 @@ struct MapStationsView: View { @State private var selectedStation: Station? @State private var isLoading = false @State private var error: String? - @State private var lastAutoLoad: Date = .distantPast + @State private var fetchedRegions: [MKCoordinateRegion] = [] @State private var showNativeMap = false @State private var mapReady = false @@ -162,7 +162,9 @@ struct MapStationsView: View { selectedStation: $selectedStation, onRegionChanged: { region in visibleRegion = region - mapReady = true + if !mapReady { + mapReady = true + } updateDisplayed() Task { await autoLoad() } } @@ -260,12 +262,26 @@ struct MapStationsView: View { Task { @MainActor in updateDisplayed() } } } - guard Date().timeIntervalSince(lastAutoLoad) > 60 else { return } - lastAutoLoad = Date() + guard needsLiveFetch() else { return } await loadVisible(live: false) Task { await loadVisible(live: true) } } + /// Returns true if the visible region has no stations or any stale station, + /// or if we haven't fetched this area before. + private func needsLiveFetch() -> Bool { + guard let region = visibleRegion else { return true } + // New region we haven't fetched yet + let alreadyFetched = fetchedRegions.contains { + $0.contains(region.center) + } + if !alreadyFetched { return true } + // Stale or missing data in the visible area + let visible = StationStore.shared.stations(in: region) + if visible.isEmpty { return true } + return visible.contains { $0.isStale } + } + private func loadVisible(live: Bool) async { let region = visibleRegion ?? { guard let loc = location.location else { return nil } @@ -285,16 +301,17 @@ struct MapStationsView: View { do { let radiusKm = max(latSpan, lngSpan) * 111 / 2 let results = try await api.fetchNearby(lat: region.center.latitude, lng: region.center.longitude, radiusKm: radiusKm) - store.merge(results) + store.replace(results, near: region.center, radiusKm: radiusKm) + fetchedRegions.append(region) stations = store.stations(in: region) updateDisplayed() } catch { self.error = error.localizedDescription } isLoading = false } else { - let minLat = region.center.latitude - latSpan / 2 - let maxLat = region.center.latitude + latSpan / 2 - let minLng = region.center.longitude - lngSpan / 2 - let maxLng = region.center.longitude + lngSpan / 2 + let minLat = region.center.latitude - latSpan * 0.75 + let maxLat = region.center.latitude + latSpan * 0.75 + let minLng = region.center.longitude - lngSpan * 0.75 + let maxLng = region.center.longitude + lngSpan * 0.75 if let results = try? await api.fetchBbox(minLat: minLat, minLng: minLng, maxLat: maxLat, maxLng: maxLng) { store.merge(results) } @@ -304,6 +321,13 @@ struct MapStationsView: View { } } +extension MKCoordinateRegion { + func contains(_ coordinate: CLLocationCoordinate2D) -> Bool { + abs(coordinate.latitude - center.latitude) <= span.latitudeDelta / 2 && + abs(coordinate.longitude - center.longitude) <= span.longitudeDelta / 2 + } +} + extension Station: Hashable { static func == (lhs: Station, rhs: Station) -> Bool { lhs.id == rhs.id } func hash(into hasher: inout Hasher) { hasher.combine(id) } diff --git a/gastrack/Views/NearbyView.swift b/gastrack/Views/NearbyView.swift index f059ce0..eeb5458 100644 --- a/gastrack/Views/NearbyView.swift +++ b/gastrack/Views/NearbyView.swift @@ -116,7 +116,7 @@ struct NearbyView: View { error = nil do { let results = try await api.fetchNearby(lat: coord.latitude, lng: coord.longitude, radiusKm: radiusKm) - store.merge(results) + store.replace(results, near: coord, radiusKm: radiusKm) recompute() } catch { self.error = error.localizedDescription diff --git a/gastrack/Views/PrefetchView.swift b/gastrack/Views/PrefetchView.swift index 31663d5..5770d45 100644 --- a/gastrack/Views/PrefetchView.swift +++ b/gastrack/Views/PrefetchView.swift @@ -39,9 +39,14 @@ struct PrefetchView: View { enum ActiveField { case from, to } + @AppStorage("route_from_text") private var fromText: String = "" + @AppStorage("route_to_text") private var toText: String = "" + @AppStorage("route_to_lat") private var toLatSaved: Double = 0 + @AppStorage("route_to_lng") private var toLngSaved: Double = 0 + @AppStorage("route_from_lat") private var fromLatSaved: Double = 0 + @AppStorage("route_from_lng") private var fromLngSaved: Double = 0 + @FocusState private var focused: ActiveField? - @State private var fromText = "" - @State private var toText = "" @State private var fromItem: MKMapItem? // nil = current location @State private var toItem: MKMapItem? @State private var route: MKRoute? @@ -147,6 +152,20 @@ struct PrefetchView: View { .navigationTitle("Prefetch Route") .navigationBarTitleDisplayMode(.inline) } + .task { await restoreRoute() } + } + + private func restoreRoute() async { + guard route == nil else { return } + if toLatSaved != 0 && toLngSaved != 0 { + let toCoord = CLLocationCoordinate2D(latitude: toLatSaved, longitude: toLngSaved) + toItem = MKMapItem(placemark: MKPlacemark(coordinate: toCoord)) + } + if fromLatSaved != 0 && fromLngSaved != 0 { + let fromCoord = CLLocationCoordinate2D(latitude: fromLatSaved, longitude: fromLngSaved) + fromItem = MKMapItem(placemark: MKPlacemark(coordinate: fromCoord)) + } + if toItem != nil { await fetchRoute() } } // MARK: - Row @@ -205,12 +224,17 @@ struct PrefetchView: View { Task { let req = MKLocalSearch.Request(completion: completion) guard let item = try? await MKLocalSearch(request: req).start().mapItems.first else { return } + let coord = item.placemark.coordinate if field == .from { fromText = completion.title fromItem = item + fromLatSaved = coord.latitude + fromLngSaved = coord.longitude } else { toText = completion.title toItem = item + toLatSaved = coord.latitude + toLngSaved = coord.longitude } await fetchRoute() } @@ -256,6 +280,10 @@ struct PrefetchView: View { let response = try await api.prefetchRoute(points: points) store.merge(response.stations) prefetchResult = (stations: response.count, samples: response.samples) + if let json = String(data: (try? JSONEncoder().encode(points)) ?? Data(), encoding: .utf8) { + UserDefaults.standard.set(json, forKey: "route_points") + } + BackgroundRefreshService.markRouteRefreshed() } catch { errorMsg = error.localizedDescription } diff --git a/gastrack/gastrack.xcodeproj/project.pbxproj b/gastrack/gastrack.xcodeproj/project.pbxproj index 8590136..7fb5050 100644 --- a/gastrack/gastrack.xcodeproj/project.pbxproj +++ b/gastrack/gastrack.xcodeproj/project.pbxproj @@ -24,6 +24,7 @@ 0B5E3B602F75C0F1005F7F70 /* StationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B5E3B5F2F75C0F1005F7F70 /* StationStore.swift */; }; 0B5E3B622F75C40F005F7F70 /* PrefetchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B5E3B612F75C40F005F7F70 /* PrefetchView.swift */; }; 0B5E3B6A2F75F12D005F7F70 /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = 0B5E3B692F75F12D005F7F70 /* GRDB */; }; + 0B5E3B6C2F76D765005F7F70 /* BackgroundRefreshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B5E3B6B2F76D765005F7F70 /* BackgroundRefreshService.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -44,6 +45,7 @@ 0B5E3B5D2F75C0B6005F7F70 /* gas.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = gas.icon; sourceTree = ""; }; 0B5E3B5F2F75C0F1005F7F70 /* StationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StationStore.swift; sourceTree = ""; }; 0B5E3B612F75C40F005F7F70 /* PrefetchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefetchView.swift; sourceTree = ""; }; + 0B5E3B6B2F76D765005F7F70 /* BackgroundRefreshService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshService.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -83,6 +85,7 @@ 0B5E3B492F75B002005F7F70 /* Services */ = { isa = PBXGroup; children = ( + 0B5E3B6B2F76D765005F7F70 /* BackgroundRefreshService.swift */, 0B5E3B5F2F75C0F1005F7F70 /* StationStore.swift */, 0B5E3B5B2F75B3E9005F7F70 /* EIAService.swift */, 0B5E3B462F75B002005F7F70 /* APIClient.swift */, @@ -196,6 +199,7 @@ 0B5E3B582F75B002005F7F70 /* KeychainService.swift in Sources */, 0B5E3B392F75ACD9005F7F70 /* gastrackApp.swift in Sources */, 0B5E3B602F75C0F1005F7F70 /* StationStore.swift in Sources */, + 0B5E3B6C2F76D765005F7F70 /* BackgroundRefreshService.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/gastrack/gastrackApp.swift b/gastrack/gastrackApp.swift index 5a81f4f..70dba15 100644 --- a/gastrack/gastrackApp.swift +++ b/gastrack/gastrackApp.swift @@ -41,6 +41,7 @@ struct gastrackApp: App { @StateObject private var api = APIClient.shared @StateObject private var eia = EIAService.shared @StateObject private var store = StationStore.shared + @Environment(\.scenePhase) private var scenePhase var body: some Scene { WindowGroup { @@ -50,5 +51,10 @@ struct gastrackApp: App { .environmentObject(store) .task { await eia.load(api: api) } } + .onChange(of: scenePhase) { _, phase in + if phase == .active { + Task { await BackgroundRefreshService.refreshIfNeeded() } + } + } } }