/** * Weather API service using Open-Meteo (free, no API key required) * https://open-meteo.com/ */ export interface WeatherData { temperature: number; // Celsius temperatureFahrenheit: number; // Fahrenheit feelsLike: number; // Celsius feelsLikeFahrenheit: number; // Fahrenheit weatherCode: number; // WMO Weather Code weatherDescription: string; weatherDescriptionKey: string; // `weatherUi.condition.*` i18n key for weatherDescription weatherIcon: string; // Emoji precipitation: number; // mm precipitationProbability: number; // % humidity: number; // % windSpeed: number; // km/h windDirection: number; // degrees windDirectionLabel: string; // N, NE, E, etc. cloudCover: number; // % visibility: number; // meters uvIndex: number; isDay: boolean; timestamp: number; } export interface AirQualityData { aqi: number; // Air Quality Index (US EPA standard) aqiLabel: string; // Good, Moderate, Unhealthy, etc. aqiLabelKey: string; // `weatherUi.aqi.*` i18n key for aqiLabel aqiColor: string; // Color for UI pm25: number; // PM2.5 concentration pm10: number; // PM10 concentration timestamp: number; } export interface WeatherForecast { daily: { date: string; maxTemp: number; minTemp: number; weatherCode: number; weatherIcon: string; precipitationProbability: number; sunrise: string; sunset: string; }[]; } export interface SkateabilityScore { score: number; // 0-100 label: string; // Perfect, Good, Okay, Poor, Unrideable labelKey: string; // `weatherUi.skateability.*` i18n key for label color: string; // green, yellow, orange, red reasons: string[]; reasonKeys: string[]; // `weatherUi.reason.*` i18n keys, parallel to `reasons` } // WMO Weather Codes to emoji and description. The English `description` stays on // the object for non-UI (model/API) consumers; the UI renders `descriptionKey` // through `t()` (see src/i18n/legacy/weather.ts → `weatherUi.condition.*`). const weatherCodes: Record = { 0: { icon: '☀️', description: 'Clear sky', descriptionKey: 'weatherUi.condition.clearSky' }, 1: { icon: '🌤️', description: 'Mainly clear', descriptionKey: 'weatherUi.condition.mainlyClear' }, 2: { icon: '⛅', description: 'Partly cloudy', descriptionKey: 'weatherUi.condition.partlyCloudy' }, 3: { icon: '☁️', description: 'Overcast', descriptionKey: 'weatherUi.condition.overcast' }, 45: { icon: '🌫️', description: 'Foggy', descriptionKey: 'weatherUi.condition.foggy' }, 48: { icon: '🌫️', description: 'Depositing rime fog', descriptionKey: 'weatherUi.condition.depositingRimeFog' }, 51: { icon: '🌦️', description: 'Light drizzle', descriptionKey: 'weatherUi.condition.lightDrizzle' }, 53: { icon: '🌦️', description: 'Moderate drizzle', descriptionKey: 'weatherUi.condition.moderateDrizzle' }, 55: { icon: '🌧️', description: 'Dense drizzle', descriptionKey: 'weatherUi.condition.denseDrizzle' }, 56: { icon: '🌧️', description: 'Light freezing drizzle', descriptionKey: 'weatherUi.condition.lightFreezingDrizzle' }, 57: { icon: '🌧️', description: 'Dense freezing drizzle', descriptionKey: 'weatherUi.condition.denseFreezingDrizzle' }, 61: { icon: '🌧️', description: 'Slight rain', descriptionKey: 'weatherUi.condition.slightRain' }, 63: { icon: '🌧️', description: 'Moderate rain', descriptionKey: 'weatherUi.condition.moderateRain' }, 65: { icon: '🌧️', description: 'Heavy rain', descriptionKey: 'weatherUi.condition.heavyRain' }, 66: { icon: '🌧️', description: 'Light freezing rain', descriptionKey: 'weatherUi.condition.lightFreezingRain' }, 67: { icon: '🌧️', description: 'Heavy freezing rain', descriptionKey: 'weatherUi.condition.heavyFreezingRain' }, 71: { icon: '🌨️', description: 'Slight snow', descriptionKey: 'weatherUi.condition.slightSnow' }, 73: { icon: '🌨️', description: 'Moderate snow', descriptionKey: 'weatherUi.condition.moderateSnow' }, 75: { icon: '🌨️', description: 'Heavy snow', descriptionKey: 'weatherUi.condition.heavySnow' }, 77: { icon: '🌨️', description: 'Snow grains', descriptionKey: 'weatherUi.condition.snowGrains' }, 80: { icon: '🌦️', description: 'Slight rain showers', descriptionKey: 'weatherUi.condition.slightRainShowers' }, 81: { icon: '🌧️', description: 'Moderate rain showers', descriptionKey: 'weatherUi.condition.moderateRainShowers' }, 82: { icon: '⛈️', description: 'Violent rain showers', descriptionKey: 'weatherUi.condition.violentRainShowers' }, 85: { icon: '🌨️', description: 'Slight snow showers', descriptionKey: 'weatherUi.condition.slightSnowShowers' }, 86: { icon: '🌨️', description: 'Heavy snow showers', descriptionKey: 'weatherUi.condition.heavySnowShowers' }, 95: { icon: '⛈️', description: 'Thunderstorm', descriptionKey: 'weatherUi.condition.thunderstorm' }, 96: { icon: '⛈️', description: 'Thunderstorm with slight hail', descriptionKey: 'weatherUi.condition.thunderstormSlightHail' }, 99: { icon: '⛈️', description: 'Thunderstorm with heavy hail', descriptionKey: 'weatherUi.condition.thunderstormHeavyHail' }, }; function getWeatherInfo(code: number): { icon: string; description: string; descriptionKey: string } { return weatherCodes[code] || { icon: '❓', description: 'Unknown', descriptionKey: 'weatherUi.condition.unknown' }; } function getWindDirection(degrees: number): string { const directions = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW']; const index = Math.round(degrees / 22.5) % 16; return directions[index]; } function celsiusToFahrenheit(celsius: number): number { return Math.round((celsius * 9 / 5) + 32); } function getAQIInfo(aqi: number): { label: string; labelKey: string; color: string } { if (aqi <= 50) return { label: 'Good', labelKey: 'weatherUi.aqi.good', color: '#00e400' }; if (aqi <= 100) return { label: 'Moderate', labelKey: 'weatherUi.aqi.moderate', color: '#ffff00' }; if (aqi <= 150) return { label: 'Unhealthy for Sensitive Groups', labelKey: 'weatherUi.aqi.unhealthySensitive', color: '#ff7e00' }; if (aqi <= 200) return { label: 'Unhealthy', labelKey: 'weatherUi.aqi.unhealthy', color: '#ff0000' }; if (aqi <= 300) return { label: 'Very Unhealthy', labelKey: 'weatherUi.aqi.veryUnhealthy', color: '#8f3f97' }; return { label: 'Hazardous', labelKey: 'weatherUi.aqi.hazardous', color: '#7e0023' }; } /** * Fetch current weather for a location */ export async function fetchWeather(lat: number, lng: number): Promise { const params = new URLSearchParams({ latitude: lat.toFixed(6), longitude: lng.toFixed(6), current: 'temperature_2m,relative_humidity_2m,apparent_temperature,is_day,precipitation,weather_code,cloud_cover,wind_speed_10m,wind_direction_10m,uv_index', temperature_unit: 'celsius', wind_speed_unit: 'kmh', precipitation_unit: 'mm', timezone: 'auto', }); const response = await fetch(`https://api.open-meteo.com/v1/forecast?${params}`); if (!response.ok) throw new Error('Failed to fetch weather data'); const data = await response.json(); const current = data.current; const weatherInfo = getWeatherInfo(current.weather_code); return { temperature: Math.round(current.temperature_2m), temperatureFahrenheit: celsiusToFahrenheit(current.temperature_2m), feelsLike: Math.round(current.apparent_temperature), feelsLikeFahrenheit: celsiusToFahrenheit(current.apparent_temperature), weatherCode: current.weather_code, weatherDescription: weatherInfo.description, weatherDescriptionKey: weatherInfo.descriptionKey, weatherIcon: weatherInfo.icon, precipitation: current.precipitation || 0, precipitationProbability: 0, // Not available in current weather humidity: current.relative_humidity_2m, windSpeed: Math.round(current.wind_speed_10m), windDirection: current.wind_direction_10m, windDirectionLabel: getWindDirection(current.wind_direction_10m), cloudCover: current.cloud_cover, visibility: 10000, // Open-Meteo doesn't provide visibility in free tier uvIndex: current.uv_index || 0, isDay: current.is_day === 1, timestamp: Date.now(), }; } /** * Fetch air quality data for a location */ export async function fetchAirQuality(lat: number, lng: number): Promise { const params = new URLSearchParams({ latitude: lat.toFixed(6), longitude: lng.toFixed(6), current: 'us_aqi,pm10,pm2_5', timezone: 'auto', }); const response = await fetch(`https://air-quality-api.open-meteo.com/v1/air-quality?${params}`); if (!response.ok) throw new Error('Failed to fetch air quality data'); const data = await response.json(); const current = data.current; const aqi = current.us_aqi || 0; const aqiInfo = getAQIInfo(aqi); return { aqi, aqiLabel: aqiInfo.label, aqiLabelKey: aqiInfo.labelKey, aqiColor: aqiInfo.color, pm25: current.pm2_5 || 0, pm10: current.pm10 || 0, timestamp: Date.now(), }; } /** Forecast length requested and displayed (Open-Meteo supports up to 16 days). */ export const FORECAST_DAYS = 6; /** * Fetch a multi-day weather forecast for a location */ export async function fetchWeatherForecast(lat: number, lng: number): Promise { const params = new URLSearchParams({ latitude: lat.toFixed(6), longitude: lng.toFixed(6), daily: 'weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max,sunrise,sunset', temperature_unit: 'celsius', timezone: 'auto', forecast_days: String(FORECAST_DAYS), }); const response = await fetch(`https://api.open-meteo.com/v1/forecast?${params}`); if (!response.ok) throw new Error('Failed to fetch forecast data'); const data = await response.json(); return { daily: data.daily.time.map((date: string, index: number) => { const weatherInfo = getWeatherInfo(data.daily.weather_code[index]); return { date, maxTemp: Math.round(data.daily.temperature_2m_max[index]), minTemp: Math.round(data.daily.temperature_2m_min[index]), weatherCode: data.daily.weather_code[index], weatherIcon: weatherInfo.icon, precipitationProbability: data.daily.precipitation_probability_max[index] || 0, sunrise: data.daily.sunrise?.[index] ?? '', sunset: data.daily.sunset?.[index] ?? '', }; }), }; } /** * Calculate skateability score based on weather conditions */ export function calculateSkateability( weather: WeatherData, airQuality?: AirQualityData ): SkateabilityScore { let score = 100; const reasons: string[] = []; const reasonKeys: string[] = []; const addReason = (en: string, key: string) => { reasons.push(en); reasonKeys.push(key); }; // Precipitation (dealbreaker) if (weather.precipitation > 0) { score = 0; addReason('Currently raining', 'weatherUi.reason.currentlyRaining'); return { score, label: 'Unrideable', labelKey: 'weatherUi.skateability.unrideable', color: '#ff0000', reasons, reasonKeys, }; } // Check for rain/snow conditions const isRaining = [51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 80, 81, 82].includes(weather.weatherCode); const isSnowing = [71, 73, 75, 77, 85, 86].includes(weather.weatherCode); const isThunderstorm = [95, 96, 99].includes(weather.weatherCode); if (isRaining || isSnowing || isThunderstorm) { score = 0; if (isSnowing) addReason('Snowing', 'weatherUi.reason.snowing'); else if (isThunderstorm) addReason('Thunderstorm', 'weatherUi.reason.thunderstorm'); else addReason('Raining', 'weatherUi.reason.raining'); return { score, label: 'Unrideable', labelKey: 'weatherUi.skateability.unrideable', color: '#ff0000', reasons, reasonKeys, }; } // Temperature if (weather.temperature < 0) { score -= 50; addReason('Freezing temperature', 'weatherUi.reason.freezingTemperature'); } else if (weather.temperature < 5) { score -= 30; addReason('Very cold', 'weatherUi.reason.veryCold'); } else if (weather.temperature > 38) { score -= 40; addReason('Extremely hot', 'weatherUi.reason.extremelyHot'); } else if (weather.temperature > 32) { score -= 20; addReason('Very hot', 'weatherUi.reason.veryHot'); } // Wind if (weather.windSpeed > 40) { score -= 40; addReason('Very strong wind', 'weatherUi.reason.veryStrongWind'); } else if (weather.windSpeed > 25) { score -= 25; addReason('Strong wind', 'weatherUi.reason.strongWind'); } else if (weather.windSpeed > 15) { score -= 10; addReason('Moderate wind', 'weatherUi.reason.moderateWind'); } // Air quality if (airQuality) { if (airQuality.aqi > 200) { score -= 40; addReason('Very unhealthy air', 'weatherUi.reason.veryUnhealthyAir'); } else if (airQuality.aqi > 150) { score -= 25; addReason('Unhealthy air', 'weatherUi.reason.unhealthyAir'); } else if (airQuality.aqi > 100) { score -= 10; addReason('Moderate air quality', 'weatherUi.reason.moderateAirQuality'); } } // Humidity (affects grip) if (weather.humidity > 90) { score -= 15; addReason('Very humid', 'weatherUi.reason.veryHumid'); } else if (weather.humidity > 75) { score -= 5; addReason('Humid', 'weatherUi.reason.humid'); } // UV Index (safety concern) if (weather.uvIndex > 10) { score -= 10; addReason('Extreme UV - wear sunscreen', 'weatherUi.reason.extremeUv'); } else if (weather.uvIndex > 7) { score -= 5; addReason('High UV - sun protection recommended', 'weatherUi.reason.highUv'); } // Ensure score doesn't go below 0 score = Math.max(0, score); // If no negative reasons, add positive feedback if (reasons.length === 0) { addReason('Perfect skating conditions', 'weatherUi.reason.perfectConditions'); } let label: string; let labelKey: string; let color: string; if (score >= 85) { label = 'Perfect'; labelKey = 'weatherUi.skateability.perfect'; color = '#00e400'; } else if (score >= 70) { label = 'Good'; labelKey = 'weatherUi.skateability.good'; color = '#92d050'; } else if (score >= 50) { label = 'Okay'; labelKey = 'weatherUi.skateability.okay'; color = '#ffff00'; } else if (score >= 25) { label = 'Poor'; labelKey = 'weatherUi.skateability.poor'; color = '#ff7e00'; } else { label = 'Bad'; labelKey = 'weatherUi.skateability.bad'; color = '#ff0000'; } return { score, label, labelKey, color, reasons, reasonKeys, }; }