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

@@ -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"));
}