feat: add wind speed and a same-day hive-inspection verdict to weather

Temperature and rain were already tracked, but wind matters just as
much for whether it's safe/sensible to open a hive - bees get more
defensive and frames are harder to handle in wind. Adds
wind_speed_10m_max to the Open-Meteo request and a derived
inspectionSuitable flag per day (>=15C, <=1mm rain, <=25km/h wind;
null when a value is missing rather than guessing). The apiary weather
card now shows a clear "good/not good for a Durchsicht today" banner
plus wind alongside each day's forecast.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Sonnet 5 committed 2026-09-05 17:54:31 +02:00
1 parent 1f67cd5548
commit c4f970f071
4 files changed
+150 -29

No files matched your search

@@ -8,6 +8,7 @@ function openMeteoResponse(
tempMax: number | null;
tempMin: number | null;
precipitation: number | null;
wind?: number | null;
}[],
): unknown {
return {
@@ -16,6 +17,7 @@ function openMeteoResponse(
temperature_2m_max: days.map((day) => day.tempMax),
temperature_2m_min: days.map((day) => day.tempMin),
precipitation_sum: days.map((day) => day.precipitation),
wind_speed_10m_max: days.map((day) => day.wind ?? 10),
},
};
}
@@ -83,13 +85,68 @@ describe('WeatherService', () => {
const result = await weatherService.getWeather('apiary-1');
expect(result.daily).toEqual([
{ date: '2026-06-01', tempMaxC: 22, tempMinC: 12, precipitationMm: 0 },
{
date: '2026-06-01',
tempMaxC: 22,
tempMinC: 12,
precipitationMm: 0,
windSpeedMaxKmh: 10,
inspectionSuitable: true,
},
]);
const [url] = fetchMock.mock.calls[0] as [URL];
expect(url.toString()).toContain('latitude=52.5');
expect(url.toString()).toContain('longitude=13.4');
});
it.each([
['too cold', { tempMax: 10, tempMin: 4, precipitation: 0, wind: 5 }],
['raining', { tempMax: 20, tempMin: 12, precipitation: 3, wind: 5 }],
['too windy', { tempMax: 20, tempMin: 12, precipitation: 0, wind: 40 }],
])('marks a day unsuitable for inspection when %s', async (_label, day) => {
prisma.apiary.findUnique.mockResolvedValue({
latitude: 52.5,
longitude: 13.4,
});
fetchMock.mockResolvedValue(
new Response(
JSON.stringify(openMeteoResponse([{ date: '2026-06-01', ...day }])),
{ status: 200 },
),
);
const result = await weatherService.getWeather('apiary-1');
expect(result.daily[0].inspectionSuitable).toBe(false);
});
it('leaves inspectionSuitable null when a required value is missing', async () => {
prisma.apiary.findUnique.mockResolvedValue({
latitude: 52.5,
longitude: 13.4,
});
fetchMock.mockResolvedValue(
new Response(
JSON.stringify(
openMeteoResponse([
{
date: '2026-06-01',
tempMax: null,
tempMin: null,
precipitation: 0,
wind: 5,
},
]),
),
{ status: 200 },
),
);
const result = await weatherService.getWeather('apiary-1');
expect(result.daily[0].inspectionSuitable).toBeNull();
});
it('throws BadRequestException when Open-Meteo responds with an error status', async () => {
prisma.apiary.findUnique.mockResolvedValue({
latitude: 52.5,
+42 -9
View File
@@ -15,11 +15,23 @@ const GOOD_PRECIPITATION_THRESHOLD_MM = 15;
const POOR_TEMP_THRESHOLD_C = 12;
const POOR_PRECIPITATION_THRESHOLD_MM = 30;
// Rule-of-thumb minimums for opening a hive without unduly stressing the
// colony or making the inspection unpleasant/risky for the beekeeper:
// below ~14-15°C bees cluster and brood chills when the hive is opened,
// noticeable rain and strong wind both make bees more defensive and the
// frames harder to handle safely.
const INSPECTION_MIN_TEMP_C = 15;
const INSPECTION_MAX_PRECIPITATION_MM = 1;
const INSPECTION_MAX_WIND_KMH = 25;
export interface DailyWeather {
date: string;
tempMaxC: number | null;
tempMinC: number | null;
precipitationMm: number | null;
windSpeedMaxKmh: number | null;
// null when a required value is missing (rather than a misleading guess).
inspectionSuitable: boolean | null;
}
export interface ApiaryWeather {
@@ -41,6 +53,7 @@ interface OpenMeteoDaily {
temperature_2m_max: (number | null)[];
temperature_2m_min: (number | null)[];
precipitation_sum: (number | null)[];
wind_speed_10m_max: (number | null)[];
}
interface OpenMeteoResponse {
@@ -95,8 +108,9 @@ export class WeatherService {
url.searchParams.set('longitude', String(longitude));
url.searchParams.set(
'daily',
'temperature_2m_max,temperature_2m_min,precipitation_sum',
'temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max',
);
url.searchParams.set('wind_speed_unit', 'kmh');
url.searchParams.set('past_days', String(PAST_DAYS));
url.searchParams.set('forecast_days', String(FORECAST_DAYS));
url.searchParams.set('timezone', 'auto');
@@ -116,14 +130,33 @@ export class WeatherService {
if (!body.daily) {
return [];
}
const { time, temperature_2m_max, temperature_2m_min, precipitation_sum } =
body.daily;
return time.map((date, index) => ({
date,
tempMaxC: temperature_2m_max[index] ?? null,
tempMinC: temperature_2m_min[index] ?? null,
precipitationMm: precipitation_sum[index] ?? null,
}));
const {
time,
temperature_2m_max,
temperature_2m_min,
precipitation_sum,
wind_speed_10m_max,
} = body.daily;
return time.map((date, index) => {
const tempMaxC = temperature_2m_max[index] ?? null;
const precipitationMm = precipitation_sum[index] ?? null;
const windSpeedMaxKmh = wind_speed_10m_max[index] ?? null;
return {
date,
tempMaxC,
tempMinC: temperature_2m_min[index] ?? null,
precipitationMm,
windSpeedMaxKmh,
inspectionSuitable:
tempMaxC === null ||
precipitationMm === null ||
windSpeedMaxKmh === null
? null
: tempMaxC >= INSPECTION_MIN_TEMP_C &&
precipitationMm <= INSPECTION_MAX_PRECIPITATION_MM &&
windSpeedMaxKmh <= INSPECTION_MAX_WIND_KMH,
};
});
}
private getSeason(month: number): { season: string; inBloom: boolean } {