diff --git a/server/src/nest/weather/weather.impl.ts b/server/src/nest/weather/weather.impl.ts index 8a6dfb751..b78340309 100644 --- a/server/src/nest/weather/weather.impl.ts +++ b/server/src/nest/weather/weather.impl.ts @@ -508,6 +508,74 @@ export async function getWeather( // ── getDetailedWeather ────────────────────────────────────────────────── +/** + * One day in full (daily figures, sunrise, sunset and the hourly series) from + * the archive. Null when the archive has no figures for the day yet. + */ +async function detailedFromArchive( + lat: string, + lng: string, + day: string, + type: 'climate' | 'forecast', + descriptions: Record, +): Promise { + const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${lat}&longitude=${lng}` + + `&start_date=${day}&end_date=${day}` + + `&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, data } = await fetchOpenMeteo(url); + + if (!response.ok || data.error) { + throw new ApiError(response.status || 500, data.reason || 'Open-Meteo Archive API error'); + } + + const daily = data.daily; + const hourly = data.hourly; + if (!daily || !daily.time || daily.time.length === 0 || daily.temperature_2m_max[0] == null) return null; + + 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++) { + if (hourly.temperature_2m[i] == null) continue; + 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); + + return { + type, + 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, + }; +} + async function _getDetailedWeatherImpl( lat: string, lng: string, @@ -525,7 +593,7 @@ async function _getDetailedWeatherImpl( const dateStr = targetDate.toISOString().slice(0, 10); const descriptions = lang === 'de' ? WMO_DESCRIPTION_DE : WMO_DESCRIPTION_EN; - // Climate / archive path (> 16 days out) + // Climate path (> 16 days out): last year's day stands in for the trip's. if (diffDays > 16) { let refYear = targetDate.getFullYear() - 1; // Archive API only has data up to yesterday — go back further if needed @@ -533,65 +601,22 @@ async function _getDetailedWeatherImpl( 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, data } = await fetchOpenMeteo(url); + const climate = await detailedFromArchive(lat, lng, refDateStr, 'climate', descriptions); + if (!climate) return { temp: 0, main: '', description: '', type: '', error: 'no_forecast' }; + setCache(ck, climate, TTL_CLIMATE_MS); + return climate; + } - if (!response.ok || data.error) { - throw new ApiError(response.status || 500, data.reason || 'Open-Meteo Climate API error'); + // Past day: the forecast API answers nulls once a date is a couple of months + // back, which the day details then showed as 0°. The archive has the real + // day. It lags a few days behind, so a recent day it cannot answer yet goes + // on to the forecast API below, which still carries it. + if (diffDays < -1) { + const past = await detailedFromArchive(lat, lng, dateStr, 'forecast', descriptions); + if (past) { + setCache(ck, past, TTL_CLIMATE_MS); + return past; } - - 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) @@ -609,7 +634,8 @@ async function _getDetailedWeatherImpl( const daily = data.daily; const hourly = data.hourly; - if (!daily || !daily.time || daily.time.length === 0) { + // A day outside the model's range comes back as nulls, not as an error. + if (!daily || !daily.time || daily.time.length === 0 || daily.temperature_2m_max[0] == null) { return { temp: 0, main: '', description: '', type: '', error: 'no_forecast' }; } diff --git a/server/tests/unit/nest/weather/weather.impl.test.ts b/server/tests/unit/nest/weather/weather.impl.test.ts index 295aa840f..aa5e67498 100644 --- a/server/tests/unit/nest/weather/weather.impl.test.ts +++ b/server/tests/unit/nest/weather/weather.impl.test.ts @@ -934,6 +934,101 @@ describe('getDetailedWeather', () => { expect(result.main).toBe('Rain'); }); }); + + // The forecast API answers nulls for a day a couple of months back, which the + // day details showed as 0°; the archive holds the real day. + describe('past day', () => { + const nullDay = (date: string) => ({ + daily: { time: [date], temperature_2m_max: [null], temperature_2m_min: [null], weathercode: [null] }, + hourly: { time: [`${date}T00:00`], temperature_2m: [null] }, + }); + + it('WEATHER-DETAIL-PAST-001: reads the archive for the day itself', async () => { + const date = dateOffset(-60); + const body = { + daily: { + time: [date], + temperature_2m_max: [24], + temperature_2m_min: [14], + weathercode: [3], + precipitation_sum: [0.4], + windspeed_10m_max: [12], + sunrise: [`${date}T04:50`], + sunset: [`${date}T21:30`], + }, + hourly: { + time: [`${date}T14:00`, `${date}T15:00`], + temperature_2m: [23, 24], + precipitation: [0, 0.2], + weathercode: [3, 61], + windspeed_10m: [10, 11], + relativehumidity_2m: [55, 60], + }, + }; + vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body)); + + const result = await getDetailedWeather('33.00', '43.00', date, 'en'); + + expect(fetch).toHaveBeenCalledTimes(1); + const url = String(vi.mocked(fetch).mock.calls[0][0]); + expect(url).toContain('archive-api.open-meteo.com'); + expect(url).toContain(`start_date=${date}&end_date=${date}`); + expect(result).toMatchObject({ type: 'forecast', temp: 19, temp_max: 24, temp_min: 14, main: 'Clouds', sunrise: '04:50', sunset: '21:30' }); + expect(result.hourly).toHaveLength(2); + expect(result.hourly![1]).toMatchObject({ temp: 24, main: 'Rain', precipitation_probability: 0 }); + }); + + it('WEATHER-DETAIL-PAST-002: a recent day the archive has no figures for yet goes on to the forecast API', async () => { + const date = dateOffset(-3); + const forecast = { + daily: { + time: [date], + temperature_2m_max: [22], + temperature_2m_min: [12], + weathercode: [0], + sunrise: [`${date}T06:00`], + sunset: [`${date}T20:00`], + precipitation_sum: [0], + precipitation_probability_max: [0], + windspeed_10m_max: [8], + }, + hourly: { time: [], temperature_2m: [] }, + }; + vi.mocked(fetch) + .mockResolvedValueOnce(mockResponse(nullDay(date))) + .mockResolvedValueOnce(mockResponse(forecast)); + + const result = await getDetailedWeather('33.01', '43.01', date, 'en'); + + expect(fetch).toHaveBeenCalledTimes(2); + expect(String(vi.mocked(fetch).mock.calls[1][0])).toContain('api.open-meteo.com/v1/forecast'); + expect(result).toMatchObject({ type: 'forecast', temp: 17, temp_max: 22, temp_min: 12 }); + }); + + it('WEATHER-DETAIL-PAST-003: nulls from both sources give no_forecast, never 0°', async () => { + const date = dateOffset(-3); + vi.mocked(fetch) + .mockResolvedValueOnce(mockResponse(nullDay(date))) + .mockResolvedValueOnce(mockResponse(nullDay(date))); + + const result = await getDetailedWeather('33.02', '43.02', date, 'en'); + + expect(result.error).toBe('no_forecast'); + }); + + it('WEATHER-DETAIL-PAST-004: leaves out the hours the archive has no temperature for', async () => { + const date = dateOffset(-60); + const body = { + daily: { time: [date], temperature_2m_max: [20], temperature_2m_min: [10], weathercode: [0] }, + hourly: { time: [`${date}T00:00`, `${date}T01:00`], temperature_2m: [null, 11], weathercode: [0, 0] }, + }; + vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body)); + + const result = await getDetailedWeather('33.03', '43.03', date, 'en'); + + expect(result.hourly).toEqual([expect.objectContaining({ temp: 11 })]); + }); + }); }); // ── error paths ──────────────────────────────────────────────────────────────