a a vibe-coded abomination experiment of a fragrance review platform built on the atmosphere. drydown.social
at main 227 lines 7.8 kB view raw
1/** 2 * Weather Service 3 * 4 * Provides GPS-based weather data collection for fragrance reviews using Open-Meteo API. 5 * Privacy-first: GPS coordinates are NEVER stored, only used transiently for API calls. 6 */ 7 8export interface WeatherData { 9 elevation: number; // meters above sea level 10 uvIndex: number; // 0-11 daily maximum 11 stage1Temp: number; // Celsius * 10 (e.g., 225 = 22.5°C) 12 stage2Temp: number | null; // Celsius * 10, null if Stage 2 not completed 13 stage3Temp: number; // Celsius * 10 (e.g., 225 = 22.5°C) 14} 15 16export interface GPSCoordinates { 17 latitude: number; 18 longitude: number; 19} 20 21class WeatherServiceClass { 22 private elevationCache: Map<string, { elevation: number; timestamp: number }> = new Map(); 23 private readonly CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours 24 25 /** 26 * Get GPS coordinates from device 27 * PRIVACY: Coordinates are never stored, only used for API calls 28 */ 29 async getGPSCoordinates(): Promise<GPSCoordinates> { 30 return new Promise((resolve, reject) => { 31 if (!navigator.geolocation) { 32 reject(new Error('GPS not supported by this browser')); 33 return; 34 } 35 36 const timeoutId = setTimeout(() => { 37 reject(new Error('GPS timeout - location took too long to acquire')); 38 }, 10000); 39 40 navigator.geolocation.getCurrentPosition( 41 (position) => { 42 clearTimeout(timeoutId); 43 resolve({ 44 latitude: position.coords.latitude, 45 longitude: position.coords.longitude, 46 }); 47 }, 48 (error) => { 49 clearTimeout(timeoutId); 50 switch (error.code) { 51 case error.PERMISSION_DENIED: 52 reject(new Error('GPS permission denied - please enable location access')); 53 break; 54 case error.POSITION_UNAVAILABLE: 55 reject(new Error('GPS unavailable - location information is not available')); 56 break; 57 case error.TIMEOUT: 58 reject(new Error('GPS timeout - location request timed out')); 59 break; 60 default: 61 reject(new Error('GPS error - unable to get location')); 62 } 63 }, 64 { 65 enableHighAccuracy: true, 66 timeout: 10000, 67 maximumAge: 0, 68 } 69 ); 70 }); 71 } 72 73 /** 74 * Get elevation for coordinates 75 * Cached for 24 hours to reduce API calls 76 */ 77 async getElevation(coords: GPSCoordinates): Promise<number> { 78 const cacheKey = `${coords.latitude.toFixed(2)},${coords.longitude.toFixed(2)}`; 79 const cached = this.elevationCache.get(cacheKey); 80 81 if (cached && Date.now() - cached.timestamp < this.CACHE_DURATION) { 82 return cached.elevation; 83 } 84 85 const controller = new AbortController(); 86 const timeoutId = setTimeout(() => controller.abort(), 5000); 87 88 try { 89 const url = `https://api.open-meteo.com/v1/elevation?latitude=${coords.latitude}&longitude=${coords.longitude}`; 90 const response = await fetch(url, { signal: controller.signal }); 91 92 if (!response.ok) { 93 throw new Error(`Elevation API error: ${response.status}`); 94 } 95 96 const data = await response.json(); 97 const elevation = Math.round(data.elevation[0]); 98 99 // Cache the result 100 this.elevationCache.set(cacheKey, { elevation, timestamp: Date.now() }); 101 102 return elevation; 103 } catch (error) { 104 if (error instanceof Error && error.name === 'AbortError') { 105 throw new Error('Elevation API timeout'); 106 } 107 throw new Error(`Failed to fetch elevation: ${error instanceof Error ? error.message : 'Unknown error'}`); 108 } finally { 109 clearTimeout(timeoutId); 110 } 111 } 112 113 /** 114 * Get daily maximum UV index for today 115 */ 116 async getUVIndex(coords: GPSCoordinates): Promise<number> { 117 const controller = new AbortController(); 118 const timeoutId = setTimeout(() => controller.abort(), 5000); 119 120 try { 121 const url = `https://api.open-meteo.com/v1/forecast?latitude=${coords.latitude}&longitude=${coords.longitude}&daily=uv_index_max&timezone=auto`; 122 const response = await fetch(url, { signal: controller.signal }); 123 124 if (!response.ok) { 125 throw new Error(`UV index API error: ${response.status}`); 126 } 127 128 const data = await response.json(); 129 const uvIndex = Math.round(data.daily.uv_index_max[0]); 130 131 // Clamp to valid range 0-11 132 return Math.max(0, Math.min(11, uvIndex)); 133 } catch (error) { 134 if (error instanceof Error && error.name === 'AbortError') { 135 throw new Error('UV index API timeout'); 136 } 137 throw new Error(`Failed to fetch UV index: ${error instanceof Error ? error.message : 'Unknown error'}`); 138 } finally { 139 clearTimeout(timeoutId); 140 } 141 } 142 143 /** 144 * Get historical temperature for a specific timestamp 145 * Returns temperature * 10 (e.g., 225 = 22.5°C) 146 */ 147 async getHistoricalTemperature(coords: GPSCoordinates, timestamp: string): Promise<number> { 148 const controller = new AbortController(); 149 const timeoutId = setTimeout(() => controller.abort(), 5000); 150 151 try { 152 const date = new Date(timestamp); 153 const dateStr = date.toISOString().split('T')[0]; // YYYY-MM-DD 154 155 const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${coords.latitude}&longitude=${coords.longitude}&start_date=${dateStr}&end_date=${dateStr}&hourly=temperature_2m&timezone=auto`; 156 const response = await fetch(url, { signal: controller.signal }); 157 158 if (!response.ok) { 159 throw new Error(`Temperature API error: ${response.status}`); 160 } 161 162 const data = await response.json(); 163 164 // Find the hour closest to the timestamp 165 const targetHour = date.getHours(); 166 const temps = data.hourly.temperature_2m; 167 const temp = temps[targetHour]; 168 169 if (temp === null || temp === undefined) { 170 throw new Error('Temperature data not available for this time'); 171 } 172 173 // Round to nearest 0.5°C and convert to integer * 10 174 const roundedTemp = Math.round(temp * 2) / 2; 175 return Math.round(roundedTemp * 10); 176 } catch (error) { 177 if (error instanceof Error && error.name === 'AbortError') { 178 throw new Error('Temperature API timeout'); 179 } 180 throw new Error(`Failed to fetch temperature: ${error instanceof Error ? error.message : 'Unknown error'}`); 181 } finally { 182 clearTimeout(timeoutId); 183 } 184 } 185 186 /** 187 * Collect all weather data for a review 188 * 189 * @param stage1Time - ISO timestamp when Stage 1 was created 190 * @param stage2Time - ISO timestamp when Stage 2 was completed (null if not completed) 191 * @param stage3Time - ISO timestamp when Stage 3 is being completed (now) 192 */ 193 async collectWeatherData( 194 stage1Time: string, 195 stage2Time: string | null, 196 stage3Time: string 197 ): Promise<WeatherData> { 198 // Get GPS coordinates (never stored) 199 const coords = await this.getGPSCoordinates(); 200 201 try { 202 // Fetch all data in parallel for performance 203 const [elevation, uvIndex, stage1Temp, stage2Temp, stage3Temp] = await Promise.all([ 204 this.getElevation(coords), 205 this.getUVIndex(coords), 206 this.getHistoricalTemperature(coords, stage1Time), 207 stage2Time ? this.getHistoricalTemperature(coords, stage2Time) : Promise.resolve(null), 208 this.getHistoricalTemperature(coords, stage3Time), 209 ]); 210 211 return { 212 elevation, 213 uvIndex, 214 stage1Temp, 215 stage2Temp, 216 stage3Temp, 217 }; 218 } catch (error) { 219 throw new Error( 220 `Weather data collection failed: ${error instanceof Error ? error.message : 'Unknown error'}` 221 ); 222 } 223 } 224} 225 226// Export singleton instance 227export const WeatherService = new WeatherServiceClass();