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:
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"));
|
||||
}
|
||||
Reference in new issue
Block a user