the best ios gas tracking app
at main 318 lines 7.9 kB view raw
1import { Database } from "bun:sqlite"; 2 3export interface StationRow { 4 id: string; 5 name: string; 6 lat: number; 7 lng: number; 8 address: string | null; 9 city: string | null; 10 state: string | null; 11 zip: string | null; 12 prices_json: string; 13 fetched_at: number; 14} 15 16export interface Price { 17 nickname: string; 18 formattedPrice: string | null; 19 postedTime: string | null; 20} 21 22export interface Station { 23 id: string; 24 name: string; 25 lat: number; 26 lng: number; 27 address: string | null; 28 city: string | null; 29 state: string | null; 30 zip: string | null; 31 prices: Price[]; 32 fetchedAt: number; 33} 34 35let _db: Database | null = null; 36 37export function getDb(): Database { 38 if (_db) return _db; 39 40 _db = new Database(process.env.DB_PATH ?? "./overpass.db"); 41 _db.exec("PRAGMA journal_mode = WAL"); 42 _db.exec("PRAGMA foreign_keys = ON"); 43 migrate(_db); 44 return _db; 45} 46 47function migrate(db: Database): void { 48 db.exec(` 49 CREATE TABLE IF NOT EXISTS stations ( 50 id TEXT PRIMARY KEY, 51 name TEXT NOT NULL, 52 lat REAL NOT NULL, 53 lng REAL NOT NULL, 54 address TEXT, 55 city TEXT, 56 state TEXT, 57 zip TEXT, 58 prices_json TEXT NOT NULL, 59 fetched_at INTEGER NOT NULL 60 ); 61 62 CREATE INDEX IF NOT EXISTS idx_stations_lat_lng ON stations (lat, lng); 63 64 CREATE TABLE IF NOT EXISTS prefetch_cells ( 65 cell_key TEXT PRIMARY KEY, 66 fetched_at INTEGER NOT NULL 67 ); 68 69 CREATE TABLE IF NOT EXISTS api_keys ( 70 key TEXT PRIMARY KEY, 71 email TEXT, 72 created_at INTEGER, 73 last_seen INTEGER 74 ); 75 76 CREATE TABLE IF NOT EXISTS rate_limit ( 77 key TEXT NOT NULL, 78 window INTEGER NOT NULL, 79 count INTEGER DEFAULT 0, 80 PRIMARY KEY (key, window) 81 ); 82 83 CREATE TABLE IF NOT EXISTS eia_averages ( 84 state TEXT PRIMARY KEY, 85 regular REAL, 86 period TEXT NOT NULL, 87 fetched_at INTEGER NOT NULL 88 ); 89 `); 90} 91 92export function rowToStation(row: StationRow): Station { 93 return { 94 id: row.id, 95 name: row.name, 96 lat: row.lat, 97 lng: row.lng, 98 address: row.address, 99 city: row.city, 100 state: row.state, 101 zip: row.zip, 102 prices: JSON.parse(row.prices_json) as Price[], 103 fetchedAt: row.fetched_at, 104 }; 105} 106 107export function upsertStations(stations: Station[]): void { 108 const db = getDb(); 109 const stmt = db.prepare(` 110 INSERT OR REPLACE INTO stations 111 (id, name, lat, lng, address, city, state, zip, prices_json, fetched_at) 112 VALUES 113 ($id, $name, $lat, $lng, $address, $city, $state, $zip, $prices_json, $fetched_at) 114 `); 115 116 const upsertMany = db.transaction((rows: Station[]) => { 117 for (const s of rows) { 118 stmt.run({ 119 $id: s.id, 120 $name: s.name, 121 $lat: s.lat, 122 $lng: s.lng, 123 $address: s.address ?? null, 124 $city: s.city ?? null, 125 $state: s.state ?? null, 126 $zip: s.zip ?? null, 127 $prices_json: JSON.stringify(s.prices), 128 $fetched_at: s.fetchedAt, 129 }); 130 } 131 }); 132 133 upsertMany(stations); 134} 135 136export function queryStationsInBbox( 137 minLat: number, 138 minLng: number, 139 maxLat: number, 140 maxLng: number, 141): Station[] { 142 const db = getDb(); 143 const rows = db 144 .query<StationRow, [number, number, number, number]>( 145 `SELECT * FROM stations 146 WHERE lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?`, 147 ) 148 .all(minLat, maxLat, minLng, maxLng); 149 return rows.map(rowToStation); 150} 151 152export function queryStationsNear( 153 lat: number, 154 lng: number, 155 radiusKm: number, 156): Station[] { 157 // Use a bounding box approximation (1 deg lat ≈ 111km) 158 const latDelta = radiusKm / 111; 159 const lngDelta = radiusKm / (111 * Math.cos((lat * Math.PI) / 180)); 160 return queryStationsInBbox( 161 lat - latDelta, 162 lng - lngDelta, 163 lat + latDelta, 164 lng + lngDelta, 165 ); 166} 167 168export function isCellFresh(cellKey: string, ttlMs: number): boolean { 169 const db = getDb(); 170 const row = db 171 .query<{ fetched_at: number }, [string]>( 172 "SELECT fetched_at FROM prefetch_cells WHERE cell_key = ?", 173 ) 174 .get(cellKey); 175 if (!row) return false; 176 return Date.now() - row.fetched_at < ttlMs; 177} 178 179/// Returns cell keys that are stale (past ttlMs) but were used recently (within maxAgeMs). 180export function getStaleCells(ttlMs: number, maxAgeMs: number): string[] { 181 const db = getDb(); 182 const now = Date.now(); 183 const rows = db 184 .query<{ cell_key: string }, [number, number]>( 185 `SELECT cell_key FROM prefetch_cells 186 WHERE fetched_at < ? AND fetched_at > ? 187 ORDER BY fetched_at ASC LIMIT 50`, 188 ) 189 .all(now - ttlMs, now - maxAgeMs); 190 return rows.map((r) => r.cell_key); 191} 192 193export function markCellFetched(cellKey: string): void { 194 const db = getDb(); 195 db.run( 196 "INSERT OR REPLACE INTO prefetch_cells (cell_key, fetched_at) VALUES (?, ?)", 197 [cellKey, Date.now()], 198 ); 199} 200 201export function getCacheStats(): { 202 cachedStations: number; 203 oldestFetch: number | null; 204 newestFetch: number | null; 205} { 206 const db = getDb(); 207 const row = db 208 .query< 209 { count: number; oldest: number | null; newest: number | null }, 210 [] 211 >("SELECT COUNT(*) as count, MIN(fetched_at) as oldest, MAX(fetched_at) as newest FROM stations") 212 .get(); 213 return { 214 cachedStations: row?.count ?? 0, 215 oldestFetch: row?.oldest ?? null, 216 newestFetch: row?.newest ?? null, 217 }; 218} 219 220export function lookupApiKey(key: string): boolean { 221 const db = getDb(); 222 const row = db 223 .query<{ key: string }, [string]>( 224 "SELECT key FROM api_keys WHERE key = ?", 225 ) 226 .get(key); 227 if (row) { 228 db.run("UPDATE api_keys SET last_seen = ? WHERE key = ?", [ 229 Date.now(), 230 key, 231 ]); 232 } 233 return row !== null; 234} 235 236export function createApiKey(email: string | null): string { 237 const db = getDb(); 238 const key = `gt_${crypto.randomUUID().replace(/-/g, "")}`; 239 db.run( 240 "INSERT INTO api_keys (key, email, created_at) VALUES (?, ?, ?)", 241 [key, email, Date.now()], 242 ); 243 return key; 244} 245 246// Returns true if under limit, false if rate limited 247export function checkRateLimit(key: string): boolean { 248 const db = getDb(); 249 const now = Date.now(); 250 const currentWindow = Math.floor(now / 60_000); 251 const cutoff = currentWindow - 15; // last 15 minute buckets 252 253 // Lazily expire old buckets 254 db.run( 255 "DELETE FROM rate_limit WHERE key = ? AND window < ?", 256 [key, cutoff], 257 ); 258 259 // Increment current bucket 260 db.run( 261 `INSERT INTO rate_limit (key, window, count) VALUES (?, ?, 1) 262 ON CONFLICT (key, window) DO UPDATE SET count = count + 1`, 263 [key, currentWindow], 264 ); 265 266 // Sum the last 60 buckets 267 const row = db 268 .query<{ total: number }, [string, number]>( 269 "SELECT SUM(count) as total FROM rate_limit WHERE key = ? AND window >= ?", 270 ) 271 .get(key, cutoff); 272 273 return (row?.total ?? 0) <= 300; 274} 275 276const EIA_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 1 week 277 278export interface EIAAverage { 279 state: string; 280 regular: number | null; 281 period: string; 282 fetchedAt: number; 283} 284 285export function getEIAAverages(): EIAAverage[] { 286 const db = getDb(); 287 return db 288 .query< 289 { state: string; regular: number | null; period: string; fetched_at: number }, 290 [] 291 >("SELECT state, regular, period, fetched_at FROM eia_averages") 292 .all() 293 .map((r) => ({ state: r.state, regular: r.regular, period: r.period, fetchedAt: r.fetched_at })); 294} 295 296export function areEIAAveragesFresh(): boolean { 297 const db = getDb(); 298 const row = db 299 .query<{ fetched_at: number }, []>( 300 "SELECT MIN(fetched_at) as fetched_at FROM eia_averages", 301 ) 302 .get(); 303 if (!row?.fetched_at) return false; 304 return Date.now() - row.fetched_at < EIA_TTL_MS; 305} 306 307export function upsertEIAAverages(averages: EIAAverage[]): void { 308 const db = getDb(); 309 const stmt = db.prepare( 310 "INSERT OR REPLACE INTO eia_averages (state, regular, period, fetched_at) VALUES ($state, $regular, $period, $fetched_at)", 311 ); 312 const upsertMany = db.transaction((rows: EIAAverage[]) => { 313 for (const a of rows) { 314 stmt.run({ $state: a.state, $regular: a.regular, $period: a.period, $fetched_at: a.fetchedAt }); 315 } 316 }); 317 upsertMany(averages); 318}