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:
1 parent
1f67cd5548
commit
c4f970f071
4 files changed
+130
-9
No files matched your search
@@ -8,6 +8,7 @@ function openMeteoResponse(
|
|||||||
tempMax: number | null;
|
tempMax: number | null;
|
||||||
tempMin: number | null;
|
tempMin: number | null;
|
||||||
precipitation: number | null;
|
precipitation: number | null;
|
||||||
|
wind?: number | null;
|
||||||
}[],
|
}[],
|
||||||
): unknown {
|
): unknown {
|
||||||
return {
|
return {
|
||||||
@@ -16,6 +17,7 @@ function openMeteoResponse(
|
|||||||
temperature_2m_max: days.map((day) => day.tempMax),
|
temperature_2m_max: days.map((day) => day.tempMax),
|
||||||
temperature_2m_min: days.map((day) => day.tempMin),
|
temperature_2m_min: days.map((day) => day.tempMin),
|
||||||
precipitation_sum: days.map((day) => day.precipitation),
|
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');
|
const result = await weatherService.getWeather('apiary-1');
|
||||||
|
|
||||||
expect(result.daily).toEqual([
|
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];
|
const [url] = fetchMock.mock.calls[0] as [URL];
|
||||||
expect(url.toString()).toContain('latitude=52.5');
|
expect(url.toString()).toContain('latitude=52.5');
|
||||||
expect(url.toString()).toContain('longitude=13.4');
|
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 () => {
|
it('throws BadRequestException when Open-Meteo responds with an error status', async () => {
|
||||||
prisma.apiary.findUnique.mockResolvedValue({
|
prisma.apiary.findUnique.mockResolvedValue({
|
||||||
latitude: 52.5,
|
latitude: 52.5,
|
||||||
|
|||||||
@@ -15,11 +15,23 @@ const GOOD_PRECIPITATION_THRESHOLD_MM = 15;
|
|||||||
const POOR_TEMP_THRESHOLD_C = 12;
|
const POOR_TEMP_THRESHOLD_C = 12;
|
||||||
const POOR_PRECIPITATION_THRESHOLD_MM = 30;
|
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 {
|
export interface DailyWeather {
|
||||||
date: string;
|
date: string;
|
||||||
tempMaxC: number | null;
|
tempMaxC: number | null;
|
||||||
tempMinC: number | null;
|
tempMinC: number | null;
|
||||||
precipitationMm: 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 {
|
export interface ApiaryWeather {
|
||||||
@@ -41,6 +53,7 @@ interface OpenMeteoDaily {
|
|||||||
temperature_2m_max: (number | null)[];
|
temperature_2m_max: (number | null)[];
|
||||||
temperature_2m_min: (number | null)[];
|
temperature_2m_min: (number | null)[];
|
||||||
precipitation_sum: (number | null)[];
|
precipitation_sum: (number | null)[];
|
||||||
|
wind_speed_10m_max: (number | null)[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OpenMeteoResponse {
|
interface OpenMeteoResponse {
|
||||||
@@ -95,8 +108,9 @@ export class WeatherService {
|
|||||||
url.searchParams.set('longitude', String(longitude));
|
url.searchParams.set('longitude', String(longitude));
|
||||||
url.searchParams.set(
|
url.searchParams.set(
|
||||||
'daily',
|
'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('past_days', String(PAST_DAYS));
|
||||||
url.searchParams.set('forecast_days', String(FORECAST_DAYS));
|
url.searchParams.set('forecast_days', String(FORECAST_DAYS));
|
||||||
url.searchParams.set('timezone', 'auto');
|
url.searchParams.set('timezone', 'auto');
|
||||||
@@ -116,14 +130,33 @@ export class WeatherService {
|
|||||||
if (!body.daily) {
|
if (!body.daily) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const { time, temperature_2m_max, temperature_2m_min, precipitation_sum } =
|
const {
|
||||||
body.daily;
|
time,
|
||||||
return time.map((date, index) => ({
|
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,
|
date,
|
||||||
tempMaxC: temperature_2m_max[index] ?? null,
|
tempMaxC,
|
||||||
tempMinC: temperature_2m_min[index] ?? null,
|
tempMinC: temperature_2m_min[index] ?? null,
|
||||||
precipitationMm: precipitation_sum[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 } {
|
private getSeason(month: number): { season: string; inBloom: boolean } {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { CloudSun } from "lucide-react";
|
import { CloudSun, Wind, CheckCircle2, XCircle } from "lucide-react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { fetchApiaryWeather, fetchTrachtprognose } from "@/lib/weather/api";
|
import { fetchApiaryWeather, fetchTrachtprognose } from "@/lib/weather/api";
|
||||||
import { TrachtprognoseRating } from "@/lib/weather/types";
|
import { TrachtprognoseRating } from "@/lib/weather/types";
|
||||||
@@ -73,6 +73,7 @@ export function WeatherCard({
|
|||||||
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
const forecastDays = (weather?.daily ?? []).filter((day) => day.date >= today).slice(0, 7);
|
const forecastDays = (weather?.daily ?? []).filter((day) => day.date >= today).slice(0, 7);
|
||||||
|
const todaySuitability = forecastDays[0]?.inspectionSuitable ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -104,6 +105,27 @@ export function WeatherCard({
|
|||||||
{weatherLoading ? (
|
{weatherLoading ? (
|
||||||
<p className="text-sm text-muted-foreground">Wetterdaten laden…</p>
|
<p className="text-sm text-muted-foreground">Wetterdaten laden…</p>
|
||||||
) : forecastDays.length > 0 ? (
|
) : forecastDays.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{todaySuitability !== null ? (
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 rounded-lg border p-2.5 text-sm ${
|
||||||
|
todaySuitability
|
||||||
|
? "border-primary/30 bg-primary/5 text-primary"
|
||||||
|
: "border-warning/40 bg-warning/10 text-warning-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{todaySuitability ? (
|
||||||
|
<CheckCircle2 className="size-4.5 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="size-4.5 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
{todaySuitability
|
||||||
|
? "Heute gut für eine Durchsicht geeignet."
|
||||||
|
: "Heute eher ungeeignet für eine Durchsicht (zu kalt, nass oder windig)."}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="grid grid-cols-3 gap-2 border-t border-border/50 pt-3 sm:grid-cols-7">
|
<div className="grid grid-cols-3 gap-2 border-t border-border/50 pt-3 sm:grid-cols-7">
|
||||||
{forecastDays.map((day) => (
|
{forecastDays.map((day) => (
|
||||||
<div key={day.date} className="flex flex-col items-center gap-0.5 text-center">
|
<div key={day.date} className="flex flex-col items-center gap-0.5 text-center">
|
||||||
@@ -121,9 +143,16 @@ export function WeatherCard({
|
|||||||
{day.precipitationMm.toFixed(0)} mm
|
{day.precipitationMm.toFixed(0)} mm
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
{day.windSpeedMaxKmh !== null ? (
|
||||||
|
<span className="flex items-center gap-0.5 text-xs text-muted-foreground">
|
||||||
|
<Wind className="size-3" />
|
||||||
|
{Math.round(day.windSpeedMaxKmh)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ export interface DailyWeather {
|
|||||||
tempMaxC: number | null;
|
tempMaxC: number | null;
|
||||||
tempMinC: number | null;
|
tempMinC: number | null;
|
||||||
precipitationMm: number | null;
|
precipitationMm: number | null;
|
||||||
|
windSpeedMaxKmh: number | null;
|
||||||
|
inspectionSuitable: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiaryWeather {
|
export interface ApiaryWeather {
|
||||||
|
|||||||
Reference in new issue
Block a user