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>
347 lines
11 KiB
TypeScript
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,
|
|
};
|
|
}
|