feat: Ein-/Auswinterung und Winterverluste je Volk

- WinterCheck per colony, winter and season (autumn/spring), every field
  editable; loss causes limited to the COLOSS monitoring categories.
- Summary per winter and apiary: loss rate lost/wintered, marked
  provisional while colonies are not yet checked in spring - an unchecked
  colony never counts as survived.
- A loss marks the colony deceased only on explicit request, in the same
  transaction and audited.
- Permissions winter-checks:read/create/update granted by migration to
  beekeeper and staff roles; winter checks included in backups.
- Page "Überwinterung": next action first, one button per row until the
  colony is wintered, German number format.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-28 17:32:03 +02:00
1 parent 0d23963f07
commit fac66882fa
26 files changed
+2020 -2

No files matched your search

@@ -20,6 +20,7 @@ import {
Package,
Scissors,
Search,
Snowflake,
ShoppingBag,
Users,
Warehouse,
@@ -58,6 +59,12 @@ const NAV_GROUPS: NavGroup[] = [
{ label: "Bienenstände", href: "/apiaries", icon: MapPin },
{ label: "Völker", href: "/colonies", icon: Layers },
{ label: "Königinnen", href: "/queens", icon: Crown },
{
label: "Überwinterung",
href: "/winter",
icon: Snowflake,
permission: "winter-checks:read",
},
],
},
{
@@ -0,0 +1,261 @@
"use client";
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { ChevronLeft, ChevronRight, Snowflake } from "lucide-react";
import { useAuth } from "@/lib/auth/auth-context";
import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchColonies } from "@/lib/colonies/api";
import { fetchWinterChecks, fetchWinterSummary } from "@/lib/winter-checks/api";
import { LOSS_CAUSE_LABELS, WINTER_OUTCOME_LABELS } from "@/lib/winter-checks/labels";
import { ColonyLossCause, WinterCheckWithColony, WinterCounts } from "@/lib/winter-checks/types";
import { buildWinterRows, WinterRow, winterLabel, winterYearFor } from "@/lib/winter-checks/winter";
import { PageHeader } from "@/components/page-header";
import { EmptyState } from "@/components/empty-state";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { WinterCheckDialog, WinterCheckTarget } from "./winter-check-dialog";
const DATE = new Intl.DateTimeFormat("de-DE", { day: "2-digit", month: "2-digit" });
function autumnText(check: WinterCheckWithColony): string {
const parts = [DATE.format(new Date(check.date))];
if (check.occupiedSeams !== null) parts.push(`${check.occupiedSeams} Gassen`);
if (check.feedStockKg !== null) {
parts.push(`${check.feedStockKg.toLocaleString("de-DE")} kg Futter`);
}
return parts.join(" · ");
}
function SummaryCards({ counts }: { counts: WinterCounts }) {
// Before the first Auswinterung a rate of "0 %" would read as "no losses".
const hasSpringResults = counts.survived + counts.weak + counts.lost > 0;
const causes = (Object.keys(counts.lostByCause) as ColonyLossCause[]).filter(
(cause) => counts.lostByCause[cause] > 0,
);
return (
<div className="grid grid-cols-2 gap-3 md:grid-cols-5">
<Stat label="Eingewintert" value={counts.wintered} />
<Stat label="Überlebt" value={counts.survived} />
<Stat label="Schwach" value={counts.weak} />
<Stat
label="Verloren"
value={counts.lost}
hint={causes.map((cause) => `${counts.lostByCause[cause]}× ${LOSS_CAUSE_LABELS[cause]}`).join(", ")}
/>
<Stat
label={counts.isProvisional ? "Verlustrate (vorläufig)" : "Verlustrate"}
value={
counts.lossRatePercent === null || !hasSpringResults
? "–"
: `${counts.lossRatePercent.toLocaleString("de-DE")} %`
}
hint={
counts.pendingSpring > 0
? `${counts.pendingSpring} Volk/Völker noch nicht ausgewintert`
: undefined
}
/>
</div>
);
}
function Stat({ label, value, hint }: { label: string; value: number | string; hint?: string }) {
return (
<div className="rounded-xl border border-border p-3">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="font-heading text-2xl font-semibold tabular-nums">{value}</p>
{hint ? <p className="mt-1 text-xs text-muted-foreground">{hint}</p> : null}
</div>
);
}
function RowActions({
row,
canCreate,
canUpdate,
isSpringDue,
onOpen,
}: {
row: WinterRow;
canCreate: boolean;
canUpdate: boolean;
/** The winter is over (January on) - Auswinterung also without Einwinterung record. */
isSpringDue: boolean;
onOpen: (season: "AUTUMN" | "SPRING", existing: WinterCheckWithColony | null) => void;
}) {
return (
<div className="flex flex-wrap items-center gap-2">
{row.autumn ? (
<Button variant="ghost" size="sm" disabled={!canUpdate} onClick={() => onOpen("AUTUMN", row.autumn)}>
Eingewintert {autumnText(row.autumn)}
</Button>
) : row.isLiving && canCreate ? (
<Button variant="outline" size="touch" onClick={() => onOpen("AUTUMN", null)}>
Einwintern
</Button>
) : (
<span className="text-sm text-muted-foreground">Nicht eingewintert</span>
)}
{row.spring?.outcome ? (
<Button variant="ghost" size="sm" disabled={!canUpdate} onClick={() => onOpen("SPRING", row.spring)}>
<Badge
variant={
row.spring.outcome === "LOST"
? "destructive"
: row.spring.outcome === "WEAK"
? "warning"
: "default"
}
>
{WINTER_OUTCOME_LABELS[row.spring.outcome]}
</Badge>
{row.spring.lossCause ? LOSS_CAUSE_LABELS[row.spring.lossCause] : null}
</Button>
) : canCreate && (row.autumn || isSpringDue) ? (
<Button variant="outline" size="touch" onClick={() => onOpen("SPRING", null)}>
Auswintern
</Button>
) : null}
</div>
);
}
export default function WinterPage() {
const { hasPermission } = useAuth();
const [winterYear, setWinterYear] = useState(() => winterYearFor(new Date()));
const [target, setTarget] = useState<WinterCheckTarget | null>(null);
const canCreate = hasPermission("winter-checks:create");
const canUpdate = hasPermission("winter-checks:update");
const checksQuery = useQuery({
queryKey: ["winter-checks", winterYear],
queryFn: () => fetchWinterChecks(winterYear),
});
const summaryQuery = useQuery({
queryKey: ["winter-checks", "summary", winterYear],
queryFn: () => fetchWinterSummary(winterYear),
});
const coloniesQuery = useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() });
const apiariesQuery = useQuery({ queryKey: ["apiaries"], queryFn: () => fetchApiaries() });
const groups = useMemo(
() =>
buildWinterRows(
coloniesQuery.data ?? [],
checksQuery.data ?? [],
new Map((apiariesQuery.data ?? []).map((apiary) => [apiary.id, apiary.name])),
),
[coloniesQuery.data, checksQuery.data, apiariesQuery.data],
);
// One action per row until the colony is wintered: from January of the
// following year on, Auswinterung is offered for every colony.
const isSpringDue = new Date() >= new Date(winterYear + 1, 0, 1);
const openAutumn = groups.reduce(
(sum, group) => sum + group.rows.filter((row) => row.isLiving && !row.autumn).length,
0,
);
function open(row: WinterRow, season: "AUTUMN" | "SPRING", existing: WinterCheckWithColony | null) {
setTarget({
colonyId: row.colonyId,
code: row.code,
isLiving: row.isLiving,
season,
winterYear,
existing,
});
}
const isLoading = checksQuery.isLoading || coloniesQuery.isLoading;
const isError = checksQuery.isError || coloniesQuery.isError;
return (
<div className="flex flex-col gap-6">
<PageHeader
icon={Snowflake}
title="Überwinterung"
description="Ein- und Auswinterung je Volk, Verluste mit Ursache."
actions={
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon"
aria-label="Vorheriger Winter"
onClick={() => setWinterYear((year) => year - 1)}
>
<ChevronLeft />
</Button>
<span className="min-w-36 text-center font-medium tabular-nums">{winterLabel(winterYear)}</span>
<Button
variant="outline"
size="icon"
aria-label="Nächster Winter"
onClick={() => setWinterYear((year) => year + 1)}
>
<ChevronRight />
</Button>
</div>
}
/>
{openAutumn > 0 && canCreate && !isSpringDue ? (
<p className="rounded-xl bg-muted px-4 py-3 text-sm">
<span className="font-medium">Als Nächstes:</span> {openAutumn} Volk/Völker noch nicht
eingewintert.
</p>
) : null}
{summaryQuery.data ? <SummaryCards counts={summaryQuery.data.total} /> : null}
{isError ? (
<p className="text-sm text-destructive">
Daten konnten nicht geladen werden. Bitte die Seite neu laden.
</p>
) : isLoading ? (
<p className="text-sm text-muted-foreground">Wird geladen…</p>
) : groups.length === 0 ? (
<EmptyState
icon={Snowflake}
title="Keine Völker"
description="Sobald Völker angelegt sind, lassen sie sich hier ein- und auswintern."
/>
) : (
groups.map((group) => (
<Card key={group.apiaryId}>
<CardHeader>
<CardTitle>{group.apiaryName}</CardTitle>
</CardHeader>
<CardContent>
<ul className="flex flex-col divide-y divide-border">
{group.rows.map((row) => (
<li
key={row.colonyId}
className="flex flex-col gap-2 py-3 sm:flex-row sm:items-center sm:justify-between"
>
<span className="font-medium">
{row.code}
{!row.isLiving ? (
<span className="ml-2 text-xs text-muted-foreground">(besteht nicht mehr)</span>
) : null}
</span>
<RowActions
row={row}
canCreate={canCreate}
canUpdate={canUpdate}
isSpringDue={isSpringDue}
onOpen={(season, existing) => open(row, season, existing)}
/>
</li>
))}
</ul>
</CardContent>
</Card>
))
)}
{target ? <WinterCheckDialog target={target} onClose={() => setTarget(null)} /> : null}
</div>
);
}
@@ -0,0 +1,269 @@
"use client";
import { FormEvent, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { ApiError } from "@/lib/auth/api-client";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
import { createWinterCheck, updateWinterCheck } from "@/lib/winter-checks/api";
import { LOSS_CAUSE_LABELS, WINTER_OUTCOME_LABELS } from "@/lib/winter-checks/labels";
import {
ColonyLossCause,
WinterCheck,
WinterCheckInput,
WinterCheckSeason,
WinterOutcome,
} from "@/lib/winter-checks/types";
import { winterLabel } from "@/lib/winter-checks/winter";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
export interface WinterCheckTarget {
colonyId: string;
code: string;
isLiving: boolean;
season: WinterCheckSeason;
winterYear: number;
existing: WinterCheck | null;
}
const QUEEN_OPTIONS = { yes: "Ja", no: "Nein", unknown: "Nicht geprüft" } as const;
type QueenOption = keyof typeof QUEEN_OPTIONS;
function queenOption(value: boolean | null | undefined): QueenOption {
if (value === true) return "yes";
if (value === false) return "no";
return "unknown";
}
function toNumber(value: string): number | undefined {
const trimmed = value.trim().replace(",", ".");
return trimmed === "" ? undefined : Number(trimmed);
}
const OUTCOMES: WinterOutcome[] = ["SURVIVED", "WEAK", "LOST"];
export function WinterCheckDialog({
target,
onClose,
}: {
target: WinterCheckTarget;
onClose: () => void;
}) {
const queryClient = useQueryClient();
const existing = target.existing;
const isSpring = target.season === "SPRING";
const [date, setDate] = useState(
existing?.date.slice(0, 10) ?? new Date().toISOString().slice(0, 10),
);
const [occupiedSeams, setOccupiedSeams] = useState(existing?.occupiedSeams?.toString() ?? "");
const [feedStockKg, setFeedStockKg] = useState(existing?.feedStockKg?.toString() ?? "");
const [queen, setQueen] = useState<QueenOption>(queenOption(existing?.queenPresent));
const [outcome, setOutcome] = useState<WinterOutcome | null>(existing?.outcome ?? null);
const [lossCause, setLossCause] = useState<ColonyLossCause | "">(existing?.lossCause ?? "");
const [markDeceased, setMarkDeceased] = useState(target.isLiving);
const [notes, setNotes] = useState(existing?.notes ?? "");
const [idempotencyKey] = useState(newIdempotencyKey);
const isLost = outcome === "LOST";
const missing = isSpring && (!outcome || (isLost && !lossCause));
const mutation = useMutation({
mutationFn: () => {
const input: WinterCheckInput = {
colonyId: target.colonyId,
season: target.season,
winterYear: target.winterYear,
date: new Date(date).toISOString(),
occupiedSeams: toNumber(occupiedSeams),
feedStockKg: isSpring ? undefined : toNumber(feedStockKg),
queenPresent: queen === "unknown" ? undefined : queen === "yes",
outcome: outcome ?? undefined,
lossCause: isLost && lossCause ? lossCause : undefined,
notes: notes.trim() || undefined,
markColonyDeceased: isLost && target.isLiving ? markDeceased : undefined,
};
return existing
? updateWinterCheck(existing.id, input)
: createWinterCheck(input, { idempotencyKey });
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["winter-checks"] });
if (isLost && markDeceased) {
void queryClient.invalidateQueries({ queryKey: ["colonies"] });
}
toast.success(`${target.code}: gespeichert`);
onClose();
},
onError: (error) =>
toast.error(
error instanceof ApiError ? error.message : "Speichern fehlgeschlagen. Bitte noch einmal versuchen.",
),
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (missing) return;
mutation.mutate();
}
const title = `${target.code} ${isSpring ? "auswintern" : "einwintern"}`;
return (
<Dialog open onOpenChange={(open) => (open ? undefined : onClose())}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<p className="text-sm text-muted-foreground">{winterLabel(target.winterYear)}</p>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
{isSpring ? (
<fieldset className="flex flex-col gap-2">
<legend className="mb-2 text-sm font-medium">Wie ist das Volk durch den Winter gekommen?</legend>
<div className="grid grid-cols-3 gap-2">
{OUTCOMES.map((value) => (
<Button
key={value}
type="button"
size="touch"
variant={outcome === value ? (value === "LOST" ? "destructive" : "default") : "outline"}
aria-pressed={outcome === value}
onClick={() => setOutcome(value)}
>
{WINTER_OUTCOME_LABELS[value]}
</Button>
))}
</div>
</fieldset>
) : null}
{isLost ? (
<div className="flex flex-col gap-3 rounded-xl border border-destructive/30 p-3">
<div className="flex flex-col gap-2">
<Label htmlFor="winter-loss-cause">Verlustursache</Label>
<Select
value={lossCause}
onValueChange={(value) => setLossCause((value ?? "") as ColonyLossCause | "")}
>
<SelectTrigger id="winter-loss-cause" className="h-11 w-full">
<SelectValue placeholder="Ursache wählen">
{lossCause ? LOSS_CAUSE_LABELS[lossCause] : null}
</SelectValue>
</SelectTrigger>
<SelectContent>
{(Object.keys(LOSS_CAUSE_LABELS) as ColonyLossCause[]).map((cause) => (
<SelectItem key={cause} value={cause}>
{LOSS_CAUSE_LABELS[cause]}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Kategorien wie im COLOSS-Monitoring. Vermutete Gründe (z. B. Varroa) gern in die Notiz.
</p>
</div>
{target.isLiving ? (
<div className="flex items-center gap-2">
<Checkbox
id="winter-mark-deceased"
checked={markDeceased}
onCheckedChange={(checked) => setMarkDeceased(checked === true)}
/>
<Label htmlFor="winter-mark-deceased">Volk als abgestorben kennzeichnen</Label>
</div>
) : null}
</div>
) : null}
<div className="flex flex-col gap-2">
<Label htmlFor="winter-date">Datum</Label>
<Input
id="winter-date"
type="date"
className="h-11"
value={date}
onChange={(event) => setDate(event.target.value)}
required
/>
</div>
{!isLost ? (
<div className={cn("grid gap-3", isSpring ? "grid-cols-1" : "grid-cols-2")}>
<div className="flex flex-col gap-2">
<Label htmlFor="winter-seams">Besetzte Wabengassen</Label>
<Input
id="winter-seams"
inputMode="numeric"
className="h-11"
value={occupiedSeams}
onChange={(event) => setOccupiedSeams(event.target.value)}
/>
</div>
{!isSpring ? (
<div className="flex flex-col gap-2">
<Label htmlFor="winter-feed">Futtervorrat (kg, geschätzt)</Label>
<Input
id="winter-feed"
inputMode="decimal"
className="h-11"
value={feedStockKg}
onChange={(event) => setFeedStockKg(event.target.value)}
/>
</div>
) : null}
</div>
) : null}
{!isLost ? (
<div className="flex flex-col gap-2">
<Label htmlFor="winter-queen">Königin/Stifte gesehen</Label>
<Select value={queen} onValueChange={(value) => setQueen((value ?? "unknown") as QueenOption)}>
<SelectTrigger id="winter-queen" className="h-11 w-full">
<SelectValue>{QUEEN_OPTIONS[queen]}</SelectValue>
</SelectTrigger>
<SelectContent>
{(Object.keys(QUEEN_OPTIONS) as QueenOption[]).map((option) => (
<SelectItem key={option} value={option}>
{QUEEN_OPTIONS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
<div className="flex flex-col gap-2">
<Label htmlFor="winter-notes">Notiz</Label>
<Textarea id="winter-notes" value={notes} onChange={(event) => setNotes(event.target.value)} />
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" size="touch" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" size="touch" disabled={missing || mutation.isPending}>
{mutation.isPending ? "Wird gespeichert…" : "Speichern"}
</Button>
</div>
{missing ? (
<p className="text-right text-xs text-muted-foreground">
{outcome ? "Bitte die Verlustursache wählen." : "Bitte zuerst das Ergebnis wählen."}
</p>
) : null}
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,37 @@
import { apiFetch } from "@/lib/auth/api-client";
import { CreateOptions, idempotencyHeaders } from "@/lib/offline/idempotency";
import {
WinterCheck,
WinterCheckInput,
WinterCheckWithColony,
WinterSummary,
} from "./types";
export function fetchWinterChecks(winterYear: number): Promise<WinterCheckWithColony[]> {
return apiFetch<WinterCheckWithColony[]>(`/winter-checks?winterYear=${winterYear}`);
}
export function fetchWinterSummary(winterYear: number): Promise<WinterSummary> {
return apiFetch<WinterSummary>(`/winter-checks/summary?winterYear=${winterYear}`);
}
export function createWinterCheck(
input: WinterCheckInput,
options: CreateOptions = {},
): Promise<WinterCheck> {
return apiFetch<WinterCheck>("/winter-checks", {
method: "POST",
headers: idempotencyHeaders(options),
body: JSON.stringify(input),
});
}
export function updateWinterCheck(
id: string,
input: Partial<WinterCheckInput>,
): Promise<WinterCheck> {
return apiFetch<WinterCheck>(`/winter-checks/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
@@ -0,0 +1,14 @@
import { ColonyLossCause, WinterOutcome } from "./types";
export const WINTER_OUTCOME_LABELS: Record<WinterOutcome, string> = {
SURVIVED: "Überlebt",
WEAK: "Schwach",
LOST: "Verloren",
};
/** Loss categories of the COLOSS monitoring - no diagnosis. */
export const LOSS_CAUSE_LABELS: Record<ColonyLossCause, string> = {
DEAD_OR_EMPTY: "Tot oder Beute leer",
QUEEN_PROBLEM: "Unlösbares Königinnenproblem",
NATURAL_DISASTER: "Naturereignis (Sturm, Hochwasser, Tiere …)",
};
@@ -0,0 +1,62 @@
export type WinterCheckSeason = "AUTUMN" | "SPRING";
export type WinterOutcome = "SURVIVED" | "WEAK" | "LOST";
export type ColonyLossCause = "DEAD_OR_EMPTY" | "QUEEN_PROBLEM" | "NATURAL_DISASTER";
export interface WinterCheck {
id: string;
colonyId: string;
userId: string;
winterYear: number;
season: WinterCheckSeason;
date: string;
occupiedSeams: number | null;
feedStockKg: number | null;
queenPresent: boolean | null;
outcome: WinterOutcome | null;
lossCause: ColonyLossCause | null;
notes: string | null;
createdAt: string;
updatedAt: string;
}
export interface WinterCheckWithColony extends WinterCheck {
colony: {
id: string;
code: string;
status: string;
apiaryId: string;
apiary: { id: string; name: string };
};
}
export interface WinterCheckInput {
colonyId: string;
season: WinterCheckSeason;
date: string;
winterYear?: number;
occupiedSeams?: number;
feedStockKg?: number;
queenPresent?: boolean;
outcome?: WinterOutcome;
lossCause?: ColonyLossCause;
notes?: string;
/** With outcome LOST: also mark the colony as deceased. */
markColonyDeceased?: boolean;
}
export interface WinterCounts {
wintered: number;
survived: number;
weak: number;
lost: number;
lostByCause: Record<ColonyLossCause, number>;
pendingSpring: number;
springWithoutAutumn: number;
lossRatePercent: number | null;
isProvisional: boolean;
}
export interface WinterSummary {
total: WinterCounts;
apiaries: (WinterCounts & { apiaryId: string; apiaryName: string })[];
}
@@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import type { Colony } from "@/lib/colonies/types";
import type { WinterCheckWithColony } from "./types";
import { buildWinterRows, winterLabel, winterYearFor } from "./winter";
function colony(overrides: Partial<Colony>): Colony {
return {
id: "c1",
code: "V-01",
apiaryId: "a",
hiveType: null,
originText: null,
colonyType: "PRODUCTION_COLONY",
status: "ACTIVE",
strength: null,
broodStatus: null,
feedStatus: null,
healthStatus: null,
notes: null,
currentQueenId: null,
createdAt: "",
updatedAt: "",
deactivatedAt: null,
...overrides,
};
}
const deadColony = {
id: "c1",
code: "V-01",
status: "DECEASED",
apiaryId: "a",
apiary: { id: "a", name: "Obstwiese" },
};
function check(overrides: Partial<WinterCheckWithColony>): WinterCheckWithColony {
return {
id: "w1",
colonyId: "c1",
userId: "u",
winterYear: 2026,
season: "AUTUMN",
date: "2026-10-01T00:00:00.000Z",
occupiedSeams: null,
feedStockKg: null,
queenPresent: null,
outcome: null,
lossCause: null,
notes: null,
createdAt: "",
updatedAt: "",
colony: { ...deadColony, status: "ACTIVE" },
...overrides,
};
}
describe("winterYearFor / winterLabel", () => {
it("maps autumn and spring to the same winter", () => {
expect(winterYearFor(new Date(2026, 9, 1))).toBe(2026);
expect(winterYearFor(new Date(2027, 2, 20))).toBe(2026);
expect(winterLabel(2026)).toBe("Winter 2026/27");
});
});
describe("buildWinterRows", () => {
it("lists living colonies without records and hides old dead ones", () => {
const groups = buildWinterRows(
[colony({ id: "c1" }), colony({ id: "c2", code: "V-02", status: "DECEASED" })],
[],
new Map([["a", "Obstwiese"]]),
);
expect(groups).toHaveLength(1);
expect(groups[0].rows.map((row) => row.colonyId)).toEqual(["c1"]);
});
it("keeps a colony that died this winter, with its loss record", () => {
const groups = buildWinterRows(
[],
[
check({ colony: deadColony }),
check({
id: "w2",
season: "SPRING",
outcome: "LOST",
lossCause: "DEAD_OR_EMPTY",
colony: deadColony,
}),
],
new Map(),
);
const [row] = groups[0].rows;
expect(row).toMatchObject({ isLiving: false });
expect(row.autumn?.id).toBe("w1");
expect(row.spring?.outcome).toBe("LOST");
expect(groups[0].apiaryName).toBe("Obstwiese");
});
it("sorts colony codes naturally", () => {
const groups = buildWinterRows(
[colony({ id: "c10", code: "V-10" }), colony({ id: "c2", code: "V-2" })],
[],
new Map([["a", "Obstwiese"]]),
);
expect(groups[0].rows.map((row) => row.code)).toEqual(["V-2", "V-10"]);
});
});
@@ -0,0 +1,83 @@
import type { Colony } from "@/lib/colonies/types";
import type { WinterCheckWithColony } from "./types";
const TERMINAL_STATUSES = new Set(["DISSOLVED", "SOLD", "DECEASED", "MERGED"]);
/** From July on a date belongs to the winter starting that year. */
export function winterYearFor(date: Date): number {
return date.getMonth() >= 6 ? date.getFullYear() : date.getFullYear() - 1;
}
export function winterLabel(winterYear: number): string {
return `Winter ${winterYear}/${String(winterYear + 1).slice(-2)}`;
}
export interface WinterRow {
colonyId: string;
code: string;
isLiving: boolean;
autumn: WinterCheckWithColony | null;
spring: WinterCheckWithColony | null;
}
export interface WinterApiaryGroup {
apiaryId: string;
apiaryName: string;
rows: WinterRow[];
}
/**
* One row per colony that is alive now or has a record in this winter, so a
* colony that died still shows up with its loss, grouped by apiary.
*/
export function buildWinterRows(
colonies: Colony[],
checks: WinterCheckWithColony[],
apiaryNames: ReadonlyMap<string, string>,
): WinterApiaryGroup[] {
const names = new Map(apiaryNames);
const rows = new Map<string, WinterRow & { apiaryId: string }>();
for (const colony of colonies) {
if (TERMINAL_STATUSES.has(colony.status)) continue;
rows.set(colony.id, {
colonyId: colony.id,
code: colony.code,
apiaryId: colony.apiaryId,
isLiving: true,
autumn: null,
spring: null,
});
}
for (const check of checks) {
const existing = rows.get(check.colonyId) ?? {
colonyId: check.colonyId,
code: check.colony.code,
apiaryId: check.colony.apiaryId,
isLiving: !TERMINAL_STATUSES.has(check.colony.status),
autumn: null,
spring: null,
};
rows.set(check.colonyId, {
...existing,
...(check.season === "AUTUMN" ? { autumn: check } : { spring: check }),
});
if (!names.has(check.colony.apiaryId)) {
names.set(check.colony.apiaryId, check.colony.apiary.name);
}
}
const groups = new Map<string, WinterApiaryGroup>();
for (const { apiaryId, ...row } of rows.values()) {
const group = groups.get(apiaryId) ?? {
apiaryId,
apiaryName: names.get(apiaryId) ?? "Unbekannter Stand",
rows: [],
};
groups.set(apiaryId, { ...group, rows: [...group.rows, row] });
}
return [...groups.values()]
.map((group) => ({
...group,
rows: [...group.rows].sort((a, b) => a.code.localeCompare(b.code, "de", { numeric: true })),
}))
.sort((a, b) => a.apiaryName.localeCompare(b.apiaryName, "de"));
}