feat: T4 wave 5 - colony end cleanup, withdrawal chain, next steps, inspection takeover UI

- Colony end releases the queen in the same transaction; open
  suggestions become obsolete and a CLEANUP suggestion lists open
  tasks and the queen; apply-cleanup validates evidence and blocks
  reactivated colonies
- Withdrawal chain: plausibility warnings on harvest and bottling,
  conflict notifications also when a treatment is entered or corrected
  after the harvest (naming bottled lots), withdrawal spans in the
  calendar and M365 sync, Berlin-day end events
- Next 3 steps per colony and the "Heute" endpoint with ranking and an
  explicit merge table by intent
- Inspection-triggered rule suggestions follow the colony's latest
  inspection (resolve when the finding is gone, refresh when it stays)
- Frontend: colony card takeover in the inspection form, feed status as
  condition, occupied frames 0-40; legacy offline entries keep syncing
- Notifications and preferences are deleted with their user

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 16:28:07 +02:00
1 parent 02eb16924e
commit ef33e5549b
122 files changed
+10707 -504

No files matched your search

@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import {
FEED_CONDITION_LABELS,
INSPECTION_FEED_OPTIONS,
feedConditionFromInput,
ratingToCondition,
} from "./condition";
describe("FEED_CONDITION_LABELS", () => {
it("words the feed levels like the backend", () => {
expect(FEED_CONDITION_LABELS).toEqual({
EXCELLENT: "Reichlich",
GOOD: "Ausreichend",
AVERAGE: "Mittel",
WEAK: "Niedrig",
CRITICAL: "Kritisch",
});
});
it("offers three levels in an inspection", () => {
expect(INSPECTION_FEED_OPTIONS).toEqual(["GOOD", "WEAK", "CRITICAL"]);
});
});
describe("ratingToCondition", () => {
it("maps the brood pattern 5..1 to excellent..critical", () => {
expect([5, 4, 3, 2, 1].map(ratingToCondition)).toEqual([
"EXCELLENT",
"GOOD",
"AVERAGE",
"WEAK",
"CRITICAL",
]);
});
it("gives nothing for no or an impossible rating", () => {
expect(ratingToCondition(null)).toBeNull();
expect(ratingToCondition(0)).toBeNull();
expect(ratingToCondition(6)).toBeNull();
expect(ratingToCondition(2.5)).toBeNull();
});
});
describe("feedConditionFromInput", () => {
it("keeps enum values, in any case", () => {
expect(feedConditionFromInput("WEAK")).toBe("WEAK");
expect(feedConditionFromInput("excellent")).toBe("EXCELLENT");
});
it("maps the legacy words of the previous version, trimmed and case-insensitive", () => {
expect(feedConditionFromInput("ausreichend")).toBe("GOOD");
expect(feedConditionFromInput(" Niedrig ")).toBe("WEAK");
expect(feedConditionFromInput("KRITISCH")).toBe("CRITICAL");
});
it("gives null for empty, unknown or inherited-looking text", () => {
expect(feedConditionFromInput(null)).toBeNull();
expect(feedConditionFromInput(undefined)).toBeNull();
expect(feedConditionFromInput(" ")).toBeNull();
expect(feedConditionFromInput("halb voll")).toBeNull();
expect(feedConditionFromInput("constructor")).toBeNull();
});
});
@@ -0,0 +1,55 @@
import type { ColonyCondition } from "./types";
/** Feed levels worded as the backend words them (colony card and inspection alike). */
export const FEED_CONDITION_LABELS: Record<ColonyCondition, string> = {
EXCELLENT: "Reichlich",
GOOD: "Ausreichend",
AVERAGE: "Mittel",
WEAK: "Niedrig",
CRITICAL: "Kritisch",
};
/** What an inspection offers for feed; „Nicht geprüft“ (null) comes on top. */
export const INSPECTION_FEED_OPTIONS = ["GOOD", "WEAK", "CRITICAL"] as const;
const CONDITION_BY_BROOD_PATTERN: Readonly<Record<number, ColonyCondition>> = {
5: "EXCELLENT",
4: "GOOD",
3: "AVERAGE",
2: "WEAK",
1: "CRITICAL",
};
/** Brood pattern 1-5 as the colony card's brood status (same mapping as the backend takeover). */
export function ratingToCondition(rating: number | null): ColonyCondition | null {
if (rating === null) {
return null;
}
return CONDITION_BY_BROOD_PATTERN[rating] ?? null;
}
const CONDITIONS: ReadonlySet<string> = new Set(Object.keys(FEED_CONDITION_LABELS));
// The words the app used before the feed vocabulary became the colony scale.
const LEGACY_FEED_WORDS: ReadonlyMap<string, ColonyCondition> = new Map([
["ausreichend", "GOOD"],
["niedrig", "WEAK"],
["kritisch", "CRITICAL"],
]);
/**
* A feed value from anywhere (a draft, an offline pack or a queued entry of
* the previous app version) as the enum. The server maps the same legacy
* words; anything else is not a feed level and gives null.
*/
export function feedConditionFromInput(value: string | null | undefined): ColonyCondition | null {
const word = value?.trim().toLowerCase() ?? "";
if (word === "") {
return null;
}
const upper = word.toUpperCase();
if (CONDITIONS.has(upper)) {
return upper as ColonyCondition;
}
return LEGACY_FEED_WORDS.get(word) ?? null;
}
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { colonyStrengthText } from "./labels";
describe("colonyStrengthText", () => {
it("names the occupied frames", () => {
expect(colonyStrengthText({ occupiedFrames: 8, strength: 7 })).toBe("8 besetzte Waben");
expect(colonyStrengthText({ occupiedFrames: 1, strength: null })).toBe("1 besetzte Wabe");
expect(colonyStrengthText({ occupiedFrames: 0, strength: null })).toBe("0 besetzte Waben");
});
it("falls back to the old 1–10 scale, marked as old", () => {
expect(colonyStrengthText({ occupiedFrames: null, strength: 7 })).toBe(
"Volksstärke (alt): 7 von 10",
);
});
it("says nothing when neither is stored", () => {
expect(colonyStrengthText({ occupiedFrames: null, strength: null })).toBeNull();
});
});
+22 -1
View File
@@ -1,4 +1,4 @@
import { Colony, ColonyCondition, ColonyStatus, ColonyType } from "./types";
import { Colony, ColonyCondition, ColonyStatus, ColonyType, SplitQueenSource } from "./types";
import { LIVING_COLONY_STATUSES } from "./status";
// Werte und Reihenfolge aus docs/lastenheft.md Abschnitt 4.3 ("Moegliche Volkstypen"/"Moegliche Status").
@@ -79,3 +79,24 @@ export const COLONY_CONDITION_TONE_CLASS: Record<ColonyCondition, string> = {
export const COLONY_STRENGTH_LABELS: Record<string, string> = Object.fromEntries(
Array.from({ length: 10 }, (_, index) => [String(index + 1), String(index + 1)]),
);
/** The most occupied frames the server accepts ("Höchstens 40 besetzte Waben."). */
export const MAX_OCCUPIED_FRAMES = 40;
export const SPLIT_QUEEN_SOURCE_LABELS: Record<SplitQueenSource, string> = {
RAISE_OWN: "Eigene Zellen ziehen",
QUEEN_CELL: "Weiselzelle",
QUEEN: "Königin",
};
/** What the colony page says about the colony's size, or null when nothing is stored. */
export function colonyStrengthText(
colony: Pick<Colony, "occupiedFrames" | "strength">,
): string | null {
if (colony.occupiedFrames !== null) {
return colony.occupiedFrames === 1
? "1 besetzte Wabe"
: `${colony.occupiedFrames} besetzte Waben`;
}
return colony.strength === null ? null : `Volksstärke (alt): ${colony.strength} von 10`;
}
@@ -26,7 +26,10 @@ export interface Colony {
originText: string | null;
colonyType: ColonyType;
status: ColonyStatus;
/** Deprecated 1-10 scale; kept as stored, never converted. */
strength: number | null;
/** Occupied frames, 0-40. */
occupiedFrames: number | null;
broodStatus: ColonyCondition | null;
feedStatus: ColonyCondition | null;
healthStatus: ColonyCondition | null;
@@ -43,6 +46,7 @@ export interface CreateColonyInput {
colonyType: ColonyType;
hiveType?: string | null;
originText?: string | null;
occupiedFrames?: number | null;
notes?: string | null;
}
@@ -55,6 +59,7 @@ export interface UpdateColonyInput {
originText?: string | null;
colonyType?: ColonyType;
strength?: number | null;
occupiedFrames?: number | null;
broodStatus?: ColonyCondition | null;
feedStatus?: ColonyCondition | null;
healthStatus?: ColonyCondition | null;
@@ -67,10 +72,17 @@ export interface MoveColonyInput {
notes?: string;
}
/** Where the split colony gets its queen from - an observation, never prefilled. */
export type SplitQueenSource = "RAISE_OWN" | "QUEEN_CELL" | "QUEEN";
export interface SplitColonyInput {
code: string;
colonyType: ColonyType;
notes?: string;
/** Day of the split, "YYYY-MM-DD" (not after today). */
date?: string;
/** Without it no follow-up plan is made. */
queenSource?: SplitQueenSource;
}
export interface MergeColonyInput {
+3 -2
View File
@@ -2,6 +2,7 @@ import { apiFetch } from "@/lib/auth/api-client";
import { CreateOptions, idempotencyHeaders } from "@/lib/offline/idempotency";
import {
CreateInspectionInput,
CreatedInspection,
CreateTaskFromInspectionInput,
Inspection,
InspectionTask,
@@ -15,8 +16,8 @@ export function fetchColonyInspections(colonyId: string): Promise<Inspection[]>
export function createInspection(
input: CreateInspectionInput,
options: CreateOptions = {},
): Promise<Inspection> {
return apiFetch<Inspection>("/inspections", {
): Promise<CreatedInspection> {
return apiFetch<CreatedInspection>("/inspections", {
method: "POST",
headers: idempotencyHeaders(options),
body: JSON.stringify(input),
@@ -5,6 +5,7 @@ import {
previousLabel,
previousValueOf,
takeOverAll,
takeoverSkipNotice,
undoTakeOver,
} from "./takeover";
import type { Inspection } from "./types";
@@ -30,7 +31,7 @@ function last(overrides: Partial<Inspection> = {}): Inspection {
swarmMood: 4,
gentleness: 2,
combBehavior: null,
feedStatus: "ausreichend",
feedStatus: "GOOD",
honeySuperStatus: null,
healthIssues: null,
varroaIssues: null,
@@ -57,20 +58,20 @@ describe("takeOverAll", () => {
colonyStrength: "8",
swarmMood: 4,
gentleness: 2,
feedStatus: "ausreichend",
feedStatus: "GOOD",
});
expect(taken).toContain("queenSeen");
expect(taken).toContain("broodFrames");
});
it("never overwrites a typed value", () => {
const typed = { ...blank(), queenSeen: false, broodFrames: "2", feedStatus: "kritisch" };
const typed = { ...blank(), queenSeen: false, broodFrames: "2", feedStatus: "CRITICAL" as const };
const { values, taken } = takeOverAll(typed, last());
expect(values.queenSeen).toBe(false);
expect(values.broodFrames).toBe("2");
expect(values.feedStatus).toBe("kritisch");
expect(values.feedStatus).toBe("CRITICAL");
expect(taken).not.toContain("queenSeen");
expect(taken).not.toContain("broodFrames");
expect(taken).not.toContain("feedStatus");
@@ -87,10 +88,20 @@ describe("takeOverAll", () => {
expect(taken).not.toContain("honeySuperStatus");
});
it("keeps a legacy free-text feedStatus verbatim", () => {
const { values } = takeOverAll(blank(), last({ feedStatus: "noch 2 kg Futterteig" }));
it("reads the legacy feed words of an older offline pack as the enum", () => {
const legacy = last({ feedStatus: "niedrig" as never });
expect(values.feedStatus).toBe("noch 2 kg Futterteig");
expect(previousValueOf(legacy, "feedStatus")).toBe("WEAK");
expect(takeOverAll(blank(), legacy).values.feedStatus).toBe("WEAK");
});
it("offers no feed value for free text - the free-text special case is gone", () => {
const freeText = last({ feedStatus: "noch 2 kg Futterteig" as never });
expect(previousValueOf(freeText, "feedStatus")).toBeNull();
const { values, taken } = takeOverAll(blank(), freeText);
expect(values.feedStatus).toBeNull();
expect(taken).not.toContain("feedStatus");
});
it("does not change the values it was given", () => {
@@ -121,8 +132,9 @@ describe("previousValueOf / previousLabel", () => {
expect(previousLabel("gentleness", 3)).toBe("3 von 5");
expect(previousLabel("colonyStrength", "8")).toBe("8 Waben");
expect(previousLabel("broodFrames", "1")).toBe("1 Wabe");
expect(previousLabel("feedStatus", "ausreichend")).toBe("Ausreichend");
expect(previousLabel("feedStatus", "noch 2 kg Futterteig")).toBe("noch 2 kg Futterteig");
expect(previousLabel("feedStatus", "GOOD")).toBe("Ausreichend");
expect(previousLabel("feedStatus", "AVERAGE")).toBe("Mittel");
expect(previousLabel("feedStatus", "EXCELLENT")).toBe("Reichlich");
});
it("words swarm mood as the buttons show it (mirrored)", () => {
@@ -168,3 +180,39 @@ describe("fields absent from the payload", () => {
expect(takeOverAll(blank(), sparse).values.broodFrames).toBe("");
});
});
describe("takeoverSkipNotice", () => {
it("explains a skipped takeover caused by a newer inspection", () => {
expect(takeoverSkipNotice([{ field: "feedStatus", reason: "NEWER_INSPECTION" }])).toBe(
"Volkskarte nicht übernommen: Es gibt eine neuere Durchsicht.",
);
});
it("explains an ended colony", () => {
expect(takeoverSkipNotice([{ field: "feedStatus", reason: "COLONY_ENDED" }])).toBe(
"Volkskarte nicht übernommen: Das Volk ist beendet.",
);
});
it("stays silent when nothing was skipped or an answer was only empty", () => {
expect(takeoverSkipNotice([])).toBeNull();
expect(takeoverSkipNotice([{ field: "broodStatus", reason: "NO_VALUE" }])).toBeNull();
});
});
describe("takeoverSkipNotice – fallback", () => {
it("gives a generic notice for any other skipped reason", () => {
for (const reason of ["NO_PERMISSION", "OUT_OF_RANGE"] as const) {
expect(takeoverSkipNotice([{ field: "feedStatus", reason }])).toBe("Volkskarte nicht übernommen.");
}
});
it("prefers the specific text and ignores NO_VALUE beside it", () => {
expect(
takeoverSkipNotice([
{ field: "broodStatus", reason: "NO_VALUE" },
{ field: "feedStatus", reason: "NEWER_INSPECTION" },
]),
).toBe("Volkskarte nicht übernommen: Es gibt eine neuere Durchsicht.");
});
});
@@ -1,10 +1,8 @@
import {
FEED_STATUS_OPTIONS,
type InspectionFormValues,
} from "@/app/(app)/inspections/new/inspection-form-values";
import type { InspectionFormValues } from "@/app/(app)/inspections/new/inspection-form-values";
import { FEED_CONDITION_LABELS, feedConditionFromInput } from "@/lib/colonies/condition";
import { toCalendarDay } from "@/lib/dates";
import { toDisplayRating } from "@/lib/forms/rating";
import type { Inspection } from "./types";
import type { ColonyTakeover, Inspection } from "./types";
/** The observations "Letztes Mal" offers for one-tap take-over, in form order. */
export const TAKEOVER_FIELDS = [
@@ -52,8 +50,10 @@ export function previousValueOf(
case "colonyStrength":
return countText(last.colonyStrength);
case "feedStatus":
// An offline pack of the previous version may still hold a legacy word.
return feedConditionFromInput(last.feedStatus);
case "honeySuperStatus":
return textOrNull(last[field]);
return textOrNull(last.honeySuperStatus);
default:
return last[field] ?? null;
}
@@ -86,7 +86,7 @@ export function previousLabel(
case "colonyStrength":
return wabenLabel(String(value));
case "feedStatus":
return FEED_STATUS_OPTIONS.find((option) => option.value === value)?.label ?? String(value);
return FEED_CONDITION_LABELS[value as keyof typeof FEED_CONDITION_LABELS];
case "honeySuperStatus":
return String(value);
default:
@@ -145,3 +145,21 @@ export function undoTakeOver(
current,
);
}
/**
* Why the colony card was left alone, in one sentence for a toast; null when
* there is nothing the beekeeper needs to know (nothing skipped, or only
* because an answer was empty). Reasons without their own text get a generic one.
*/
export function takeoverSkipNotice(skipped: ColonyTakeover["skipped"]): string | null {
if (skipped.some((entry) => entry.reason === "NEWER_INSPECTION")) {
return "Volkskarte nicht übernommen: Es gibt eine neuere Durchsicht.";
}
if (skipped.some((entry) => entry.reason === "COLONY_ENDED")) {
return "Volkskarte nicht übernommen: Das Volk ist beendet.";
}
if (skipped.some((entry) => entry.reason !== "NO_VALUE")) {
return "Volkskarte nicht übernommen.";
}
return null;
}
+33 -3
View File
@@ -1,3 +1,5 @@
import type { ColonyCondition } from "@/lib/colonies/types";
export interface Inspection {
id: string;
colonyId: string;
@@ -17,7 +19,7 @@ export interface Inspection {
swarmMood: number | null;
gentleness: number | null;
combBehavior: number | null;
feedStatus: string | null;
feedStatus: ColonyCondition | null;
honeySuperStatus: string | null;
healthIssues: string | null;
varroaIssues: string | null;
@@ -26,6 +28,28 @@ export interface Inspection {
createdAt: string;
}
/** Colony-card values an inspection can hand over; same names as the backend. */
export const COLONY_TAKEOVER_FIELDS = ["occupiedFrames", "broodStatus", "feedStatus"] as const;
export type ColonyTakeoverField = (typeof COLONY_TAKEOVER_FIELDS)[number];
export type ColonyTakeoverSkipReason =
| "NO_VALUE"
| "NO_PERMISSION"
| "NEWER_INSPECTION"
| "COLONY_ENDED"
| "OUT_OF_RANGE";
/** What the server did with the requested applyToColony fields. */
export interface ColonyTakeover {
applied: ColonyTakeoverField[];
skipped: { field: ColonyTakeoverField; reason: ColonyTakeoverSkipReason }[];
}
/** The answer to a new inspection: the inspection plus what reached the colony card. */
export interface CreatedInspection extends Inspection {
colonyTakeover?: ColonyTakeover;
}
export interface CreateInspectionInput {
colonyId: string;
date: string;
@@ -43,12 +67,18 @@ export interface CreateInspectionInput {
swarmMood?: number | null;
gentleness?: number | null;
combBehavior?: number | null;
feedStatus?: string | null;
feedStatus?: ColonyCondition | null;
honeySuperStatus?: string | null;
healthIssues?: string | null;
varroaIssues?: string | null;
measures?: string | null;
notes?: string | null;
/**
* Colony-card fields to fill from this inspection (creating only; a
* correction never takes anything over). Needs colonies:update on the
* server, which answers 201 either way.
*/
applyToColony?: ColonyTakeoverField[];
}
/** `null` clears an optional field, an omitted one stays unchanged. */
@@ -70,7 +100,7 @@ export interface UpdateInspectionInput {
swarmMood?: number | null;
gentleness?: number | null;
combBehavior?: number | null;
feedStatus?: string | null;
feedStatus?: ColonyCondition | null;
honeySuperStatus?: string | null;
healthIssues?: string | null;
varroaIssues?: string | null;
@@ -7,6 +7,7 @@ import {
foreignEntriesWaiting,
photosAttached,
photosNotTakenOver,
rejectionText,
sessionExpiredWithEntries,
} from "./queue-texts";
@@ -44,3 +45,15 @@ describe("queue texts", () => {
);
});
});
describe("rejectionText", () => {
it("names the field for the too-many-frames rejection of an old entry", () => {
expect(rejectionText("Höchstens 40 besetzte Waben.")).toBe(
"Besetzte Waben: höchstens 40 – bitte im Eintrag korrigieren.",
);
});
it("passes other server reasons through", () => {
expect(rejectionText("Datei zu groß")).toBe("Datei zu groß");
});
});
@@ -30,6 +30,18 @@ export function photosNotTakenOver(n: number): string {
return `${count(n, "Foto wurde", "Fotos wurden")} nicht übernommen und verworfen`;
}
/**
* The server's reason for rejecting a queued entry, in words the beekeeper can
* act on. Old entries may hold more than 40 besetzte Waben; they are never
* clamped silently.
*/
export function rejectionText(message: string): string {
if (/Höchstens 40 besetzte Waben/.test(message)) {
return "Besetzte Waben: höchstens 40 – bitte im Eintrag korrigieren.";
}
return message;
}
/** Sign-out dialog: "3 Einträge sind noch nicht gesendet." */
export function entriesNotSent(n: number): string {
return n === 1 ? "1 Eintrag ist noch nicht gesendet." : `${n} Einträge sind noch nicht gesendet.`;
@@ -135,6 +135,34 @@ describe("syncPendingEntries", () => {
});
});
it("sends an inspection queued by the previous version with its legacy feed word unchanged", async () => {
// The server maps ausreichend/niedrig/kritisch itself; the queue must not rewrite or reject them.
const legacy = { ...inspection(1), feedStatus: "niedrig" } as never;
await db.queueInspection(legacy, "key-legacy-feed-0001", "user-1");
createInspection.mockResolvedValue({ id: "x" } as never);
const result = await sync.syncPendingEntries("user-1");
expect(result).toEqual({ synced: 1, failed: 0, skippedForeign: 0 });
expect(createInspection).toHaveBeenCalledWith(
expect.objectContaining({ feedStatus: "niedrig" }),
{ idempotencyKey: "key-legacy-feed-0001" },
);
});
it("sends the Volkskarte fields of a queued inspection along", async () => {
const queued = { ...inspection(1), feedStatus: "WEAK", applyToColony: ["feedStatus"] } as never;
await db.queueInspection(queued, "key-takeover-0001", "user-1");
createInspection.mockResolvedValue({ id: "x" } as never);
await sync.syncPendingEntries("user-1");
expect(createInspection).toHaveBeenCalledWith(
expect.objectContaining({ feedStatus: "WEAK", applyToColony: ["feedStatus"] }),
{ idempotencyKey: "key-takeover-0001" },
);
});
it("still sends entries queued before keys and owners existed", async () => {
await db.summarizeQueue("user-1"); // creates the database at the current version
const raw = await openDB("apicore-offline");
@@ -13,6 +13,7 @@ function colony(overrides: Partial<Colony>): Colony {
colonyType: "PRODUCTION_COLONY",
status: "ACTIVE",
strength: null,
occupiedFrames: null,
broodStatus: null,
feedStatus: null,
healthStatus: null,