a a vibe-coded abomination experiment of a fragrance review platform built on the atmosphere. drydown.social

adding new weather data to records

+507 -6
+59
src/app.css
··· 292 292 .review-paragraph { 293 293 margin-bottom: 1rem; 294 294 } 295 + 296 + /* Weather Opt-In Banner */ 297 + .weather-opt-in-banner { 298 + background: var(--card-bg); 299 + border: 1px solid var(--card-border); 300 + border-radius: 8px; 301 + padding: 1.5rem; 302 + margin-bottom: 2rem; 303 + display: flex; 304 + flex-direction: column; 305 + gap: 1rem; 306 + } 307 + 308 + .banner-icon { 309 + font-size: 2rem; 310 + } 311 + 312 + .banner-content h3 { 313 + margin: 0 0 0.5rem 0; 314 + font-size: 1.125rem; 315 + color: var(--text-color); 316 + } 317 + 318 + .banner-content p { 319 + margin: 0; 320 + color: var(--text-secondary); 321 + font-size: 0.875rem; 322 + } 323 + 324 + .banner-actions { 325 + display: flex; 326 + gap: 1rem; 327 + align-items: center; 328 + } 329 + 330 + .banner-actions button { 331 + padding: 0.5rem 1rem; 332 + } 333 + 334 + .banner-actions a { 335 + color: var(--text-secondary); 336 + text-decoration: none; 337 + } 338 + 339 + .banner-actions a:hover { 340 + text-decoration: underline; 341 + } 342 + 343 + .error-message { 344 + color: #ef4444; 345 + font-size: 0.875rem; 346 + margin-top: 0.5rem; 347 + } 348 + 349 + @media (prefers-color-scheme: dark) { 350 + .error-message { 351 + color: #f87171; 352 + } 353 + }
+1
src/client/index.ts
··· 7 7 type FetchHandlerOptions, 8 8 } from '@atproto/xrpc' 9 9 import { schemas } from './lexicons.js' 10 + import { CID } from 'multiformats/cid' 10 11 import { type OmitKey, type Un$Typed } from './util.js' 11 12 import { 12 13 ComAtprotoRepoListRecords,
+36 -1
src/client/lexicons.ts
··· 7 7 ValidationError, 8 8 type ValidationResult, 9 9 } from '@atproto/lexicon' 10 - import { is$typed, maybe$typed } from './util.js' 10 + import { type $Typed, is$typed, maybe$typed } from './util.js' 11 11 12 12 export const schemaDict = { 13 13 SocialDrydownFragrance: { ··· 175 175 maxGraphemes: 255, 176 176 maxLength: 3000, 177 177 description: 'Written review (max 255 graphemes)', 178 + }, 179 + weatherOptIn: { 180 + type: 'boolean', 181 + description: 'User opted in to weather data collection', 182 + }, 183 + elevation: { 184 + type: 'integer', 185 + description: 'Elevation in meters above sea level', 186 + }, 187 + uvIndex: { 188 + type: 'integer', 189 + minimum: 0, 190 + maximum: 11, 191 + description: 'Daily maximum UV index (0-11+)', 192 + }, 193 + stage1Temp: { 194 + type: 'integer', 195 + description: 196 + 'Temperature in Celsius at Stage 1 * 10 (e.g. 225 = 22.5°C)', 197 + }, 198 + stage2Temp: { 199 + type: 'integer', 200 + description: 201 + 'Temperature in Celsius at Stage 2 * 10 (e.g. 225 = 22.5°C)', 202 + }, 203 + stage3Temp: { 204 + type: 'integer', 205 + description: 206 + 'Temperature in Celsius at Stage 3 * 10 (e.g. 225 = 22.5°C)', 207 + }, 208 + stage2CompletedAt: { 209 + type: 'string', 210 + format: 'datetime', 211 + description: 212 + 'Timestamp when Stage 2 was completed (for accurate temperature)', 178 213 }, 179 214 }, 180 215 },
+3 -1
src/client/types/social/drydown/fragrance.ts
··· 1 1 /** 2 2 * GENERATED CODE - DO NOT MODIFY 3 3 */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 4 6 import { validate as _validate } from '../../../lexicons' 5 - import { is$typed as _is$typed } from '../../../util' 7 + import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' 6 8 7 9 const is$typed = _is$typed, 8 10 validate = _validate
+3 -1
src/client/types/social/drydown/house.ts
··· 1 1 /** 2 2 * GENERATED CODE - DO NOT MODIFY 3 3 */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 4 6 import { validate as _validate } from '../../../lexicons' 5 - import { is$typed as _is$typed } from '../../../util' 7 + import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' 6 8 7 9 const is$typed = _is$typed, 8 10 validate = _validate
+17 -1
src/client/types/social/drydown/review.ts
··· 1 1 /** 2 2 * GENERATED CODE - DO NOT MODIFY 3 3 */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 4 6 import { validate as _validate } from '../../../lexicons' 5 - import { is$typed as _is$typed } from '../../../util' 7 + import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' 6 8 7 9 const is$typed = _is$typed, 8 10 validate = _validate ··· 36 38 weightedScore?: number 37 39 /** Written review (max 255 graphemes) */ 38 40 text?: string 41 + /** User opted in to weather data collection */ 42 + weatherOptIn?: boolean 43 + /** Elevation in meters above sea level */ 44 + elevation?: number 45 + /** Daily maximum UV index (0-11+) */ 46 + uvIndex?: number 47 + /** Temperature in Celsius at Stage 1 * 10 (e.g. 225 = 22.5°C) */ 48 + stage1Temp?: number 49 + /** Temperature in Celsius at Stage 2 * 10 (e.g. 225 = 22.5°C) */ 50 + stage2Temp?: number 51 + /** Temperature in Celsius at Stage 3 * 10 (e.g. 225 = 22.5°C) */ 52 + stage3Temp?: number 53 + /** Timestamp when Stage 2 was completed (for accurate temperature) */ 54 + stage2CompletedAt?: string 39 55 [k: string]: unknown 40 56 } 41 57
+95 -2
src/components/EditReview.tsx
··· 2 2 import { AtpBaseClient } from '../client/index' 3 3 import { calculateWeightedScore, encodeWeightedScore } from '../utils/reviewUtils' 4 4 import type { OAuthSession } from '@atproto/oauth-client-browser' 5 + import { WeatherService, type WeatherData } from '../services/weatherService' 5 6 6 7 interface EditReviewProps { 7 8 session: OAuthSession ··· 29 30 const [longevity, setLongevity] = useState<number>(0) 30 31 const [overallRating, setOverallRating] = useState<number>(0) 31 32 const [text, setText] = useState<string>('') 33 + 34 + // Weather data state 35 + const [weatherOptInShown, setWeatherOptInShown] = useState(false) 36 + const [weatherData, setWeatherData] = useState<WeatherData | null>(null) 37 + const [weatherLoading, setWeatherLoading] = useState(false) 38 + const [weatherError, setWeatherError] = useState<string | null>(null) 32 39 33 40 const [atp, setAtp] = useState<AtpBaseClient | null>(null) 34 41 ··· 79 86 loadReview() 80 87 }, [reviewUri, session]) 81 88 89 + // Show weather opt-in banner on Stage 3 90 + useEffect(() => { 91 + if (stage === 'stage3' && review && !review.weatherOptIn && !weatherOptInShown) { 92 + setWeatherOptInShown(true) 93 + } 94 + }, [stage, review, weatherOptInShown]) 95 + 82 96 async function saveReview() { 83 97 if (!atp || !review) return false 84 98 ··· 86 100 try { 87 101 const rkey = reviewUri.split('/').pop()! 88 102 89 - const updates = stage === 'stage2' ? { 103 + const updates: any = stage === 'stage2' ? { 90 104 drydownRating, 91 105 midProjection, 92 - sillage 106 + sillage, 107 + stage2CompletedAt: new Date().toISOString() 93 108 } : { 94 109 endRating, 95 110 complexity, ··· 98 113 text: text || undefined 99 114 } 100 115 116 + // Add weather data if collected 117 + if (weatherData) { 118 + updates.weatherOptIn = true 119 + updates.elevation = weatherData.elevation 120 + updates.uvIndex = weatherData.uvIndex 121 + updates.stage1Temp = weatherData.stage1Temp 122 + updates.stage2Temp = weatherData.stage2Temp 123 + updates.stage3Temp = weatherData.stage3Temp 124 + } else if (stage === 'stage3' && weatherOptInShown) { 125 + // User skipped weather collection 126 + updates.weatherOptIn = false 127 + } 128 + 101 129 const updatedReview = { ...review, ...updates } 102 130 103 131 // Calculate weighted score for Stage 3 ··· 121 149 } 122 150 } 123 151 152 + async function handleWeatherOptIn() { 153 + setWeatherLoading(true) 154 + setWeatherError(null) 155 + 156 + try { 157 + const data = await WeatherService.collectWeatherData( 158 + review.createdAt, 159 + review.stage2CompletedAt || null, 160 + new Date().toISOString() 161 + ) 162 + 163 + setWeatherData(data) 164 + setWeatherOptInShown(false) 165 + } catch (error) { 166 + setWeatherError(error instanceof Error ? error.message : 'Failed to collect weather data') 167 + } finally { 168 + setWeatherLoading(false) 169 + } 170 + } 171 + 172 + function handleWeatherSkip(e: Event) { 173 + e.preventDefault() 174 + setWeatherOptInShown(false) 175 + } 176 + 124 177 async function handleSubmit(e: Event) { 125 178 e.preventDefault() 126 179 const success = await saveReview() ··· 223 276 <h3 style={{ fontSize: '1.2rem', marginBottom: '1rem' }}> 224 277 {stage === 'stage2' ? 'Stage 2: Heart Notes' : 'Stage 3: Final Review'} 225 278 </h3> 279 + 280 + {/* Weather Opt-In Banner (Stage 3 only) */} 281 + {weatherOptInShown && stage === 'stage3' && !review.weatherOptIn && ( 282 + <div className="weather-opt-in-banner"> 283 + <div className="banner-icon">🌤️</div> 284 + <div className="banner-content"> 285 + <h3>Add weather context to your review?</h3> 286 + <p> 287 + We'll record elevation, UV index, and temperature. 288 + Your GPS coordinates are never stored. 289 + </p> 290 + </div> 291 + <div className="banner-actions"> 292 + <button type="button" onClick={handleWeatherOptIn} disabled={weatherLoading}> 293 + {weatherLoading ? 'Collecting...' : 'Add Weather Data'} 294 + </button> 295 + <a href="#" onClick={handleWeatherSkip}>Skip</a> 296 + </div> 297 + {weatherError && <div className="error-message">{weatherError}</div>} 298 + </div> 299 + )} 300 + 301 + {/* Success message after weather data collected */} 302 + {weatherData && stage === 'stage3' && ( 303 + <div style={{ 304 + background: '#d1fae5', 305 + border: '1px solid #34d399', 306 + borderRadius: '8px', 307 + padding: '1rem', 308 + marginBottom: '2rem' 309 + }}> 310 + <div style={{ fontSize: '1.5rem', marginBottom: '0.5rem' }}>✓</div> 311 + <div style={{ fontWeight: 'bold', marginBottom: '0.25rem' }}>Weather data added</div> 312 + <div style={{ fontSize: '0.875rem', color: '#065f46' }}> 313 + {weatherData.elevation}m elevation • UV {weatherData.uvIndex} • 314 + {weatherData.stage1Temp / 10}°C → {weatherData.stage2Temp ? `${weatherData.stage2Temp / 10}°C → ` : ''} 315 + {weatherData.stage3Temp / 10}°C 316 + </div> 317 + </div> 318 + )} 226 319 227 320 {stage === 'stage2' ? ( 228 321 <>
+35
src/components/SingleReviewPage.tsx
··· 6 6 import { resolveIdentity } from '../utils/resolveIdentity' 7 7 import { cache, TTL } from '../services/cache' 8 8 9 + /** 10 + * Calculate average temperature from review's weather data 11 + * Temperatures are stored as integers * 10 (e.g., 225 = 22.5°C) 12 + */ 13 + function calculateAverageTemp(review: any): number { 14 + const temps = [ 15 + review.stage1Temp, 16 + review.stage2Temp, 17 + review.stage3Temp 18 + ].filter((t): t is number => t !== undefined && t !== null) 19 + 20 + if (temps.length === 0) return 0 21 + 22 + const sum = temps.reduce((acc, t) => acc + t, 0) 23 + return Math.round(sum / temps.length / 10) // Convert back to Celsius 24 + } 25 + 9 26 interface SingleReviewPageProps { 10 27 handle: string 11 28 rkey: string ··· 281 298 <div class="score-item"> 282 299 <div className="score-label">Value</div> 283 300 <div className="score-value">{review.subjectiveValue}/5</div> 301 + </div> 302 + )} 303 + {review.elevation !== undefined && ( 304 + <div class="score-item"> 305 + <div className="score-label">Altitude</div> 306 + <div className="score-value">{review.elevation}m</div> 307 + </div> 308 + )} 309 + {review.uvIndex !== undefined && ( 310 + <div class="score-item"> 311 + <div className="score-label">UV Index</div> 312 + <div className="score-value">{review.uvIndex}</div> 313 + </div> 314 + )} 315 + {(review.stage1Temp !== undefined || review.stage2Temp !== undefined || review.stage3Temp !== undefined) && ( 316 + <div class="score-item"> 317 + <div className="score-label">Avg Temp</div> 318 + <div className="score-value">{calculateAverageTemp(review)}°C</div> 284 319 </div> 285 320 )} 286 321 </div>
+31
src/lexicons/social.drydown.review.json
··· 83 83 "maxGraphemes": 255, 84 84 "maxLength": 3000, 85 85 "description": "Written review (max 255 graphemes)" 86 + }, 87 + "weatherOptIn": { 88 + "type": "boolean", 89 + "description": "User opted in to weather data collection" 90 + }, 91 + "elevation": { 92 + "type": "integer", 93 + "description": "Elevation in meters above sea level" 94 + }, 95 + "uvIndex": { 96 + "type": "integer", 97 + "minimum": 0, 98 + "maximum": 11, 99 + "description": "Daily maximum UV index (0-11+)" 100 + }, 101 + "stage1Temp": { 102 + "type": "integer", 103 + "description": "Temperature in Celsius at Stage 1 * 10 (e.g. 225 = 22.5°C)" 104 + }, 105 + "stage2Temp": { 106 + "type": "integer", 107 + "description": "Temperature in Celsius at Stage 2 * 10 (e.g. 225 = 22.5°C)" 108 + }, 109 + "stage3Temp": { 110 + "type": "integer", 111 + "description": "Temperature in Celsius at Stage 3 * 10 (e.g. 225 = 22.5°C)" 112 + }, 113 + "stage2CompletedAt": { 114 + "type": "string", 115 + "format": "datetime", 116 + "description": "Timestamp when Stage 2 was completed (for accurate temperature)" 86 117 } 87 118 } 88 119 }
+227
src/services/weatherService.ts
··· 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 + 8 + export 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 + 16 + export interface GPSCoordinates { 17 + latitude: number; 18 + longitude: number; 19 + } 20 + 21 + class 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 227 + export const WeatherService = new WeatherServiceClass();