SkateSpots/src/lib/weather.ts
shakespeare.diy e29d35f1e4 fix: restore full skate-spots source tree from misplaced src/src/ copy
The entire skate-spots codebase was accidentally copied into src/src/ and
public/public/ instead of src/ and public/ directly. This left the real
src/ directory as blank MKStack template stubs, explaining why the landing
page showed the default template instead of SkateSpots.

Changes made:
- Copied all files from src/src/ up to src/ (App.tsx, AppRouter.tsx, main.tsx,
  all pages, components, hooks, contexts, lib, i18n, test)
- Copied all files from public/public/ up to public/ (images, manifest, etc.)
- Deleted src/src/ and public/public/ nested directories
- Removed HelloWorld.tsx template stub from pages/
- Retained the previously fixed index.css (Tailwind v3 directives)

Full skate-spots feature set is now active:
- All 30+ routes (map, events, spots, coaches, marketplace, wallet, etc.)
- NWCProvider, LanguageProvider, PWAInstallPrompt, AmberCallbackHandler
- Skate community relays (goskatespots.nostr1.com, wotr.relatr.xyz, etc.)
- Outfit Variable font, Amber pre-check in main.tsx
- All hooks (useSkateSpots, useCoaches, useZaps, etc.)
- i18n support (en/es)

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-06-15 22:18:28 +07:00

347 lines
11 KiB
TypeScript

/**
* 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;
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.
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;
}[];
}
export interface SkateabilityScore {
score: number; // 0-100
label: string; // Perfect, Good, Okay, Poor, Unrideable
color: string; // green, yellow, orange, red
reasons: string[];
}
// WMO Weather Codes to emoji and description
const weatherCodes: Record<number, { icon: string; description: string }> = {
0: { icon: '☀️', description: 'Clear sky' },
1: { icon: '🌤️', description: 'Mainly clear' },
2: { icon: '⛅', description: 'Partly cloudy' },
3: { icon: '☁️', description: 'Overcast' },
45: { icon: '🌫️', description: 'Foggy' },
48: { icon: '🌫️', description: 'Depositing rime fog' },
51: { icon: '🌦️', description: 'Light drizzle' },
53: { icon: '🌦️', description: 'Moderate drizzle' },
55: { icon: '🌧️', description: 'Dense drizzle' },
56: { icon: '🌧️', description: 'Light freezing drizzle' },
57: { icon: '🌧️', description: 'Dense freezing drizzle' },
61: { icon: '🌧️', description: 'Slight rain' },
63: { icon: '🌧️', description: 'Moderate rain' },
65: { icon: '🌧️', description: 'Heavy rain' },
66: { icon: '🌧️', description: 'Light freezing rain' },
67: { icon: '🌧️', description: 'Heavy freezing rain' },
71: { icon: '🌨️', description: 'Slight snow' },
73: { icon: '🌨️', description: 'Moderate snow' },
75: { icon: '🌨️', description: 'Heavy snow' },
77: { icon: '🌨️', description: 'Snow grains' },
80: { icon: '🌦️', description: 'Slight rain showers' },
81: { icon: '🌧️', description: 'Moderate rain showers' },
82: { icon: '⛈️', description: 'Violent rain showers' },
85: { icon: '🌨️', description: 'Slight snow showers' },
86: { icon: '🌨️', description: 'Heavy snow showers' },
95: { icon: '⛈️', description: 'Thunderstorm' },
96: { icon: '⛈️', description: 'Thunderstorm with slight hail' },
99: { icon: '⛈️', description: 'Thunderstorm with heavy hail' },
};
function getWeatherInfo(code: number): { icon: string; description: string } {
return weatherCodes[code] || { icon: '❓', description: '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; color: string } {
if (aqi <= 50) return { label: 'Good', color: '#00e400' };
if (aqi <= 100) return { label: 'Moderate', color: '#ffff00' };
if (aqi <= 150) return { label: 'Unhealthy for Sensitive Groups', color: '#ff7e00' };
if (aqi <= 200) return { label: 'Unhealthy', color: '#ff0000' };
if (aqi <= 300) return { label: 'Very Unhealthy', color: '#8f3f97' };
return { label: 'Hazardous', color: '#7e0023' };
}
/**
* Fetch current weather for a location
*/
export async function fetchWeather(lat: number, lng: number): Promise<WeatherData> {
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,
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<AirQualityData> {
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,
aqiColor: aqiInfo.color,
pm25: current.pm2_5 || 0,
pm10: current.pm10 || 0,
timestamp: Date.now(),
};
}
/**
* Fetch 3-day weather forecast for a location
*/
export async function fetchWeatherForecast(lat: number, lng: number): Promise<WeatherForecast> {
const params = new URLSearchParams({
latitude: lat.toFixed(6),
longitude: lng.toFixed(6),
daily: 'weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max',
temperature_unit: 'celsius',
timezone: 'auto',
forecast_days: '3',
});
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,
};
}),
};
}
/**
* Calculate skateability score based on weather conditions
*/
export function calculateSkateability(
weather: WeatherData,
airQuality?: AirQualityData
): SkateabilityScore {
let score = 100;
const reasons: string[] = [];
// Precipitation (dealbreaker)
if (weather.precipitation > 0) {
score = 0;
reasons.push('Currently raining');
return {
score,
label: 'Unrideable',
color: '#ff0000',
reasons,
};
}
// 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;
reasons.push(isSnowing ? 'Snowing' : isThunderstorm ? 'Thunderstorm' : 'Raining');
return {
score,
label: 'Unrideable',
color: '#ff0000',
reasons,
};
}
// Temperature
if (weather.temperature < 0) {
score -= 50;
reasons.push('Freezing temperature');
} else if (weather.temperature < 5) {
score -= 30;
reasons.push('Very cold');
} else if (weather.temperature > 38) {
score -= 40;
reasons.push('Extremely hot');
} else if (weather.temperature > 32) {
score -= 20;
reasons.push('Very hot');
}
// Wind
if (weather.windSpeed > 40) {
score -= 40;
reasons.push('Very strong wind');
} else if (weather.windSpeed > 25) {
score -= 25;
reasons.push('Strong wind');
} else if (weather.windSpeed > 15) {
score -= 10;
reasons.push('Moderate wind');
}
// Air quality
if (airQuality) {
if (airQuality.aqi > 200) {
score -= 40;
reasons.push('Very unhealthy air');
} else if (airQuality.aqi > 150) {
score -= 25;
reasons.push('Unhealthy air');
} else if (airQuality.aqi > 100) {
score -= 10;
reasons.push('Moderate air quality');
}
}
// Humidity (affects grip)
if (weather.humidity > 90) {
score -= 15;
reasons.push('Very humid');
} else if (weather.humidity > 75) {
score -= 5;
reasons.push('Humid');
}
// UV Index (safety concern)
if (weather.uvIndex > 10) {
score -= 10;
reasons.push('Extreme UV - wear sunscreen');
} else if (weather.uvIndex > 7) {
score -= 5;
reasons.push('High UV - sun protection recommended');
}
// Ensure score doesn't go below 0
score = Math.max(0, score);
// If no negative reasons, add positive feedback
if (reasons.length === 0) {
reasons.push('Perfect skating conditions');
}
let label: string;
let color: string;
if (score >= 85) {
label = 'Perfect';
color = '#00e400';
} else if (score >= 70) {
label = 'Good';
color = '#92d050';
} else if (score >= 50) {
label = 'Okay';
color = '#ffff00';
} else if (score >= 25) {
label = 'Poor';
color = '#ff7e00';
} else {
label = 'Bad';
color = '#ff0000';
}
return {
score,
label,
color,
reasons,
};
}