mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(weather): read a past day of the day details from the archive
The detailed weather asked the forecast API for every day up to 16 days out, past days included. Once a day is a couple of months back that API answers nulls instead of an error, and the day details showed 0° while the planner, which already reads the archive, showed the real figure. A past day now comes from the archive, with the same daily figures, sunrise, sunset and hourly series as the climate path, which shares the new helper. A recent day the archive has no figures for yet goes on to the forecast API as before, and nulls from either source now answer no_forecast instead of 0°.
This commit is contained in:
@@ -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<number, string>,
|
||||
): Promise<WeatherResult | null> {
|
||||
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' };
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user