fix(weather): day-local named anchor, cache ttl and past-days forecast window (#2167)

This commit is contained in:
Maurice
2026-09-01 15:48:37 +02:00
committed by Maurice
parent c21e86e3bd
commit 03bd5c77af
41 changed files with 568 additions and 105 deletions
+84 -14
View File
@@ -198,10 +198,15 @@ 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 {
export function cacheKey(lat: string, lng: string, date?: string, lang?: string): string {
const rlat = Number.parseFloat(lat).toFixed(2);
const rlng = Number.parseFloat(lng).toFixed(2);
return `${rlat}_${rlng}_${date || 'current'}`;
const base = `${rlat}_${rlng}_${date || 'current'}`;
// Descriptions come back localized, so the language is part of the identity —
// without it a cached German answer serves English callers for up to the TTL
// (and the MCP tools default to 'en' while REST defaults to 'de', so the two
// surfaces poisoned each other's descriptions).
return lang ? `${base}_${lang}` : base;
}
function getCached(key: string): WeatherResult | null {
@@ -227,6 +232,46 @@ export function estimateCondition(tempAvg: number, precipMm: number): string {
return tempAvg > 15 ? 'Clear' : 'Clouds';
}
/**
* Daily figures for a recent date via the forecast API's `past_days` window.
*
* The ERA5 archive lags roughly five days behind real time, so the last days of
* an ongoing trip — and a location-local "yesterday" that has already slipped
* out of the forward window in the server's UTC frame — answered with nulls,
* rendered as a dash (#2167). The forecast model still carries those days.
* Returns null when the requested date is not in the answered series, so the
* caller can fall back to the archive.
*/
async function fetchRecentPastForecast(
lat: string,
lng: string,
dateStr: string,
lang: string,
): Promise<WeatherResult | null> {
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}&daily=temperature_2m_max,temperature_2m_min,weathercode&timezone=auto&past_days=5&forecast_days=2`;
const { response, data } = await fetchOpenMeteo(url);
if (!response.ok || data.error) {
throw new ApiError(response.status || 500, data.reason || 'Open-Meteo API error');
}
const idx = (data.daily?.time || []).indexOf(dateStr);
if (idx === -1 || data.daily!.temperature_2m_max[idx] == null || data.daily!.temperature_2m_min[idx] == null) {
return null;
}
const code = data.daily!.weathercode[idx];
const descriptions = lang === 'de' ? WMO_DESCRIPTION_DE : WMO_DESCRIPTION_EN;
return {
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',
};
}
// ── getWeather ──────────────────────────────────────────────────────────
async function _getWeatherImpl(
@@ -236,7 +281,7 @@ async function _getWeatherImpl(
lang: string,
time?: string,
): Promise<WeatherResult> {
const ck = cacheKey(lat, lng, date ? `${date}T${time ?? ''}` : date);
const ck = cacheKey(lat, lng, date ? `${date}T${time ?? ''}` : date, lang);
if (date) {
const cached = getCached(ck);
@@ -274,6 +319,31 @@ async function _getWeatherImpl(
setCache(ck, result, TTL_FORECAST_MS);
return result;
}
// idx === -1 with diffDays <= 0: a location-local yesterday (or today in
// the hours after the UTC rollover) that the forward window no longer
// carries. Last year's climate is the wrong answer for a day that just
// happened — ask the model's past_days window before falling through.
if (diffDays <= 0) {
const recent = await fetchRecentPastForecast(lat, lng, dateStr, lang);
if (recent) {
setCache(ck, recent, TTL_FORECAST_MS);
return recent;
}
}
}
// Recent past inside the archive's lag window: the forecast API still has
// real daily figures where ERA5 answers nulls. A request with a usable time
// stays on the archive — only it has the hourly series a journal entry
// wants (#1614). On a miss the archive below remains the fallback.
if (diffDays < -1 && diffDays >= -5 && hourIndexFromTime(time) == null) {
const dateStr = targetDate.toISOString().slice(0, 10);
const recent = await fetchRecentPastForecast(lat, lng, dateStr, lang);
if (recent) {
setCache(ck, recent, TTL_CLIMATE_MS);
return recent;
}
}
// Past date: use archive API for the actual date
@@ -424,16 +494,16 @@ export async function getWeather(
): Promise<WeatherResult> {
const lat = String(coord(rawLat, 90, 'latitude'));
const lng = String(coord(rawLng, 180, 'longitude'));
const ck = cacheKey(lat, lng, date ? `${date}T${time ?? ''}` : date);
const ck = cacheKey(lat, lng, date ? `${date}T${time ?? ''}` : date, lang);
const cached = getCached(ck);
if (cached) return cached;
const inFlightKey = `${ck}:${lang}`;
const existing = inFlight.get(inFlightKey);
// lang is already part of ck, so the cache key doubles as the in-flight key.
const existing = inFlight.get(ck);
if (existing !== undefined) return existing;
const promise = _getWeatherImpl(lat, lng, date, lang, time);
inFlight.set(inFlightKey, promise);
try { return await promise; } finally { inFlight.delete(inFlightKey); }
inFlight.set(ck, promise);
try { return await promise; } finally { inFlight.delete(ck); }
}
// ── getDetailedWeather ──────────────────────────────────────────────────
@@ -444,7 +514,7 @@ async function _getDetailedWeatherImpl(
date: string,
lang: string,
): Promise<WeatherResult> {
const ck = `detailed_${cacheKey(lat, lng, date)}`;
const ck = `detailed_${cacheKey(lat, lng, date, lang)}`;
const cached = getCached(ck);
if (cached) return cached;
@@ -595,16 +665,16 @@ export async function getDetailedWeather(
): Promise<WeatherResult> {
const lat = String(coord(rawLat, 90, 'latitude'));
const lng = String(coord(rawLng, 180, 'longitude'));
const ck = `detailed_${cacheKey(lat, lng, date)}`;
const ck = `detailed_${cacheKey(lat, lng, date, lang)}`;
const cached = getCached(ck);
if (cached) return cached;
const inFlightKey = `${ck}:${lang}`;
const existing = inFlight.get(inFlightKey);
// lang is already part of ck, so the cache key doubles as the in-flight key.
const existing = inFlight.get(ck);
if (existing !== undefined) return existing;
const promise = _getDetailedWeatherImpl(lat, lng, date, lang);
inFlight.set(inFlightKey, promise);
try { return await promise; } finally { inFlight.delete(inFlightKey); }
inFlight.set(ck, promise);
try { return await promise; } finally { inFlight.delete(ck); }
}
// ── ApiError ────────────────────────────────────────────────────────────
@@ -112,6 +112,13 @@ describe('cacheKey', () => {
it('preserves the date string as-is', () => {
expect(cacheKey('0', '0', 'climate')).toBe('0.00_0.00_climate');
});
// #2167 — descriptions are localized, so a cached German answer must not be
// served to an English caller (REST defaults 'de', the MCP tools 'en').
it('separates languages when lang is given', () => {
expect(cacheKey('48.8566', '2.3522', '2024-06-15', 'de')).toBe('48.86_2.35_2024-06-15_de');
expect(cacheKey('48.8566', '2.3522', '2024-06-15', 'en')).toBe('48.86_2.35_2024-06-15_en');
});
});
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -247,6 +254,63 @@ describe('getWeather', () => {
await expect(getWeather('12.01', '22.01', date, 'en')).rejects.toThrow(ApiError);
});
// #2167 — a location-local yesterday (or today in the hours after the UTC
// rollover) can be missing from the forward series; last year's climate is
// the wrong answer for a day that just happened.
it('asks the past_days window for a local yesterday missing from the forward series', async () => {
const date = dateOffset(0); // diffDays in (-1, 0] — forecast branch
const missBody = {
daily: {
time: ['1970-01-01'],
temperature_2m_max: [10],
temperature_2m_min: [5],
weathercode: [0],
},
};
const pastDaysBody = {
daily: {
time: [date],
temperature_2m_max: [22],
temperature_2m_min: [12],
weathercode: [2],
},
};
vi.mocked(fetch)
.mockResolvedValueOnce(mockResponse(missBody))
.mockResolvedValueOnce(mockResponse(pastDaysBody));
const result = await getWeather('50.00', '60.00', date, 'en');
expect(fetch).toHaveBeenCalledTimes(2);
expect(vi.mocked(fetch).mock.calls[1][0]).toContain('api.open-meteo.com/v1/forecast');
expect(vi.mocked(fetch).mock.calls[1][0]).toContain('past_days=5');
expect(result.type).toBe('forecast'); // NOT climate
expect(result.temp).toBe(17);
});
// #2167 — the lang-less cache key served whichever language was asked first.
it('keeps languages apart in the cache', async () => {
const date = dateOffset(6);
const body = {
daily: {
time: [date],
temperature_2m_max: [12],
temperature_2m_min: [8],
weathercode: [3],
},
};
vi.mocked(fetch)
.mockResolvedValueOnce(mockResponse(body))
.mockResolvedValueOnce(mockResponse(body));
const en = await getWeather('50.01', '60.01', date, 'en');
const de = await getWeather('50.01', '60.01', date, 'de');
expect(fetch).toHaveBeenCalledTimes(2);
expect(en.description).toBe('Overcast');
expect(de.description).toBe('Bewolkt');
});
it('falls through to climate path when date is not found in forecast data', async () => {
// The forecast API returns data but NOT for our target date; the code
// checks idx === -1 and falls into the diffDays > -1 climate branch.
@@ -282,6 +346,85 @@ describe('getWeather', () => {
});
});
// #2167 — the ERA5 archive lags ~5 days and answered nulls for the last days
// of an ongoing trip; the forecast API's past_days window still has them.
describe('with date — recent past inside the past_days window', () => {
it('routes the date through the forecast API with past_days instead of the archive', async () => {
const date = dateOffset(-3);
const body = {
daily: {
time: [date],
temperature_2m_max: [18],
temperature_2m_min: [10],
weathercode: [2],
},
};
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
const result = await getWeather('51.00', '61.00', date, 'en');
expect(fetch).toHaveBeenCalledTimes(1);
expect(vi.mocked(fetch).mock.calls[0][0]).toContain('api.open-meteo.com/v1/forecast');
expect(vi.mocked(fetch).mock.calls[0][0]).toContain('past_days=5');
expect(vi.mocked(fetch).mock.calls[0][0]).not.toContain('archive-api');
expect(result.type).toBe('forecast');
expect(result.temp).toBe(14);
expect(result.temp_max).toBe(18);
expect(result.temp_min).toBe(10);
});
it('falls back to the archive when the past_days series misses the date', async () => {
const date = dateOffset(-3);
const missBody = {
daily: {
time: ['1970-01-01'],
temperature_2m_max: [1],
temperature_2m_min: [0],
weathercode: [0],
},
};
const archiveBody = {
daily: {
time: [date],
temperature_2m_max: [16],
temperature_2m_min: [8],
weathercode: [61],
precipitation_sum: [2],
},
};
vi.mocked(fetch)
.mockResolvedValueOnce(mockResponse(missBody))
.mockResolvedValueOnce(mockResponse(archiveBody));
const result = await getWeather('51.01', '61.01', date, 'en');
expect(fetch).toHaveBeenCalledTimes(2);
expect(vi.mocked(fetch).mock.calls[1][0]).toContain('archive-api.open-meteo.com');
expect(result.temp).toBe(12);
expect(result.main).toBe('Rain'); // WMO 61 — provably the archive answer
});
// #1614 — the hourly series only exists on the archive; a timed request
// (journal entry) must keep taking that path.
it('keeps a timed request on the archive so the hourly series stays available', async () => {
const date = dateOffset(-3);
const hourly = { temperature_2m: Array(24).fill(null), weathercode: Array(24).fill(null) };
hourly.temperature_2m[9] = 19;
hourly.weathercode[9] = 1;
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({
daily: { time: [date], temperature_2m_max: [18], temperature_2m_min: [10], weathercode: [2], precipitation_sum: [0] },
hourly,
}));
const result = await getWeather('51.02', '61.02', date, 'en', '09:15');
expect(fetch).toHaveBeenCalledTimes(1);
expect(vi.mocked(fetch).mock.calls[0][0]).toContain('archive-api.open-meteo.com');
expect(vi.mocked(fetch).mock.calls[0][0]).toContain('hourly=temperature_2m,weathercode');
expect(result.temp).toBe(19);
});
});
describe('with date — past date (diffDays < -1)', () => {
it('returns forecast-type WeatherResult from the archive API', async () => {
const date = dateOffset(-5); // 5 days in the past
@@ -514,6 +657,35 @@ describe('getDetailedWeather', () => {
expect(fetch).not.toHaveBeenCalled();
expect(second).toEqual(first);
});
// #2167 — same lang-in-key rule as getWeather.
it('keeps languages apart in the detailed cache', async () => {
const date = dateOffset(5);
const body = {
daily: {
time: [date],
temperature_2m_max: [20],
temperature_2m_min: [10],
weathercode: [45],
precipitation_sum: [0],
precipitation_probability_max: [0],
windspeed_10m_max: [5],
sunrise: [`${date}T06:00`],
sunset: [`${date}T20:00`],
},
hourly: { time: [], temperature_2m: [] },
};
vi.mocked(fetch)
.mockResolvedValueOnce(mockResponse(body))
.mockResolvedValueOnce(mockResponse(body));
const en = await getDetailedWeather('30.02', '40.02', date, 'en');
const de = await getDetailedWeather('30.02', '40.02', date, 'de');
expect(fetch).toHaveBeenCalledTimes(2);
expect(en.description).toBe('Fog');
expect(de.description).toBe('Nebel');
});
});
describe('forecast path (diffDays <= 16)', () => {