a a vibe-coded abomination experiment of a fragrance review platform built on the atmosphere.
drydown.social
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();