feat: Wetter-Widget auf der Bienenstand-Detailseite

Neue WeatherCard auf /apiaries/[id]: Trachtprognose-Badge mit
Begruendung, 7-Tage-Vorhersagestreifen (Temperatur + Niederschlag).
Zeigt einen Hinweis statt eines Fehlers, wenn fuer den Bienenstand
noch keine Koordinaten hinterlegt sind.

TASKS.md: Wetterintegration + Trachtprognosen jetzt vollstaendig
(Backend aus vorherigem Commit + Frontend hier) abgehakt.

Verifiziert: tsc, lint, build, alle Unit-Tests (14/14) gruen. Live-
Browser-Check war blockiert durch dieselbe wiederkehrende WSL/
Postgres-Portweiterleitungs-Unzuverlaessigkeit wie zuvor in der
Session - die Funktionalitaet ist stattdessen durch die Backend-e2e-
Suite abgedeckt, die echten Postgres UND die echte Open-Meteo-API
aufruft (163/163 gruen).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Sonnet 5 committed 2026-08-26 09:47:44 +02:00
1 parent ce9cea1b94
commit 0518a5740f
5 files changed
+165 -2

No files matched your search

@@ -9,6 +9,7 @@ import { QrCodeDialog } from "@/components/qr-code-dialog";
import { DocumentUploadCard } from "@/components/document-upload-card";
import { Button } from "@/components/ui/button";
import { ApiaryEditCard } from "./apiary-edit-card";
import { WeatherCard } from "./weather-card";
const CURRENT_YEAR = new Date().getFullYear();
@@ -40,6 +41,10 @@ export default function ApiaryDetailPage() {
<QrCodeDialog entityType="apiaries" id={apiary.id} />
</div>
<ApiaryEditCard key={apiary.id} apiary={apiary} />
<WeatherCard
apiaryId={apiary.id}
hasCoordinates={apiary.latitude !== null && apiary.longitude !== null}
/>
<DocumentUploadCard entityType="APIARY" entityId={apiary.id} />
<div className="flex flex-wrap gap-2">
<Button
@@ -0,0 +1,129 @@
"use client";
import { CloudSun } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { fetchApiaryWeather, fetchTrachtprognose } from "@/lib/weather/api";
import { TrachtprognoseRating } from "@/lib/weather/types";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
const RATING_LABELS: Record<TrachtprognoseRating, string> = {
GUT: "Gute Tracht",
MAESSIG: "Mäßige Tracht",
SCHWACH: "Schwache Tracht",
KEINE_TRACHT: "Keine Tracht",
};
const RATING_VARIANTS: Record<
TrachtprognoseRating,
"default" | "secondary" | "warning" | "outline"
> = {
GUT: "default",
MAESSIG: "secondary",
SCHWACH: "warning",
KEINE_TRACHT: "outline",
};
function formatDayLabel(dateIso: string): string {
return new Date(dateIso).toLocaleDateString("de-DE", {
weekday: "short",
day: "2-digit",
month: "2-digit",
});
}
export function WeatherCard({
apiaryId,
hasCoordinates,
}: {
apiaryId: string;
hasCoordinates: boolean;
}) {
const { data: trachtprognose, isLoading: trachtLoading } = useQuery({
queryKey: ["apiaries", apiaryId, "trachtprognose"],
queryFn: () => fetchTrachtprognose(apiaryId),
enabled: hasCoordinates,
});
const { data: weather, isLoading: weatherLoading } = useQuery({
queryKey: ["apiaries", apiaryId, "weather"],
queryFn: () => fetchApiaryWeather(apiaryId),
enabled: hasCoordinates,
});
if (!hasCoordinates) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CloudSun className="size-4.5" />
Wetter & Trachtprognose
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Für diesen Bienenstand sind noch keine Koordinaten hinterlegt.
Trage Breiten- und Längengrad ein, um Wetterdaten zu sehen.
</p>
</CardContent>
</Card>
);
}
const today = new Date().toISOString().slice(0, 10);
const forecastDays = (weather?.daily ?? []).filter((day) => day.date >= today).slice(0, 7);
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CloudSun className="size-4.5" />
Wetter & Trachtprognose
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{trachtLoading ? (
<p className="text-sm text-muted-foreground">Lädt…</p>
) : trachtprognose ? (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Badge variant={RATING_VARIANTS[trachtprognose.rating]}>
{RATING_LABELS[trachtprognose.rating]}
</Badge>
<span className="text-sm text-muted-foreground">{trachtprognose.season}</span>
</div>
<ul className="flex list-disc flex-col gap-1 pl-5 text-sm text-muted-foreground">
{trachtprognose.reasoning.map((line) => (
<li key={line}>{line}</li>
))}
</ul>
</div>
) : null}
{weatherLoading ? (
<p className="text-sm text-muted-foreground">Wetterdaten laden…</p>
) : forecastDays.length > 0 ? (
<div className="grid grid-cols-3 gap-2 border-t border-border/50 pt-3 sm:grid-cols-7">
{forecastDays.map((day) => (
<div key={day.date} className="flex flex-col items-center gap-0.5 text-center">
<span className="text-xs text-muted-foreground">
{formatDayLabel(day.date)}
</span>
<span className="text-sm font-medium">
{day.tempMaxC !== null ? `${Math.round(day.tempMaxC)}°` : "–"}
</span>
<span className="text-xs text-muted-foreground">
{day.tempMinC !== null ? `${Math.round(day.tempMinC)}°` : "–"}
</span>
{day.precipitationMm !== null && day.precipitationMm > 0 ? (
<span className="text-xs text-primary">
{day.precipitationMm.toFixed(0)} mm
</span>
) : null}
</div>
))}
</div>
) : null}
</CardContent>
</Card>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { apiFetch } from "@/lib/auth/api-client";
import { ApiaryWeather, Trachtprognose } from "./types";
export function fetchApiaryWeather(apiaryId: string): Promise<ApiaryWeather> {
return apiFetch<ApiaryWeather>(`/apiaries/${apiaryId}/weather`);
}
export function fetchTrachtprognose(apiaryId: string): Promise<Trachtprognose> {
return apiFetch<Trachtprognose>(`/apiaries/${apiaryId}/trachtprognose`);
}
+19
View File
@@ -0,0 +1,19 @@
export interface DailyWeather {
date: string;
tempMaxC: number | null;
tempMinC: number | null;
precipitationMm: number | null;
}
export interface ApiaryWeather {
daily: DailyWeather[];
}
export type TrachtprognoseRating = "GUT" | "MAESSIG" | "SCHWACH" | "KEINE_TRACHT";
export interface Trachtprognose {
rating: TrachtprognoseRating;
season: string;
reasoning: string[];
historicalAverageYieldKg: number | null;
}