mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
6c253c71c3
When a trip is far in the future (e.g. May 2027), the climate fallback looked up last year's data (May 2026). But if that date hasn't passed yet, the Open-Meteo archive API returns 400. Now checks if the reference date is still in the future and goes back one more year. Fixes the flood of 400 errors that could trigger CrowdSec bans.
446 lines
17 KiB
TypeScript
446 lines
17 KiB
TypeScript
|
|
// ── 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<number, string> = {
|
|
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<number, string> = {
|
|
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<number, string> = {
|
|
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<string, { data: WeatherResult; expiresAt: number }>();
|
|
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<WeatherResult> {
|
|
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();
|
|
let refYear = targetDate.getFullYear() - 1;
|
|
// Archive API only has data up to yesterday — go back further if needed
|
|
const yesterday = new Date(now.getTime() - 86400000);
|
|
if (new Date(refYear, month - 1, day + 2) > yesterday) refYear--;
|
|
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<WeatherResult> {
|
|
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) {
|
|
let refYear = targetDate.getFullYear() - 1;
|
|
// Archive API only has data up to yesterday — go back further if needed
|
|
const yesterday = new Date(now.getTime() - 86400000);
|
|
if (new Date(refYear, targetDate.getMonth(), targetDate.getDate()) > yesterday) refYear--;
|
|
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;
|
|
}
|
|
}
|