diff --git a/ApiCore-Backend/src/weather/weather.service.spec.ts b/ApiCore-Backend/src/weather/weather.service.spec.ts index f0913f0..ac3c984 100644 --- a/ApiCore-Backend/src/weather/weather.service.spec.ts +++ b/ApiCore-Backend/src/weather/weather.service.spec.ts @@ -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, diff --git a/ApiCore-Backend/src/weather/weather.service.ts b/ApiCore-Backend/src/weather/weather.service.ts index 3ebad3e..5aabf22 100644 --- a/ApiCore-Backend/src/weather/weather.service.ts +++ b/ApiCore-Backend/src/weather/weather.service.ts @@ -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 } { diff --git a/ApiCore-Frontend/src/app/(app)/apiaries/[id]/weather-card.tsx b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/weather-card.tsx index e1a2a8c..1a7bdef 100644 --- a/ApiCore-Frontend/src/app/(app)/apiaries/[id]/weather-card.tsx +++ b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/weather-card.tsx @@ -1,6 +1,6 @@ "use client"; -import { CloudSun } from "lucide-react"; +import { CloudSun, Wind, CheckCircle2, XCircle } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import { fetchApiaryWeather, fetchTrachtprognose } from "@/lib/weather/api"; import { TrachtprognoseRating } from "@/lib/weather/types"; @@ -73,6 +73,7 @@ export function WeatherCard({ const today = new Date().toISOString().slice(0, 10); const forecastDays = (weather?.daily ?? []).filter((day) => day.date >= today).slice(0, 7); + const todaySuitability = forecastDays[0]?.inspectionSuitable ?? null; return ( @@ -104,26 +105,54 @@ export function WeatherCard({ {weatherLoading ? (

Wetterdaten laden…

) : forecastDays.length > 0 ? ( -
- {forecastDays.map((day) => ( -
- - {formatDayLabel(day.date)} + <> + {todaySuitability !== null ? ( +
+ {todaySuitability ? ( + + ) : ( + + )} + + {todaySuitability + ? "Heute gut für eine Durchsicht geeignet." + : "Heute eher ungeeignet für eine Durchsicht (zu kalt, nass oder windig)."} - - {day.tempMaxC !== null ? `${Math.round(day.tempMaxC)}°` : "–"} - - - {day.tempMinC !== null ? `${Math.round(day.tempMinC)}°` : "–"} - - {day.precipitationMm !== null && day.precipitationMm > 0 ? ( - - {day.precipitationMm.toFixed(0)} mm - - ) : null}
- ))} -
+ ) : null} +
+ {forecastDays.map((day) => ( +
+ + {formatDayLabel(day.date)} + + + {day.tempMaxC !== null ? `${Math.round(day.tempMaxC)}°` : "–"} + + + {day.tempMinC !== null ? `${Math.round(day.tempMinC)}°` : "–"} + + {day.precipitationMm !== null && day.precipitationMm > 0 ? ( + + {day.precipitationMm.toFixed(0)} mm + + ) : null} + {day.windSpeedMaxKmh !== null ? ( + + + {Math.round(day.windSpeedMaxKmh)} + + ) : null} +
+ ))} +
+ ) : null} diff --git a/ApiCore-Frontend/src/lib/weather/types.ts b/ApiCore-Frontend/src/lib/weather/types.ts index bea98e5..a85183f 100644 --- a/ApiCore-Frontend/src/lib/weather/types.ts +++ b/ApiCore-Frontend/src/lib/weather/types.ts @@ -3,6 +3,8 @@ export interface DailyWeather { tempMaxC: number | null; tempMinC: number | null; precipitationMm: number | null; + windSpeedMaxKmh: number | null; + inspectionSuitable: boolean | null; } export interface ApiaryWeather {