// ── Interfaces ────────────────────────────────────────────────────────── export interface WeatherResult { temp: number; temp_max?: number; temp_min?: number; main: string; description: string; type: string; sunrise?: string | null; sunset?: string | null; precipitation_sum?: number; precipitation_probability_max?: number; wind_max?: number; hourly?: HourlyEntry[]; error?: string; } export interface HourlyEntry { hour: number; temp: number; precipitation: number; precipitation_probability: number; main: string; wind: number; humidity: number; } interface OpenMeteoForecast { error?: boolean; reason?: string; current?: { temperature_2m: number; weathercode: number }; daily?: { time: string[]; temperature_2m_max: number[]; temperature_2m_min: number[]; weathercode: number[]; precipitation_sum?: number[]; precipitation_probability_max?: number[]; windspeed_10m_max?: number[]; sunrise?: string[]; sunset?: string[]; }; hourly?: { time: string[]; temperature_2m: number[]; precipitation_probability?: number[]; precipitation?: number[]; weathercode?: number[]; windspeed_10m?: number[]; relativehumidity_2m?: number[]; }; } // ── WMO code mappings ─────────────────────────────────────────────────── const WMO_MAP: Record = { 0: 'Clear', 1: 'Clear', 2: 'Clouds', 3: 'Clouds', 45: 'Fog', 48: 'Fog', 51: 'Drizzle', 53: 'Drizzle', 55: 'Drizzle', 56: 'Drizzle', 57: 'Drizzle', 61: 'Rain', 63: 'Rain', 65: 'Rain', 66: 'Rain', 67: 'Rain', 71: 'Snow', 73: 'Snow', 75: 'Snow', 77: 'Snow', 80: 'Rain', 81: 'Rain', 82: 'Rain', 85: 'Snow', 86: 'Snow', 95: 'Thunderstorm', 96: 'Thunderstorm', 99: 'Thunderstorm', }; const WMO_DESCRIPTION_DE: Record = { 0: 'Klar', 1: 'Uberwiegend klar', 2: 'Teilweise bewolkt', 3: 'Bewolkt', 45: 'Nebel', 48: 'Nebel mit Reif', 51: 'Leichter Nieselregen', 53: 'Nieselregen', 55: 'Starker Nieselregen', 56: 'Gefrierender Nieselregen', 57: 'Starker gefr. Nieselregen', 61: 'Leichter Regen', 63: 'Regen', 65: 'Starker Regen', 66: 'Gefrierender Regen', 67: 'Starker gefr. Regen', 71: 'Leichter Schneefall', 73: 'Schneefall', 75: 'Starker Schneefall', 77: 'Schneekorner', 80: 'Leichte Regenschauer', 81: 'Regenschauer', 82: 'Starke Regenschauer', 85: 'Leichte Schneeschauer', 86: 'Starke Schneeschauer', 95: 'Gewitter', 96: 'Gewitter mit Hagel', 99: 'Starkes Gewitter mit Hagel', }; const WMO_DESCRIPTION_EN: Record = { 0: 'Clear sky', 1: 'Mainly clear', 2: 'Partly cloudy', 3: 'Overcast', 45: 'Fog', 48: 'Rime fog', 51: 'Light drizzle', 53: 'Drizzle', 55: 'Heavy drizzle', 56: 'Freezing drizzle', 57: 'Heavy freezing drizzle', 61: 'Light rain', 63: 'Rain', 65: 'Heavy rain', 66: 'Freezing rain', 67: 'Heavy freezing rain', 71: 'Light snowfall', 73: 'Snowfall', 75: 'Heavy snowfall', 77: 'Snow grains', 80: 'Light rain showers', 81: 'Rain showers', 82: 'Heavy rain showers', 85: 'Light snow showers', 86: 'Heavy snow showers', 95: 'Thunderstorm', 96: 'Thunderstorm with hail', 99: 'Severe thunderstorm with hail', }; // ── Cache management ──────────────────────────────────────────────────── const weatherCache = new Map(); const CACHE_MAX_ENTRIES = 1000; const CACHE_PRUNE_TARGET = 500; const CACHE_CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 minutes setInterval(() => { const now = Date.now(); for (const [key, entry] of weatherCache) { if (now > entry.expiresAt) weatherCache.delete(key); } if (weatherCache.size > CACHE_MAX_ENTRIES) { const entries = [...weatherCache.entries()].sort((a, b) => a[1].expiresAt - b[1].expiresAt); const toDelete = entries.slice(0, entries.length - CACHE_PRUNE_TARGET); toDelete.forEach(([key]) => weatherCache.delete(key)); } }, CACHE_CLEANUP_INTERVAL); const TTL_FORECAST_MS = 60 * 60 * 1000; // 1 hour const TTL_CURRENT_MS = 15 * 60 * 1000; // 15 minutes const TTL_CLIMATE_MS = 24 * 60 * 60 * 1000; // 24 hours export function cacheKey(lat: string, lng: string, date?: string): string { const rlat = parseFloat(lat).toFixed(2); const rlng = parseFloat(lng).toFixed(2); return `${rlat}_${rlng}_${date || 'current'}`; } function getCached(key: string): WeatherResult | null { const entry = weatherCache.get(key); if (!entry) return null; if (Date.now() > entry.expiresAt) { weatherCache.delete(key); return null; } return entry.data; } function setCache(key: string, data: WeatherResult, ttlMs: number): void { weatherCache.set(key, { data, expiresAt: Date.now() + ttlMs }); } // ── Helpers ───────────────────────────────────────────────────────────── export function estimateCondition(tempAvg: number, precipMm: number): string { if (precipMm > 5) return tempAvg <= 0 ? 'Snow' : 'Rain'; if (precipMm > 1) return tempAvg <= 0 ? 'Snow' : 'Drizzle'; if (precipMm > 0.3) return 'Clouds'; return tempAvg > 15 ? 'Clear' : 'Clouds'; } // ── getWeather ────────────────────────────────────────────────────────── export async function getWeather( lat: string, lng: string, date: string | undefined, lang: string, ): Promise { const ck = cacheKey(lat, lng, date); if (date) { const cached = getCached(ck); if (cached) return cached; const targetDate = new Date(date); const now = new Date(); const diffDays = (targetDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24); // Forecast range (-1 .. +16 days) if (diffDays >= -1 && diffDays <= 16) { const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}&daily=temperature_2m_max,temperature_2m_min,weathercode&timezone=auto&forecast_days=16`; const response = await fetch(url); const data = await response.json() as OpenMeteoForecast; if (!response.ok || data.error) { throw new ApiError(response.status || 500, data.reason || 'Open-Meteo API error'); } const dateStr = targetDate.toISOString().slice(0, 10); const idx = (data.daily?.time || []).indexOf(dateStr); if (idx !== -1) { const code = data.daily!.weathercode[idx]; const descriptions = lang === 'de' ? WMO_DESCRIPTION_DE : WMO_DESCRIPTION_EN; const result: WeatherResult = { temp: Math.round((data.daily!.temperature_2m_max[idx] + data.daily!.temperature_2m_min[idx]) / 2), temp_max: Math.round(data.daily!.temperature_2m_max[idx]), temp_min: Math.round(data.daily!.temperature_2m_min[idx]), main: WMO_MAP[code] || 'Clouds', description: descriptions[code] || '', type: 'forecast', }; setCache(ck, result, TTL_FORECAST_MS); return result; } } // Climate / archive fallback (far-future dates) if (diffDays > -1) { const month = targetDate.getMonth() + 1; const day = targetDate.getDate(); const refYear = targetDate.getFullYear() - 1; const startDate = new Date(refYear, month - 1, day - 2); const endDate = new Date(refYear, month - 1, day + 2); const startStr = startDate.toISOString().slice(0, 10); const endStr = endDate.toISOString().slice(0, 10); const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${lat}&longitude=${lng}&start_date=${startStr}&end_date=${endStr}&daily=temperature_2m_max,temperature_2m_min,precipitation_sum&timezone=auto`; const response = await fetch(url); const data = await response.json() as OpenMeteoForecast; if (!response.ok || data.error) { throw new ApiError(response.status || 500, data.reason || 'Open-Meteo Climate API error'); } const daily = data.daily; if (!daily || !daily.time || daily.time.length === 0) { return { temp: 0, main: '', description: '', type: '', error: 'no_forecast' }; } let sumMax = 0, sumMin = 0, sumPrecip = 0, count = 0; for (let i = 0; i < daily.time.length; i++) { if (daily.temperature_2m_max[i] != null && daily.temperature_2m_min[i] != null) { sumMax += daily.temperature_2m_max[i]; sumMin += daily.temperature_2m_min[i]; sumPrecip += daily.precipitation_sum![i] || 0; count++; } } if (count === 0) { return { temp: 0, main: '', description: '', type: '', error: 'no_forecast' }; } const avgMax = sumMax / count; const avgMin = sumMin / count; const avgTemp = (avgMax + avgMin) / 2; const avgPrecip = sumPrecip / count; const main = estimateCondition(avgTemp, avgPrecip); const result: WeatherResult = { temp: Math.round(avgTemp), temp_max: Math.round(avgMax), temp_min: Math.round(avgMin), main, description: '', type: 'climate', }; setCache(ck, result, TTL_CLIMATE_MS); return result; } return { temp: 0, main: '', description: '', type: '', error: 'no_forecast' }; } // No date supplied -> current weather const cached = getCached(ck); if (cached) return cached; const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}¤t=temperature_2m,weathercode&timezone=auto`; const response = await fetch(url); const data = await response.json() as OpenMeteoForecast; if (!response.ok || data.error) { throw new ApiError(response.status || 500, data.reason || 'Open-Meteo API error'); } const code = data.current!.weathercode; const descriptions = lang === 'de' ? WMO_DESCRIPTION_DE : WMO_DESCRIPTION_EN; const result: WeatherResult = { temp: Math.round(data.current!.temperature_2m), main: WMO_MAP[code] || 'Clouds', description: descriptions[code] || '', type: 'current', }; setCache(ck, result, TTL_CURRENT_MS); return result; } // ── getDetailedWeather ────────────────────────────────────────────────── export async function getDetailedWeather( lat: string, lng: string, date: string, lang: string, ): Promise { const ck = `detailed_${cacheKey(lat, lng, date)}`; const cached = getCached(ck); if (cached) return cached; const targetDate = new Date(date); const now = new Date(); const diffDays = (targetDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24); const dateStr = targetDate.toISOString().slice(0, 10); const descriptions = lang === 'de' ? WMO_DESCRIPTION_DE : WMO_DESCRIPTION_EN; // Climate / archive path (> 16 days out) if (diffDays > 16) { const refYear = targetDate.getFullYear() - 1; const refDateStr = `${refYear}-${String(targetDate.getMonth() + 1).padStart(2, '0')}-${String(targetDate.getDate()).padStart(2, '0')}`; const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${lat}&longitude=${lng}` + `&start_date=${refDateStr}&end_date=${refDateStr}` + `&hourly=temperature_2m,precipitation,weathercode,windspeed_10m,relativehumidity_2m` + `&daily=temperature_2m_max,temperature_2m_min,weathercode,precipitation_sum,windspeed_10m_max,sunrise,sunset` + `&timezone=auto`; const response = await fetch(url); const data = await response.json() as OpenMeteoForecast; if (!response.ok || data.error) { throw new ApiError(response.status || 500, data.reason || 'Open-Meteo Climate API error'); } const daily = data.daily; const hourly = data.hourly; if (!daily || !daily.time || daily.time.length === 0) { return { temp: 0, main: '', description: '', type: '', error: 'no_forecast' }; } const idx = 0; const code = daily.weathercode?.[idx]; const avgMax = daily.temperature_2m_max[idx]; const avgMin = daily.temperature_2m_min[idx]; const hourlyData: HourlyEntry[] = []; if (hourly?.time) { for (let i = 0; i < hourly.time.length; i++) { const hour = new Date(hourly.time[i]).getHours(); const hCode = hourly.weathercode?.[i]; hourlyData.push({ hour, temp: Math.round(hourly.temperature_2m[i]), precipitation: hourly.precipitation?.[i] || 0, precipitation_probability: 0, main: WMO_MAP[hCode!] || 'Clouds', wind: Math.round(hourly.windspeed_10m?.[i] || 0), humidity: hourly.relativehumidity_2m?.[i] || 0, }); } } let sunrise: string | null = null, sunset: string | null = null; if (daily.sunrise?.[idx]) sunrise = daily.sunrise[idx].split('T')[1]?.slice(0, 5); if (daily.sunset?.[idx]) sunset = daily.sunset[idx].split('T')[1]?.slice(0, 5); const result: WeatherResult = { type: 'climate', temp: Math.round((avgMax + avgMin) / 2), temp_max: Math.round(avgMax), temp_min: Math.round(avgMin), main: WMO_MAP[code!] || estimateCondition((avgMax + avgMin) / 2, daily.precipitation_sum?.[idx] || 0), description: descriptions[code!] || '', precipitation_sum: Math.round((daily.precipitation_sum?.[idx] || 0) * 10) / 10, wind_max: Math.round(daily.windspeed_10m_max?.[idx] || 0), sunrise, sunset, hourly: hourlyData, }; setCache(ck, result, TTL_CLIMATE_MS); return result; } // Forecast path (<= 16 days) const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}` + `&hourly=temperature_2m,precipitation_probability,precipitation,weathercode,windspeed_10m,relativehumidity_2m` + `&daily=temperature_2m_max,temperature_2m_min,weathercode,sunrise,sunset,precipitation_probability_max,precipitation_sum,windspeed_10m_max` + `&timezone=auto&start_date=${dateStr}&end_date=${dateStr}`; const response = await fetch(url); const data = await response.json() as OpenMeteoForecast; if (!response.ok || data.error) { throw new ApiError(response.status || 500, data.reason || 'Open-Meteo API error'); } const daily = data.daily; const hourly = data.hourly; if (!daily || !daily.time || daily.time.length === 0) { return { temp: 0, main: '', description: '', type: '', error: 'no_forecast' }; } const dayIdx = 0; const code = daily.weathercode[dayIdx]; const formatTime = (isoStr: string) => { if (!isoStr) return ''; const d = new Date(isoStr); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; }; const hourlyData: HourlyEntry[] = []; if (hourly && hourly.time) { for (let i = 0; i < hourly.time.length; i++) { const h = new Date(hourly.time[i]).getHours(); hourlyData.push({ hour: h, temp: Math.round(hourly.temperature_2m[i]), precipitation_probability: hourly.precipitation_probability![i] || 0, precipitation: hourly.precipitation![i] || 0, main: WMO_MAP[hourly.weathercode![i]] || 'Clouds', wind: Math.round(hourly.windspeed_10m![i] || 0), humidity: Math.round(hourly.relativehumidity_2m![i] || 0), }); } } const result: WeatherResult = { type: 'forecast', temp: Math.round((daily.temperature_2m_max[dayIdx] + daily.temperature_2m_min[dayIdx]) / 2), temp_max: Math.round(daily.temperature_2m_max[dayIdx]), temp_min: Math.round(daily.temperature_2m_min[dayIdx]), main: WMO_MAP[code] || 'Clouds', description: descriptions[code] || '', sunrise: formatTime(daily.sunrise![dayIdx]), sunset: formatTime(daily.sunset![dayIdx]), precipitation_sum: daily.precipitation_sum![dayIdx] || 0, precipitation_probability_max: daily.precipitation_probability_max![dayIdx] || 0, wind_max: Math.round(daily.windspeed_10m_max![dayIdx] || 0), hourly: hourlyData, }; setCache(ck, result, TTL_FORECAST_MS); return result; } // ── ApiError ──────────────────────────────────────────────────────────── export class ApiError extends Error { status: number; constructor(status: number, message: string) { super(message); this.status = status; } }