mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 06:11:45 +00:00
66a7de09c1
- weather: add archive API branch in getWeather for past dates (previously returned no_forecast, making the day-strip widget show "—") - dayplan: add expand/collapse-all toggle between ICS and Undo with animated icon swap (ChevronsUpDown <-> ChevronsDownUp) - dayplan: drop the trip title + date range block from the sidebar header (already shown in the page header), toolbar now right-aligned
514 lines
19 KiB
TypeScript
514 lines
19 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 inFlight = new Map<string, Promise<WeatherResult>>();
|
|
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 ──────────────────────────────────────────────────────────
|
|
|
|
async function _getWeatherImpl(
|
|
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;
|
|
}
|
|
}
|
|
|
|
// Past date: use archive API for the actual date
|
|
if (diffDays < -1) {
|
|
const dateStr = targetDate.toISOString().slice(0, 10);
|
|
const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${lat}&longitude=${lng}&start_date=${dateStr}&end_date=${dateStr}&daily=temperature_2m_max,temperature_2m_min,weathercode,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 Archive API error');
|
|
}
|
|
|
|
const daily = data.daily;
|
|
if (daily && daily.time && daily.time.length > 0 && daily.temperature_2m_max[0] != null) {
|
|
const code = daily.weathercode?.[0];
|
|
const descriptions = lang === 'de' ? WMO_DESCRIPTION_DE : WMO_DESCRIPTION_EN;
|
|
const tMax = daily.temperature_2m_max[0];
|
|
const tMin = daily.temperature_2m_min[0];
|
|
const result: WeatherResult = {
|
|
temp: Math.round((tMax + tMin) / 2),
|
|
temp_max: Math.round(tMax),
|
|
temp_min: Math.round(tMin),
|
|
main: WMO_MAP[code!] || estimateCondition((tMax + tMin) / 2, daily.precipitation_sum?.[0] || 0),
|
|
description: descriptions[code!] || '',
|
|
type: 'forecast',
|
|
};
|
|
setCache(ck, result, TTL_CLIMATE_MS);
|
|
return result;
|
|
}
|
|
return { temp: 0, main: '', description: '', type: '', error: 'no_forecast' };
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
export async function getWeather(
|
|
lat: string,
|
|
lng: string,
|
|
date: string | undefined,
|
|
lang: string,
|
|
): Promise<WeatherResult> {
|
|
const ck = cacheKey(lat, lng, date);
|
|
const cached = getCached(ck);
|
|
if (cached) return cached;
|
|
|
|
const inFlightKey = `${ck}:${lang}`;
|
|
const existing = inFlight.get(inFlightKey);
|
|
if (existing) return existing;
|
|
const promise = _getWeatherImpl(lat, lng, date, lang);
|
|
inFlight.set(inFlightKey, promise);
|
|
try { return await promise; } finally { inFlight.delete(inFlightKey); }
|
|
}
|
|
|
|
// ── getDetailedWeather ──────────────────────────────────────────────────
|
|
|
|
async function _getDetailedWeatherImpl(
|
|
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;
|
|
}
|
|
|
|
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 inFlightKey = `${ck}:${lang}`;
|
|
const existing = inFlight.get(inFlightKey);
|
|
if (existing) return existing;
|
|
const promise = _getDetailedWeatherImpl(lat, lng, date, lang);
|
|
inFlight.set(inFlightKey, promise);
|
|
try { return await promise; } finally { inFlight.delete(inFlightKey); }
|
|
}
|
|
|
|
// ── ApiError ────────────────────────────────────────────────────────────
|
|
|
|
export class ApiError extends Error {
|
|
status: number;
|
|
constructor(status: number, message: string) {
|
|
super(message);
|
|
this.status = status;
|
|
}
|
|
}
|