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

@@ -1,9 +1,11 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import { fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ColonyActions } from "./colony-actions";
import { fetchColonies, mergeColony } from "@/lib/colonies/api";
import { fetchColonies, mergeColony, splitColony } from "@/lib/colonies/api";
import { ApiError } from "@/lib/auth/api-client";
import { todayLocalIsoDate } from "@/lib/dates";
import type { Colony, ColonyStatus } from "@/lib/colonies/types";
vi.mock("@/lib/colonies/api", () => ({
@@ -31,6 +33,7 @@ function colony(id: string, code: string, status: ColonyStatus = "ACTIVE"): Colo
colonyType: "PRODUCTION_COLONY",
status,
strength: null,
occupiedFrames: null,
broodStatus: null,
feedStatus: null,
healthStatus: null,
@@ -140,3 +143,106 @@ describe("ColonyActions – colonies that need attention", () => {
expect(options.map((option) => option.textContent)).toEqual(["V-02", "V-03 (Weisellos)"]);
});
});
describe("ColonyActions – split", () => {
beforeEach(() => {
vi.mocked(fetchColonies).mockResolvedValue([target]);
vi.mocked(splitColony).mockReset();
vi.mocked(splitColony).mockResolvedValue(other);
});
async function openSplit() {
renderActions();
await userEvent.click(screen.getByRole("button", { name: "Ableger bilden" }));
return screen.findByRole("dialog");
}
const originGroup = (dialog: HTMLElement) =>
within(dialog).getByRole("radiogroup", { name: /Woher kommt die Königin\?/ });
it("the split dialog sends date and queenSource with nothing preselected", async () => {
const dialog = await openSplit();
const group = originGroup(dialog);
const radios = within(group).getAllByRole("radio");
expect(radios.map((radio) => radio.textContent)).toEqual([
"Eigene Zellen ziehen",
"Weiselzelle",
"Königin",
"Weiß ich noch nicht",
]);
expect(radios.every((radio) => radio.getAttribute("aria-checked") === "false")).toBe(true);
const date = within(dialog).getByLabelText(/Datum des Ablegers/);
expect(date).toHaveValue(todayLocalIsoDate());
expect(date).toHaveAttribute("max", todayLocalIsoDate());
await userEvent.type(within(dialog).getByLabelText(/Code des Ablegers/), "N-01");
fireEvent.change(date, { target: { value: "2026-10-10" } });
await userEvent.click(within(group).getByRole("radio", { name: "Eigene Zellen ziehen" }));
expect(
within(dialog).getByText("Es entsteht ein Plan: Zellen prüfen, Begattung, Stifte."),
).toBeInTheDocument();
await userEvent.click(within(dialog).getByRole("button", { name: "Ableger anlegen" }));
expect(splitColony).toHaveBeenCalledWith("colony-1", {
code: "N-01",
colonyType: "NUCLEUS_COLONY",
date: "2026-10-10",
queenSource: "RAISE_OWN",
});
});
it("states the consequence of each origin", async () => {
const dialog = await openSplit();
const group = originGroup(dialog);
await userEvent.click(within(group).getByRole("radio", { name: "Weiselzelle" }));
expect(within(dialog).getByText("Es entsteht ein Plan: Stifte prüfen.")).toBeInTheDocument();
await userEvent.click(within(group).getByRole("radio", { name: "Königin" }));
expect(
within(dialog).getByText("Es entsteht ein Plan: Annahme der Königin prüfen."),
).toBeInTheDocument();
await userEvent.click(within(group).getByRole("radio", { name: "Weiß ich noch nicht" }));
expect(within(dialog).getByText("Ohne Angabe entsteht kein Plan.")).toBeInTheDocument();
});
it("„Weiß ich noch nicht“ sends no queenSource", async () => {
const dialog = await openSplit();
await userEvent.type(within(dialog).getByLabelText(/Code des Ablegers/), "N-02");
await userEvent.click(
within(originGroup(dialog)).getByRole("radio", { name: "Weiß ich noch nicht" }),
);
await userEvent.click(within(dialog).getByRole("button", { name: "Ableger anlegen" }));
const [, input] = vi.mocked(splitColony).mock.calls[0];
expect(input).toMatchObject({ code: "N-02", date: todayLocalIsoDate() });
expect(input).not.toHaveProperty("queenSource");
});
it("a future date shows the server message at the date field", async () => {
vi.mocked(splitColony).mockRejectedValue(
new ApiError(400, "Das Datum liegt in der Zukunft."),
);
const dialog = await openSplit();
await userEvent.type(within(dialog).getByLabelText(/Code des Ablegers/), "N-03");
await userEvent.click(within(dialog).getByRole("button", { name: "Ableger anlegen" }));
const message = await within(dialog).findByText("Das Datum liegt in der Zukunft.");
const date = within(dialog).getByLabelText(/Datum des Ablegers/);
expect(date).toHaveAttribute("aria-invalid", "true");
expect(date.getAttribute("aria-describedby")).toContain(message.id);
});
it("asks for the date when it was cleared", async () => {
const dialog = await openSplit();
await userEvent.type(within(dialog).getByLabelText(/Code des Ablegers/), "N-04");
fireEvent.change(within(dialog).getByLabelText(/Datum des Ablegers/), { target: { value: "" } });
await userEvent.click(within(dialog).getByRole("button", { name: "Ableger anlegen" }));
expect(within(dialog).getByText("Bitte ein Datum angeben.")).toBeInTheDocument();
expect(splitColony).not.toHaveBeenCalled();
});
});
@@ -10,8 +10,10 @@ import {
reactivateColony,
splitColony,
} from "@/lib/colonies/api";
import { Colony, ColonyType } from "@/lib/colonies/types";
import { COLONY_TYPES, COLONY_TYPE_LABELS } from "@/lib/colonies/labels";
import { Colony, ColonyType, SplitQueenSource } from "@/lib/colonies/types";
import { COLONY_TYPES, COLONY_TYPE_LABELS, SPLIT_QUEEN_SOURCE_LABELS } from "@/lib/colonies/labels";
import { ApiError } from "@/lib/auth/api-client";
import { todayLocalIsoDate } from "@/lib/dates";
import { isLivingColony } from "@/lib/colonies/status";
import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchColonies } from "@/lib/colonies/api";
@@ -21,6 +23,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { Field } from "@/components/form/field";
import { ChoiceGroup, choiceOptions, type ChoiceOption } from "@/components/form/choice-group";
import { LivingColonySelectField } from "@/components/colony-select-field";
import {
Dialog,
@@ -136,24 +139,63 @@ function MoveDialog({ colony }: { colony: Colony }) {
);
}
/** "" = not answered yet; "UNKNOWN" = the beekeeper said they do not know. */
type QueenOriginChoice = SplitQueenSource | "UNKNOWN" | "";
const QUEEN_ORIGIN_OPTIONS: readonly ChoiceOption<QueenOriginChoice>[] = [
...choiceOptions<SplitQueenSource>(SPLIT_QUEEN_SOURCE_LABELS),
{ value: "UNKNOWN", label: "Weiß ich noch nicht" },
];
/** What follows from the choice: the chain of checks the server plans. */
const QUEEN_ORIGIN_CONSEQUENCE: Record<QueenOriginChoice, string> = {
RAISE_OWN: "Es entsteht ein Plan: Zellen prüfen, Begattung, Stifte.",
QUEEN_CELL: "Es entsteht ein Plan: Stifte prüfen.",
QUEEN: "Es entsteht ein Plan: Annahme der Königin prüfen.",
UNKNOWN: "Ohne Angabe entsteht kein Plan.",
"": "Ohne Angabe entsteht kein Plan.",
};
function queenSourceOf(choice: QueenOriginChoice): SplitQueenSource | undefined {
return choice === "" || choice === "UNKNOWN" ? undefined : choice;
}
function SplitDialog({ colony }: { colony: Colony }) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [code, setCode] = useState("");
const [colonyType, setColonyType] = useState<ColonyType>("NUCLEUS_COLONY");
const [date, setDate] = useState(todayLocalIsoDate);
// The origin of the queen is an observation: nothing is preselected.
const [origin, setOrigin] = useState<QueenOriginChoice>("");
const [codeError, setCodeError] = useState<string | null>(null);
const [dateError, setDateError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const mutation = useMutation({
mutationFn: () => splitColony(colony.id, { code, colonyType }),
mutationFn: () => {
const queenSource = queenSourceOf(origin);
return splitColony(colony.id, {
code,
colonyType,
date,
...(queenSource ? { queenSource } : {}),
});
},
onSuccess: () => {
invalidateColony(queryClient, colony.id);
void queryClient.invalidateQueries({ queryKey: ["colonies"] });
toast.success(`Ableger ${code} angelegt`);
setOpen(false);
},
onError: (mutationError) =>
setError(errorMessage(mutationError, "Ableger konnte nicht angelegt werden.")),
onError: (mutationError) => {
// The date's own complaint belongs at the date field, not in the footer.
if (mutationError instanceof ApiError && mutationError.status === 400 && /Datum/.test(mutationError.message)) {
setDateError(mutationError.message);
return;
}
setError(errorMessage(mutationError, "Ableger konnte nicht angelegt werden."));
},
});
function handleSubmit(event: FormEvent) {
@@ -163,6 +205,10 @@ function SplitDialog({ colony }: { colony: Colony }) {
setCodeError("Bitte einen Code für den Ableger angeben.");
return;
}
if (!date) {
setDateError("Bitte ein Datum angeben.");
return;
}
mutation.mutate();
}
@@ -210,6 +256,41 @@ function SplitDialog({ colony }: { colony: Colony }) {
/>
)}
</Field>
<Field label="Datum des Ablegers" required error={dateError}>
{({ id, describedBy, invalid }) => (
<Input
id={id}
type="date"
required
max={todayLocalIsoDate()}
className="h-11"
aria-describedby={describedBy}
aria-invalid={invalid}
value={date}
onChange={(event) => {
setDate(event.target.value);
setDateError(null);
}}
/>
)}
</Field>
<Field label="Woher kommt die Königin?" group>
{({ id, labelId }) => (
<>
<ChoiceGroup
id={id}
labelledBy={labelId}
options={QUEEN_ORIGIN_OPTIONS}
value={origin}
onChange={setOrigin}
className="grid-cols-1 sm:grid-cols-2"
/>
<p role="status" aria-live="polite" className="text-sm text-muted-foreground">
{QUEEN_ORIGIN_CONSEQUENCE[origin]}
</p>
</>
)}
</Field>
<ActionFooter error={error}>
<Button type="submit" size="touch" disabled={mutation.isPending}>
{mutation.isPending ? "Wird angelegt…" : "Ableger anlegen"}
@@ -21,6 +21,7 @@ const colony: Colony = {
colonyType: "PRODUCTION_COLONY",
status: "ACTIVE",
strength: 7,
occupiedFrames: 8,
broodStatus: "GOOD",
feedStatus: "AVERAGE",
healthStatus: "CRITICAL",
@@ -31,11 +32,11 @@ const colony: Colony = {
deactivatedAt: null,
};
function renderCard() {
function renderCard(subject: Colony = colony) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<ColonyEditCard colony={colony} />
<ColonyEditCard colony={subject} />
</QueryClientProvider>,
);
}
@@ -85,7 +86,7 @@ describe("ColonyEditCard", () => {
renderCard();
expect(screen.getByRole("combobox", { name: "Brutstatus" })).toHaveTextContent("Gut");
expect(screen.getByRole("combobox", { name: "Volksstärke" })).toHaveTextContent("7");
expect(screen.getByRole("combobox", { name: "Volksstärke (alt, Skala 1–10)" })).toHaveTextContent("7");
await choose(/^Status/, "Weisellos");
await choose(/^Volktyp/, "Ableger");
await choose(/Brutstatus/, "Kritisch");
@@ -146,4 +147,85 @@ describe("ColonyEditCard", () => {
notes: "sanft",
});
});
it("„Besetzte Waben“ accepts 0–40 and sends the number", async () => {
renderCard();
const field = screen.getByLabelText(/^Besetzte Waben/);
expect(field).toHaveValue("8");
await userEvent.clear(field);
await userEvent.type(field, "0");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(vi.mocked(updateColony).mock.calls[0][1]).toMatchObject({ occupiedFrames: 0 });
});
it("caps „Besetzte Waben“ at 40", async () => {
renderCard();
const field = screen.getByLabelText(/^Besetzte Waben/);
await userEvent.clear(field);
await userEvent.type(field, "99");
expect(field).toHaveValue("40");
});
it("clearing „Besetzte Waben“ sends null", async () => {
renderCard();
await userEvent.clear(screen.getByLabelText(/^Besetzte Waben/));
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(vi.mocked(updateColony).mock.calls[0][1]).toMatchObject({ occupiedFrames: null });
});
it("shows the legacy strength field only when a value is stored", () => {
renderCard({ ...colony, strength: null });
expect(
screen.queryByRole("combobox", { name: "Volksstärke (alt, Skala 1–10)" }),
).not.toBeInTheDocument();
});
it("does not touch an unset legacy strength when saving", async () => {
renderCard({ ...colony, strength: null });
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(vi.mocked(updateColony).mock.calls[0][1]).not.toHaveProperty("strength");
});
it("words the feed status like the inspection form", async () => {
renderCard();
await userEvent.click(screen.getByRole("combobox", { name: "Futterstatus" }));
for (const name of ["Nicht erfasst", "Reichlich", "Ausreichend", "Mittel", "Niedrig", "Kritisch"]) {
expect(await screen.findByRole("option", { name })).toBeInTheDocument();
}
expect(screen.queryByRole("option", { name: "Sehr gut" })).not.toBeInTheDocument();
expect(screen.queryByRole("option", { name: "Gut" })).not.toBeInTheDocument();
});
});
describe("ColonyEditCard – legacy strength after clearing", () => {
it("does not resend strength once it was cleared and the card shows the saved colony", async () => {
vi.mocked(updateColony).mockReset().mockResolvedValue(colony);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const ui = (subject: Colony) => (
<QueryClientProvider client={client}>
<ColonyEditCard colony={subject} />
</QueryClientProvider>
);
const { rerender } = render(ui(colony));
await userEvent.click(screen.getByRole("combobox", { name: /Volksstärke/ }));
await userEvent.click(await screen.findByRole("option", { name: "Nicht erfasst" }));
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(vi.mocked(updateColony).mock.calls[0][1]).toMatchObject({ strength: null });
rerender(ui({ ...colony, strength: null }));
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(vi.mocked(updateColony).mock.calls[1][1]).not.toHaveProperty("strength");
});
});
@@ -6,6 +6,7 @@ import { toast } from "sonner";
import { updateColony } from "@/lib/colonies/api";
import { fetchApiaries } from "@/lib/apiaries/api";
import { Colony, ColonyCondition, ColonyStatus, ColonyType } from "@/lib/colonies/types";
import { FEED_CONDITION_LABELS } from "@/lib/colonies/condition";
import {
COLONY_CONDITIONS,
COLONY_CONDITION_LABELS,
@@ -15,6 +16,7 @@ import {
COLONY_STRENGTH_LABELS,
COLONY_TYPES,
COLONY_TYPE_LABELS,
MAX_OCCUPIED_FRAMES,
} from "@/lib/colonies/labels";
import { errorMessage } from "@/lib/forms/error-message";
import { emptyToNull } from "@/lib/forms/empty-to-null";
@@ -25,6 +27,7 @@ import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { RecordSelect } from "@/components/form/record-select";
import { EnumSelect } from "@/components/form/enum-select";
import { NumberStepper } from "@/components/form/number-stepper";
import {
Card,
CardContent,
@@ -40,25 +43,28 @@ function ConditionSelect({
label,
value,
onChange,
labels = COLONY_CONDITION_LABELS,
}: {
id: string;
label: string;
value: ColonyCondition | null;
onChange: (value: ColonyCondition | null) => void;
/** How the five levels are worded; the feed scale has its own words. */
labels?: Record<ColonyCondition, string>;
}) {
return (
<div className="flex flex-col gap-2">
<Label htmlFor={id}>{label}</Label>
<EnumSelect
id={id}
labels={COLONY_CONDITION_LABELS}
labels={labels}
values={COLONY_CONDITIONS}
noneLabel={NOT_RECORDED_LABEL}
value={value ?? ""}
onChange={(next) => onChange(next || null)}
renderOption={(condition) => (
<Badge className={COLONY_CONDITION_TONE_CLASS[condition]}>
{COLONY_CONDITION_LABELS[condition]}
{labels[condition]}
</Badge>
)}
/>
@@ -74,6 +80,7 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
const [colonyType, setColonyType] = useState<ColonyType>(colony.colonyType);
const [hiveType, setHiveType] = useState(colony.hiveType ?? "");
const [originText, setOriginText] = useState(colony.originText ?? "");
const [occupiedFrames, setOccupiedFrames] = useState(colony.occupiedFrames?.toString() ?? "");
const [strength, setStrength] = useState(colony.strength?.toString() ?? "");
const [broodStatus, setBroodStatus] = useState<ColonyCondition | null>(colony.broodStatus);
const [feedStatus, setFeedStatus] = useState<ColonyCondition | null>(colony.feedStatus);
@@ -97,7 +104,9 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
// Emptied fields go out as null - omitted, the old value would stay.
hiveType: emptyToNull(hiveType),
originText: emptyToNull(originText),
strength: emptyToNull(strength, Number),
occupiedFrames: emptyToNull(occupiedFrames, Number),
// The old 1-10 scale is only sent while a value is stored (to clear it).
...(colony.strength !== null ? { strength: emptyToNull(strength, Number) } : {}),
broodStatus,
feedStatus,
healthStatus,
@@ -187,15 +196,29 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="colony-strength">Volksstärke</Label>
<EnumSelect
id="colony-strength"
labels={COLONY_STRENGTH_LABELS}
noneLabel={NOT_RECORDED_LABEL}
value={strength}
onChange={setStrength}
<Label htmlFor="colony-occupied-frames">Besetzte Waben</Label>
<NumberStepper
id="colony-occupied-frames"
value={occupiedFrames}
onChange={setOccupiedFrames}
max={MAX_OCCUPIED_FRAMES}
unitLabel="besetzte Waben"
decrementLabel="Eine besetzte Wabe weniger"
incrementLabel="Eine besetzte Wabe mehr"
/>
</div>
{colony.strength !== null ? (
<div className="flex flex-col gap-2">
<Label htmlFor="colony-strength">Volksstärke (alt, Skala 1–10)</Label>
<EnumSelect
id="colony-strength"
labels={COLONY_STRENGTH_LABELS}
noneLabel={NOT_RECORDED_LABEL}
value={strength}
onChange={setStrength}
/>
</div>
) : null}
<ConditionSelect
id="colony-brood"
label="Brutstatus"
@@ -207,6 +230,7 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
label="Futterstatus"
value={feedStatus}
onChange={setFeedStatus}
labels={FEED_CONDITION_LABELS}
/>
<ConditionSelect
id="colony-health"
@@ -38,7 +38,7 @@ const inspection: Inspection = {
swarmMood: null,
gentleness: 2,
combBehavior: null,
feedStatus: "ausreichend",
feedStatus: "GOOD",
honeySuperStatus: null,
healthIssues: null,
varroaIssues: null,
@@ -244,4 +244,69 @@ describe("InspectionEditDialog", () => {
expect(screen.queryByLabelText(/Brutmenge/)).not.toBeInTheDocument();
});
it("the edit dialog shows a stored AVERAGE feed value", async () => {
vi.mocked(updateInspection).mockResolvedValue(inspection);
await openDialog({ ...inspection, feedStatus: "AVERAGE" });
const group = screen.getByRole("radiogroup", { name: /^Futter/ });
expect(
within(group)
.getAllByRole("radio")
.find((radio) => radio.getAttribute("aria-checked") === "true")?.textContent,
).toBe("Mittel");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
const [, input] = vi.mocked(updateInspection).mock.calls[0];
expect(input).toMatchObject({ feedStatus: "AVERAGE" });
});
it("sends the feed status as the enum and never sends applyToColony on a correction", async () => {
vi.mocked(updateInspection).mockResolvedValue(inspection);
await openDialog();
await userEvent.click(
within(screen.getByRole("radiogroup", { name: /^Futter/ })).getByRole("radio", {
name: "Niedrig",
}),
);
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
const [, input] = vi.mocked(updateInspection).mock.calls[0];
expect(input).toMatchObject({ feedStatus: "WEAK" });
expect(input).not.toHaveProperty("applyToColony");
});
it("shows a hint for an unmappable stored feed text and does not overwrite it unless changed", async () => {
vi.mocked(updateInspection).mockResolvedValue(inspection);
await openDialog({ ...inspection, feedStatus: "halb voll" as never });
expect(
screen.getByText("Alter Futterwert „halb voll“ nicht übernommen – bitte neu wählen."),
).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(vi.mocked(updateInspection).mock.calls[0][1]).not.toHaveProperty("feedStatus");
});
it("sends the new choice and drops the hint once the unmappable feed text is replaced", async () => {
vi.mocked(updateInspection).mockResolvedValue(inspection);
await openDialog({ ...inspection, feedStatus: "halb voll" as never });
await userEvent.click(
within(screen.getByRole("radiogroup", { name: /^Futter/ })).getByRole("radio", { name: "Niedrig" }),
);
expect(screen.queryByText(/Alter Futterwert/)).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(vi.mocked(updateInspection).mock.calls[0][1]).toMatchObject({ feedStatus: "WEAK" });
});
it("caps „Volksstärke“ at 40 besetzte Waben", async () => {
await openDialog();
const field = screen.getByLabelText(/^Volksstärke/);
await userEvent.clear(field);
await userEvent.type(field, "77");
expect(field).toHaveValue("40");
});
});
@@ -4,6 +4,8 @@ import { FormEvent, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { updateInspection } from "@/lib/inspections/api";
import { Inspection } from "@/lib/inspections/types";
import { feedConditionFromInput } from "@/lib/colonies/condition";
import { MAX_OCCUPIED_FRAMES } from "@/lib/colonies/labels";
import type { RatingDirection } from "@/lib/forms/rating";
import { errorMessage } from "@/lib/forms/error-message";
import { Input } from "@/components/ui/input";
@@ -24,9 +26,11 @@ import {
DialogTrigger,
} from "@/components/ui/dialog";
import {
FEED_STATUS_OPTIONS,
INSPECTION_OBSERVATIONS,
InspectionFormValues,
feedFieldFrom,
feedLegacyHint,
feedStatusOptions,
toInspectionInput,
} from "../../inspections/new/inspection-form-values";
import {
@@ -55,14 +59,17 @@ function valuesFromInspection(inspection: Inspection): InspectionFormValues {
swarmMood: inspection.swarmMood,
gentleness: inspection.gentleness,
combBehavior: inspection.combBehavior,
// Kept verbatim even if it is not one of the three known values, so
// saving without touching it never changes it.
feedStatus: inspection.feedStatus,
// Any stored level is kept (the colony scale has five), so saving
// without touching it never changes it.
// An unmappable stored text stays untouched (and is hinted at) until changed.
...feedFieldFrom(inspection.feedStatus),
honeySuperStatus: inspection.honeySuperStatus ?? "",
healthIssues: inspection.healthIssues ?? "",
varroaIssues: inspection.varroaIssues ?? "",
measures: inspection.measures ?? "",
notes: inspection.notes ?? "",
// A correction never takes anything over onto the colony card.
applyToColony: [],
weatherAuto: false,
// A saved inspection takes its photos straight to the server (Fotos below).
photoSessionId: "",
@@ -96,12 +103,18 @@ export function InspectionEditDialog({ inspection }: { inspection: Inspection })
);
const [dateError, setDateError] = useState<string | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null);
// Offered from the stored value, so a level outside the three stays visible.
const [feedOptions] = useState(() => feedStatusOptions(feedConditionFromInput(inspection.feedStatus)));
function set<K extends keyof InspectionFormValues>(
key: K,
value: InspectionFormValues[K],
) {
setValues((current) => ({ ...current, [key]: value }));
setValues((current) => ({
...current,
[key]: value,
...(key === "feedStatus" ? { feedLegacyText: null } : {}),
}));
if (key === "date") setDateError(null);
}
@@ -240,6 +253,7 @@ export function InspectionEditDialog({ inspection }: { inspection: Inspection })
id={id}
value={values.colonyStrength}
onChange={(count) => set("colonyStrength", count)}
max={MAX_OCCUPIED_FRAMES}
unitLabel="besetzte Waben"
decrementLabel="Eine besetzte Wabe weniger"
incrementLabel="Eine besetzte Wabe mehr"
@@ -267,14 +281,21 @@ export function InspectionEditDialog({ inspection }: { inspection: Inspection })
<FormSection step={4} title="Futter">
<Field label="Futterstatus" group>
{({ id, labelId }) => (
<ChoiceGroup
id={id}
labelledBy={labelId}
options={FEED_STATUS_OPTIONS}
value={values.feedStatus}
onChange={(feedStatus) => set("feedStatus", feedStatus)}
className="grid-cols-2 sm:grid-cols-4"
/>
<>
<ChoiceGroup
id={id}
labelledBy={labelId}
options={feedOptions}
value={values.feedStatus}
onChange={(feedStatus) => set("feedStatus", feedStatus)}
className="grid-cols-2 sm:grid-cols-4"
/>
{values.feedLegacyText !== null ? (
<p role="status" className="text-sm">
{feedLegacyHint(values.feedLegacyText)}
</p>
) : null}
</>
)}
</Field>
</FormSection>
@@ -4,6 +4,7 @@ import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import ColonyDetailPage from "./page";
import { fetchColony } from "@/lib/colonies/api";
import type { Colony } from "@/lib/colonies/types";
vi.mock("next/navigation", () => ({
useParams: () => ({ id: "colony-1" }),
@@ -14,6 +15,16 @@ vi.mock("@/lib/export/api", () => ({
exportColonyRecordCardPdf: vi.fn(),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock("./colony-edit-card", () => ({ ColonyEditCard: () => null }));
vi.mock("./colony-actions", () => ({ ColonyActions: () => null }));
vi.mock("./colony-history", () => ({ ColonyHistoryCard: () => null }));
vi.mock("./colony-inspections", () => ({ ColonyInspectionsCard: () => null }));
vi.mock("./colony-varroa", () => ({ ColonyVarroaCard: () => null }));
vi.mock("./colony-swarm-control", () => ({ ColonySwarmControlCard: () => null }));
vi.mock("./colony-treatments", () => ({ ColonyTreatmentsCard: () => null }));
vi.mock("./colony-feedings", () => ({ ColonyFeedingsCard: () => null }));
vi.mock("@/components/document-upload-card", () => ({ DocumentUploadCard: () => null }));
vi.mock("@/components/qr-code-dialog", () => ({ QrCodeDialog: () => null }));
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
@@ -48,3 +59,41 @@ describe("ColonyDetailPage – failed load", () => {
expect(fetchColony).toHaveBeenCalledTimes(2);
});
});
describe("ColonyDetailPage – strength", () => {
const colony = {
id: "colony-1",
code: "V-01",
colonyType: "PRODUCTION_COLONY",
status: "ACTIVE",
strength: null,
occupiedFrames: null,
} as Colony;
beforeEach(() => {
vi.mocked(fetchColony).mockReset();
});
it("shows „{n} besetzte Waben“", async () => {
vi.mocked(fetchColony).mockResolvedValue({ ...colony, occupiedFrames: 8, strength: 7 });
renderPage();
expect(await screen.findByText("8 besetzte Waben")).toBeInTheDocument();
expect(screen.queryByText(/Volksstärke \(alt\)/)).not.toBeInTheDocument();
});
it("the colony page shows „Volksstärke (alt): 7 von 10“ without occupiedFrames", async () => {
vi.mocked(fetchColony).mockResolvedValue({ ...colony, strength: 7 });
renderPage();
expect(await screen.findByText("Volksstärke (alt): 7 von 10")).toBeInTheDocument();
});
it("shows no strength line when none is stored", async () => {
vi.mocked(fetchColony).mockResolvedValue(colony);
renderPage();
expect(await screen.findByRole("heading", { name: "V-01" })).toBeInTheDocument();
expect(screen.queryByText(/besetzte Waben|Volksstärke/)).not.toBeInTheDocument();
});
});
@@ -6,7 +6,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import { ClipboardCheck, FileText, Layers } from "lucide-react";
import { toast } from "sonner";
import { fetchColony } from "@/lib/colonies/api";
import { COLONY_STATUS_LABELS, COLONY_TYPE_LABELS } from "@/lib/colonies/labels";
import { COLONY_STATUS_LABELS, COLONY_TYPE_LABELS, colonyStrengthText } from "@/lib/colonies/labels";
import { exportColonyAnnualReportPdf, exportColonyRecordCardPdf } from "@/lib/export/api";
import { QrCodeDialog } from "@/components/qr-code-dialog";
import { DocumentUploadCard } from "@/components/document-upload-card";
@@ -46,6 +46,8 @@ export default function ColonyDetailPage() {
return <DetailQueryFallback query={colonyQuery} what="Volksdaten" {...BACK} />;
}
const strengthText = colonyStrengthText(colony);
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<DetailHeader
@@ -81,6 +83,7 @@ export default function ColonyDetailPage() {
]}
{...BACK}
/>
{strengthText ? <p className="text-sm text-muted-foreground">{strengthText}</p> : null}
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-3">
<div className="flex flex-col gap-4 lg:col-span-1">
<ColonyEditCard key={colony.id} colony={colony} />
@@ -8,6 +8,8 @@ export interface ColonyFormValues {
colonyType: ColonyType;
hiveType: string;
originText: string;
/** Occupied frames as typed; "" = not counted. */
occupiedFrames: string;
}
export type ColonyFormErrors = Partial<Record<keyof ColonyFormValues, string>>;
@@ -19,6 +21,7 @@ export function initialColonyValues(): ColonyFormValues {
colonyType: "PRODUCTION_COLONY",
hiveType: "",
originText: "",
occupiedFrames: "",
};
}
@@ -38,5 +41,6 @@ export function toColonyInput(values: ColonyFormValues): CreateColonyInput {
// Not filled in = null, the same convention as every edit form.
hiveType: emptyToNull(values.hiveType),
originText: emptyToNull(values.originText),
occupiedFrames: emptyToNull(values.occupiedFrames, Number),
};
}
@@ -60,6 +60,7 @@ describe("NewColonyPage", () => {
// Not filled in = null, the same convention as every edit form.
hiveType: null,
originText: null,
occupiedFrames: null,
});
expect(push).toHaveBeenCalledWith("/colonies/colony-9");
});
@@ -76,3 +77,39 @@ describe("NewColonyPage", () => {
expect(createColony).not.toHaveBeenCalled();
});
});
describe("NewColonyPage – besetzte Waben", () => {
beforeEach(() => {
window.localStorage.clear();
push.mockReset();
vi.mocked(fetchApiaries).mockResolvedValue([
{ id: "apiary-1", name: "Hausgarten", status: "ACTIVE" },
] as never);
vi.mocked(createColony).mockReset();
vi.mocked(createColony).mockResolvedValue({ id: "colony-9" } as never);
});
it("sends only occupiedFrames, no old strength", async () => {
renderPage();
await userEvent.type(screen.getByLabelText(/Code/), "V-08");
await userEvent.click(screen.getByRole("combobox", { name: /Bienenstand/ }));
await userEvent.click(await screen.findByRole("option", { name: "Hausgarten" }));
await userEvent.click(screen.getByRole("button", { name: /Weitere Angaben/ }));
await userEvent.type(screen.getByLabelText(/^Besetzte Waben/), "6");
await userEvent.click(screen.getByRole("button", { name: "Volk anlegen" }));
const [input] = vi.mocked(createColony).mock.calls[0];
expect(input).toMatchObject({ occupiedFrames: 6 });
expect(input).not.toHaveProperty("strength");
});
it("caps the count at 40", async () => {
renderPage();
await userEvent.click(screen.getByRole("button", { name: /Weitere Angaben/ }));
const field = screen.getByLabelText(/^Besetzte Waben/);
await userEvent.type(field, "63");
expect(field).toHaveValue("40");
});
});
@@ -6,7 +6,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Layers } from "lucide-react";
import { createColony } from "@/lib/colonies/api";
import { COLONY_TYPES, COLONY_TYPE_LABELS } from "@/lib/colonies/labels";
import { COLONY_TYPES, COLONY_TYPE_LABELS, MAX_OCCUPIED_FRAMES } from "@/lib/colonies/labels";
import { fetchApiaries } from "@/lib/apiaries/api";
import { useFormDraft } from "@/lib/forms/use-form-draft";
import { errorMessage } from "@/lib/forms/error-message";
@@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { EnumSelect } from "@/components/form/enum-select";
import { RecordSelect } from "@/components/form/record-select";
import { NumberStepper } from "@/components/form/number-stepper";
import { FormBackLink } from "@/components/form/form-back-link";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
@@ -85,7 +86,8 @@ export default function NewColonyPage() {
function restoreDraft() {
const restored = draft.restore();
if (restored) {
setValues(restored);
// A draft from before „Besetzte Waben“ existed lacks it.
setValues({ ...initialColonyValues(), ...restored });
setDirty(true);
}
}
@@ -183,7 +185,7 @@ export default function NewColonyPage() {
title="Weitere Angaben"
collapsible
defaultOpen={false}
fieldCount={2}
fieldCount={3}
>
<Field label="Beutentyp" hint="z. B. Zander, Dadant">
{({ id, describedBy }) => (
@@ -195,6 +197,20 @@ export default function NewColonyPage() {
/>
)}
</Field>
<Field label="Besetzte Waben" hint="Wie viele Waben das Volk besetzt">
{({ id, describedBy }) => (
<NumberStepper
id={id}
describedBy={describedBy}
value={values.occupiedFrames}
onChange={(count) => set("occupiedFrames", count)}
max={MAX_OCCUPIED_FRAMES}
unitLabel="besetzte Waben"
decrementLabel="Eine besetzte Wabe weniger"
incrementLabel="Eine besetzte Wabe mehr"
/>
)}
</Field>
<Field label="Herkunft" hint="z. B. Ableger von V-03, Schwarm">
{({ id, describedBy }) => (
<Input
@@ -30,6 +30,7 @@ describe("ColoniesPage", () => {
colonyType: "PRODUCTION_COLONY",
status: "ACTIVE",
strength: null,
occupiedFrames: null,
broodStatus: null,
feedStatus: null,
healthStatus: null,
@@ -7,7 +7,12 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { fetchColonies } from "@/lib/colonies/api";
import { Colony, ColonyStatus } from "@/lib/colonies/types";
import { COLONY_CONDITION_LABELS, COLONY_STATUS_LABELS, COLONY_TYPE_LABELS } from "@/lib/colonies/labels";
import {
COLONY_CONDITION_LABELS,
COLONY_STATUS_LABELS,
COLONY_TYPE_LABELS,
colonyStrengthText,
} from "@/lib/colonies/labels";
import { ENDED_COLONY_STATUSES, LIVING_COLONY_STATUSES } from "@/lib/colonies/status";
import { exportColoniesCsv } from "@/lib/export/api";
import { statusTone } from "@/lib/status-tone";
@@ -175,7 +180,7 @@ function ColonyGrid({ colonies }: { colonies: Colony[] }) {
<Badge variant={statusTone(colony.status)}>{COLONY_STATUS_LABELS[colony.status]}</Badge>
}
fields={[
{ label: "Stärke", value: colony.strength ?? "—" },
{ label: "Stärke", value: colonyStrengthText(colony) ?? "—" },
{
label: "Gesundheit",
value: colony.healthStatus ? COLONY_CONDITION_LABELS[colony.healthStatus] : "—",
@@ -1,5 +1,14 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { initialInspectionValues, toInspectionInput } from "./inspection-form-values";
import {
currentDraftValues,
feedLegacyHint,
feedStatusOptions,
initialInspectionValues,
syncTakeoverSelection,
takeoverFieldsFilled,
toCreateInspectionInput,
toInspectionInput,
} from "./inspection-form-values";
const OBSERVATIONS = [
"queenSeen",
@@ -38,14 +47,14 @@ describe("inspection form values", () => {
queenSeen: false,
eggsPresent: false,
queenCellsPresent: true,
feedStatus: "kritisch",
feedStatus: "CRITICAL",
});
expect(input).toMatchObject({
queenSeen: false,
eggsPresent: false,
queenCellsPresent: true,
feedStatus: "kritisch",
feedStatus: "CRITICAL",
});
});
@@ -134,4 +143,141 @@ describe("inspection form values", () => {
process.env.TZ = originalTz;
}
});
it("sends the feed status as the enum, also for a legacy word left in a draft", () => {
const base = initialInspectionValues("colony-1");
expect(toInspectionInput({ ...base, feedStatus: "WEAK" }).feedStatus).toBe("WEAK");
expect(
toInspectionInput({ ...base, feedStatus: "niedrig" as never }).feedStatus,
).toBe("WEAK");
});
it("never sends applyToColony on a correction", () => {
const values = { ...initialInspectionValues("colony-1"), applyToColony: ["feedStatus" as const] };
expect(toInspectionInput(values)).not.toHaveProperty("applyToColony");
});
});
describe("Volkskarte selection", () => {
const blank = () => initialInspectionValues("colony-1");
it("starts empty and lists exactly the fields filled in", () => {
expect(blank().applyToColony).toEqual([]);
expect(takeoverFieldsFilled(blank())).toEqual([]);
expect(
takeoverFieldsFilled({ ...blank(), colonyStrength: "0", broodPattern: 2, feedStatus: "WEAK" }),
).toEqual(["occupiedFrames", "broodStatus", "feedStatus"]);
});
it("checks a field when it gets filled and unchecks it when it is emptied", () => {
const filled = syncTakeoverSelection(blank(), { ...blank(), feedStatus: "WEAK" });
expect(filled.applyToColony).toEqual(["feedStatus"]);
const emptied = syncTakeoverSelection(filled, { ...filled, feedStatus: null });
expect(emptied.applyToColony).toEqual([]);
});
it("keeps the order of the fields and leaves a manual uncheck alone", () => {
const before = { ...blank(), feedStatus: "WEAK" as const, applyToColony: [] };
const after = syncTakeoverSelection(before, { ...before, colonyStrength: "9" });
expect(after.applyToColony).toEqual(["occupiedFrames"]);
expect(syncTakeoverSelection(after, { ...after, notes: "x" }).applyToColony).toEqual([
"occupiedFrames",
]);
});
it("builds the create input with the checked fields that are filled in", () => {
const values = {
...blank(),
colonyStrength: "9",
feedStatus: "WEAK" as const,
applyToColony: ["occupiedFrames" as const, "feedStatus" as const, "broodStatus" as const],
};
expect(toCreateInspectionInput(values, true).applyToColony).toEqual([
"occupiedFrames",
"feedStatus",
]);
expect(toCreateInspectionInput(values, false)).not.toHaveProperty("applyToColony");
expect(
toCreateInspectionInput({ ...values, applyToColony: [] }, true),
).not.toHaveProperty("applyToColony");
});
});
describe("feedStatusOptions", () => {
const labels = (stored: Parameters<typeof feedStatusOptions>[0]) =>
feedStatusOptions(stored).map((option) => option.label);
it("offers three levels plus „Nicht geprüft“", () => {
expect(labels(null)).toEqual(["Ausreichend", "Niedrig", "Kritisch", "Nicht geprüft"]);
});
it("also shows a stored value outside the three", () => {
expect(labels("AVERAGE")).toEqual([
"Ausreichend",
"Niedrig",
"Kritisch",
"Mittel",
"Nicht geprüft",
]);
expect(labels("EXCELLENT")).toContain("Reichlich");
});
});
describe("Volkskarte selection – never ticked by copied values", () => {
const blank = () => initialInspectionValues("colony-1");
it("does not tick a field that was filled by copying the previous inspection", () => {
const copied = syncTakeoverSelection(blank(), { ...blank(), feedStatus: "WEAK" }, { autoTick: false });
expect(copied.applyToColony).toEqual([]);
});
it("still unticks a field that is emptied again (undo)", () => {
const ticked = { ...blank(), feedStatus: "WEAK" as const, applyToColony: ["feedStatus" as const] };
expect(
syncTakeoverSelection(ticked, { ...ticked, feedStatus: null }, { autoTick: false }).applyToColony,
).toEqual([]);
});
it("ticks a touched field even if it was filled before", () => {
const before = { ...blank(), colonyStrength: "8" };
const after = syncTakeoverSelection(before, { ...before, colonyStrength: "9" }, { touched: "occupiedFrames" });
expect(after.applyToColony).toEqual(["occupiedFrames"]);
});
});
describe("unmappable legacy feed text", () => {
it("leaves the field empty, keeps the text for a hint and does not send null", () => {
const values = currentDraftValues({ colonyId: "colony-1", feedStatus: "halb voll" as never });
expect(values.feedStatus).toBeNull();
expect(values.feedLegacyText).toBe("halb voll");
expect(toInspectionInput(values)).not.toHaveProperty("feedStatus");
expect(feedLegacyHint("halb voll")).toBe(
"Alter Futterwert „halb voll“ nicht übernommen – bitte neu wählen.",
);
});
it("sends the new choice once the user picks one, and null when they pick „Nicht geprüft“", () => {
const values = currentDraftValues({ colonyId: "colony-1", feedStatus: "halb voll" as never });
expect(toInspectionInput({ ...values, feedStatus: "WEAK", feedLegacyText: null }).feedStatus).toBe("WEAK");
expect(toInspectionInput({ ...values, feedLegacyText: null })).toHaveProperty("feedStatus", null);
});
it("a legacy draft without selection ticks nothing by itself", () => {
const values = currentDraftValues({ colonyId: "colony-1", feedStatus: "niedrig" as never, colonyStrength: "8" });
expect(values.feedStatus).toBe("WEAK");
expect(values.applyToColony).toEqual([]);
});
});
@@ -1,18 +1,59 @@
import type { ChoiceOption } from "@/components/form/choice-group";
import type { createInspection } from "@/lib/inspections/api";
import {
FEED_CONDITION_LABELS,
INSPECTION_FEED_OPTIONS,
feedConditionFromInput,
ratingToCondition,
} from "@/lib/colonies/condition";
import type { ColonyCondition } from "@/lib/colonies/types";
import { toApiDate, todayLocalIsoDate } from "@/lib/dates";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import {
COLONY_TAKEOVER_FIELDS,
type ColonyTakeoverField,
type CreateInspectionInput,
} from "@/lib/inspections/types";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
export type FeedStatus = "ausreichend" | "niedrig" | "kritisch";
const NOT_CHECKED: ChoiceOption<ColonyCondition | null> = { value: null, label: "Nicht geprüft" };
/** The three feed levels plus "Nicht geprüft" (null), which is the default. */
export const FEED_STATUS_OPTIONS: readonly ChoiceOption<FeedStatus | null>[] = [
{ value: "ausreichend", label: "Ausreichend" },
{ value: "niedrig", label: "Niedrig" },
{ value: "kritisch", label: "Kritisch" },
{ value: null, label: "Nicht geprüft" },
];
/**
* The three feed levels plus "Nicht geprüft" (null), which is the default.
* A stored value outside the three (the colony scale has five) is shown too,
* so an edit never hides what an inspection recorded.
*/
export function feedStatusOptions(
stored: ColonyCondition | null = null,
): readonly ChoiceOption<ColonyCondition | null>[] {
const levels: ColonyCondition[] = [...INSPECTION_FEED_OPTIONS];
if (stored !== null && !levels.includes(stored)) {
levels.push(stored);
}
return [
...levels.map((value) => ({ value, label: FEED_CONDITION_LABELS[value] })),
NOT_CHECKED,
];
}
export const FEED_STATUS_OPTIONS = feedStatusOptions();
/**
* A feed answer from an older app version, split into what can be used and
* what cannot: a legacy word becomes the enum, free text that maps to nothing
* is kept aside so the form can say so instead of dropping it silently.
*/
export function feedFieldFrom(raw: string | null | undefined): {
feedStatus: ColonyCondition | null;
feedLegacyText: string | null;
} {
const feedStatus = feedConditionFromInput(raw);
const text = raw?.trim() ?? "";
return { feedStatus, feedLegacyText: feedStatus === null && text !== "" ? text : null };
}
export function feedLegacyHint(text: string): string {
return `Alter Futterwert „${text}“ nicht übernommen – bitte neu wählen.`;
}
/** The yes/no observations of an inspection, in the order they are asked. */
export const INSPECTION_OBSERVATIONS = [
@@ -47,17 +88,26 @@ export interface InspectionFormValues {
swarmMood: number | null;
gentleness: number | null;
combBehavior: number | null;
/** A feed level, or null for "Nicht geprüft". */
feedStatus: ColonyCondition | null;
/**
* One of FEED_STATUS_OPTIONS, or null for "Nicht geprüft" - or an older
* free-text value, kept verbatim so saving without touching it changes
* nothing.
* An old feed text that maps to no level: the field stays empty, the text
* is shown as a hint, and the stored value is not overwritten with null
* unless the beekeeper changes the field. Never sent.
*/
feedStatus: string | null;
feedLegacyText: string | null;
honeySuperStatus: string;
healthIssues: string;
varroaIssues: string;
measures: string;
notes: string;
/**
* The colony-card fields to take over from this inspection (Volkskarte).
* Follows what is filled in: a field is checked when it gets a value and
* unchecked when it is emptied; unchecking by hand stays until the field
* is emptied and filled again. Create only - never sent on a correction.
*/
applyToColony: ColonyTakeoverField[];
/**
* true: weather and temperature come from the apiary weather prefill (or
* are still empty) and may be replaced when date or colony change. Any
@@ -92,11 +142,13 @@ export function initialInspectionValues(
gentleness: null,
combBehavior: null,
feedStatus: null,
feedLegacyText: null,
honeySuperStatus: "",
healthIssues: "",
varroaIssues: "",
measures: "",
notes: "",
applyToColony: [],
weatherAuto: true,
photoSessionId: newIdempotencyKey(),
};
@@ -110,7 +162,7 @@ export function initialInspectionValues(
*/
export function toInspectionInput(
values: InspectionFormValues,
): Parameters<typeof createInspection>[0] {
): Omit<CreateInspectionInput, "applyToColony"> {
return {
colonyId: values.colonyId,
date: toApiDate(values.date),
@@ -128,7 +180,11 @@ export function toInspectionInput(
swarmMood: values.swarmMood ?? null,
gentleness: values.gentleness ?? null,
combBehavior: values.combBehavior ?? null,
feedStatus: values.feedStatus,
// A legacy word left in an old draft still goes out as the enum. While an
// unmappable old text is kept, nothing is sent: null would erase it.
...(values.feedLegacyText !== null && values.feedStatus === null
? {}
: { feedStatus: feedConditionFromInput(values.feedStatus) }),
honeySuperStatus: emptyToNull(values.honeySuperStatus),
healthIssues: emptyToNull(values.healthIssues),
varroaIssues: emptyToNull(values.varroaIssues),
@@ -136,3 +192,87 @@ export function toInspectionInput(
notes: emptyToNull(values.notes),
};
}
type TakeoverSource = Pick<InspectionFormValues, "colonyStrength" | "broodPattern" | "feedStatus">;
const IS_FILLED: Record<ColonyTakeoverField, (values: TakeoverSource) => boolean> = {
occupiedFrames: (values) => values.colonyStrength.trim() !== "",
broodStatus: (values) => ratingToCondition(values.broodPattern) !== null,
feedStatus: (values) => values.feedStatus !== null,
};
/** The Volkskarte fields this inspection has a value for, in the server's order. */
export function takeoverFieldsFilled(values: TakeoverSource): ColonyTakeoverField[] {
return COLONY_TAKEOVER_FIELDS.filter((field) => IS_FILLED[field](values));
}
/**
* Keeps the Volkskarte selection in step with the form: a field that just
* got a value is checked, one that was just emptied is unchecked, and
* everything else - a box unchecked by hand - stays as it is.
*/
export function syncTakeoverSelection(
before: InspectionFormValues,
after: InspectionFormValues,
options: {
/** false: a newly filled field is not ticked (values copied from the last inspection). */
autoTick?: boolean;
/** The field the beekeeper just typed or tapped: ticked when it has a value. */
touched?: ColonyTakeoverField;
} = {},
): InspectionFormValues {
const { autoTick = true, touched } = options;
const was = new Set(takeoverFieldsFilled(before));
const now = new Set(takeoverFieldsFilled(after));
const selected = new Set(after.applyToColony);
for (const field of COLONY_TAKEOVER_FIELDS) {
if (now.has(field) && (field === touched || (autoTick && !was.has(field)))) selected.add(field);
if (!now.has(field) && was.has(field)) selected.delete(field);
}
const next = COLONY_TAKEOVER_FIELDS.filter((field) => selected.has(field));
const unchanged =
next.length === after.applyToColony.length &&
next.every((field, index) => field === after.applyToColony[index]);
return unchanged ? after : { ...after, applyToColony: next };
}
/**
* What a new inspection sends: the correction input plus, when the beekeeper
* may update colonies, the checked Volkskarte fields that have a value.
* Omitted when empty, so nothing is asked of the server that was not wanted.
*/
export function toCreateInspectionInput(
values: InspectionFormValues,
canUpdateColony: boolean,
): CreateInspectionInput {
const input = toInspectionInput(values);
if (!canUpdateColony) {
return input;
}
const filled = new Set(takeoverFieldsFilled(values));
const applyToColony = values.applyToColony.filter((field) => filled.has(field));
return applyToColony.length > 0 ? { ...input, applyToColony } : input;
}
/**
* The form values of a restored draft, on top of a fresh form. A draft saved
* by an earlier app version may hold a legacy feed word and has no Volkskarte
* selection yet - that then starts empty. `extra`
* wins over the draft (the form's own photo session, weather mode).
*/
export function currentDraftValues(
restored: Partial<InspectionFormValues> & Pick<InspectionFormValues, "colonyId">,
extra: Partial<InspectionFormValues> = {},
): InspectionFormValues {
const merged = { ...initialInspectionValues(restored.colonyId), ...restored, ...extra };
const feed =
merged.feedStatus !== null
? feedFieldFrom(merged.feedStatus)
: { feedStatus: null, feedLegacyText: merged.feedLegacyText ?? null };
return {
...merged,
...feed,
// Nothing is ticked on the draft's behalf: an old draft cannot tell typed from copied.
applyToColony: Array.isArray(restored.applyToColony) ? restored.applyToColony : [],
};
}
@@ -216,6 +216,235 @@ describe("NewInspectionPage", () => {
}
});
describe("Volkskarte", () => {
const colonyCard = {
id: "colony-1",
code: "V-01",
status: "ACTIVE",
apiaryId: "apiary-1",
occupiedFrames: 6,
broodStatus: "AVERAGE",
feedStatus: "GOOD",
};
const SECTION = { name: /Volkskarte/ };
beforeEach(() => {
permissions = new Set(["colonies:update"]);
vi.mocked(fetchColonies).mockResolvedValue([colonyCard] as never);
vi.mocked(createInspection).mockResolvedValue({
id: "i-1",
colonyId: "colony-1",
colonyTakeover: { applied: ["feedStatus"], skipped: [] },
} as never);
});
async function pickFeed(choice: string) {
await userEvent.click(
within(screen.getByRole("radiogroup", { name: /^Futterstatus/ })).getByRole("radio", {
name: choice,
}),
);
}
async function save() {
await userEvent.click(screen.getByRole("button", { name: "Durchsicht speichern" }));
}
it("checks only fields filled in this inspection and shows „bisher → neu“", async () => {
renderPage();
expect(screen.queryByRole("group", SECTION)).not.toBeInTheDocument();
await pickFeed("Niedrig");
const section = await screen.findByRole("group", SECTION);
expect(
within(section).getByRole("checkbox", { name: "Futter: Ausreichend → Niedrig" }),
).toBeChecked();
expect(within(section).getAllByRole("checkbox")).toHaveLength(1);
await userEvent.type(screen.getByLabelText(/^Volksstärke/), "9");
expect(
await within(section).findByRole("checkbox", { name: "Besetzte Waben: 6 → 9" }),
).toBeChecked();
});
it("sits directly above the save button", async () => {
renderPage();
await pickFeed("Niedrig");
const section = await screen.findByRole("group", SECTION);
const save = screen.getByRole("button", { name: "Durchsicht speichern" });
expect(
section.compareDocumentPosition(save) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
it("hides the section without colonies:update", async () => {
permissions = new Set();
renderPage();
await pickFeed("Niedrig");
expect(screen.queryByRole("group", SECTION)).not.toBeInTheDocument();
});
it("sends feedStatus as WEAK and applyToColony with the checked fields", async () => {
renderPage();
await pickFeed("Niedrig");
await userEvent.type(screen.getByLabelText(/^Volksstärke/), "9");
await userEvent.click(
await screen.findByRole("checkbox", { name: "Besetzte Waben: 6 → 9" }),
);
await save();
const [input] = vi.mocked(createInspection).mock.calls[0];
expect(input).toMatchObject({
feedStatus: "WEAK",
colonyStrength: 9,
applyToColony: ["feedStatus"],
});
});
it("sends no applyToColony without the permission or without filled fields", async () => {
renderPage();
await save();
expect(vi.mocked(createInspection).mock.calls[0][0]).not.toHaveProperty("applyToColony");
});
it("tells that the card was not updated when a newer inspection exists", async () => {
vi.mocked(createInspection).mockResolvedValue({
id: "i-1",
colonyId: "colony-1",
colonyTakeover: {
applied: [],
skipped: [{ field: "feedStatus", reason: "NEWER_INSPECTION" }],
},
} as never);
renderPage();
await pickFeed("Niedrig");
await save();
await waitFor(() =>
expect(toast.info).toHaveBeenCalledWith(
"Volkskarte nicht übernommen: Es gibt eine neuere Durchsicht.",
),
);
});
it("stays quiet about the card when everything was taken over", async () => {
renderPage();
await pickFeed("Niedrig");
await save();
await waitFor(() => expect(push).toHaveBeenCalled());
expect(toast.info).not.toHaveBeenCalled();
});
it("keeps applyToColony in an offline-queued inspection", async () => {
vi.mocked(createInspection).mockRejectedValue(new TypeError("Failed to fetch"));
renderPage();
await pickFeed("Kritisch");
await save();
await waitFor(() => expect(queueInspectionWithPhotos).toHaveBeenCalled());
expect(queueInspectionWithPhotos).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ feedStatus: "CRITICAL", applyToColony: ["feedStatus"] }),
expect.any(String),
"user-1",
);
});
it("caps „Volksstärke“ at 40 besetzte Waben", async () => {
renderPage();
const field = screen.getByLabelText(/^Volksstärke/);
await userEvent.type(field, "55");
expect(field).toHaveValue("40");
});
it("restores a draft of the previous version with its legacy feed word as the enum", async () => {
// A draft of the previous version: no applyToColony, a legacy feed word.
const { feedStatus, applyToColony, ...rest } = initialInspectionValues("colony-1");
void feedStatus;
void applyToColony;
window.localStorage.setItem(
`${DRAFT_PREFIX}user-1:inspection-new`,
JSON.stringify({
savedAt: Date.now(),
values: { ...rest, feedStatus: "niedrig", colonyStrength: "8" },
}),
);
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Weitermachen" }));
expect(
within(screen.getByRole("radiogroup", { name: /^Futterstatus/ }))
.getAllByRole("radio")
.find((radio) => radio.getAttribute("aria-checked") === "true")?.textContent,
).toBe("Niedrig");
// Nothing is ticked on the draft's behalf.
expect(
await screen.findByRole("checkbox", { name: "Besetzte Waben: 6 → 8" }),
).not.toBeChecked();
expect(screen.getByRole("checkbox", { name: "Futter: Ausreichend → Niedrig" })).not.toBeChecked();
});
it("shows a hint for unmappable legacy feed text in a restored draft and sends no feedStatus", async () => {
const { feedStatus, applyToColony, ...rest } = initialInspectionValues("colony-1");
void feedStatus;
void applyToColony;
window.localStorage.setItem(
`${DRAFT_PREFIX}user-1:inspection-new`,
JSON.stringify({ savedAt: Date.now(), values: { ...rest, feedStatus: "halb voll" } }),
);
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Weitermachen" }));
expect(
screen.getByText("Alter Futterwert „halb voll“ nicht übernommen – bitte neu wählen."),
).toBeInTheDocument();
await save();
expect(vi.mocked(createInspection).mock.calls[0][0]).not.toHaveProperty("feedStatus");
});
it("does not tick fields copied with „Alles wie letztes Mal“ or „übernehmen“, but ticks what is typed", async () => {
vi.mocked(fetchColonyInspections).mockResolvedValue([
{
id: "i-0",
colonyId: "colony-1",
date: "2026-10-01T00:00:00.000Z",
colonyStrength: 8,
feedStatus: "WEAK",
broodPattern: null,
createdAt: "2026-10-01T00:00:00.000Z",
},
] as never);
renderPage();
await screen.findByText(/Letzte Durchsicht:/);
await userEvent.click(screen.getByRole("button", { name: "Alles wie letztes Mal" }));
const section = await screen.findByRole("group", SECTION);
for (const box of within(section).getAllByRole("checkbox")) {
expect(box).not.toBeChecked();
}
await userEvent.click(
within(screen.getByRole("radiogroup", { name: /^Futterstatus/ })).getByRole("radio", { name: "Kritisch" }),
);
expect(within(section).getByRole("checkbox", { name: /^Futter:/ })).toBeChecked();
expect(within(section).getByRole("checkbox", { name: /^Besetzte Waben:/ })).not.toBeChecked();
});
});
describe("capture helpers", () => {
const lastInspection: Inspection = {
id: "i-0",
@@ -236,7 +465,7 @@ describe("NewInspectionPage", () => {
swarmMood: 4,
gentleness: null,
combBehavior: null,
feedStatus: "niedrig",
feedStatus: "WEAK",
honeySuperStatus: null,
healthIssues: null,
varroaIssues: null,
@@ -425,7 +654,7 @@ describe("NewInspectionPage", () => {
daily: [dayWeather(todayLocalIsoDate())],
});
renderPage();
const live = await screen.findByRole("status");
const live = await screen.findByTestId("weather-notice");
expect(live).toHaveAttribute("aria-live", "polite");
await waitFor(() => expect(live).toHaveTextContent("Wetter aktualisiert"));
@@ -442,7 +671,7 @@ describe("NewInspectionPage", () => {
daily: [dayWeather(todayLocalIsoDate()), dayWeather(other, { weatherCode: 61, tempMaxC: 14 })],
});
renderPage();
const live = await screen.findByRole("status");
const live = await screen.findByTestId("weather-notice");
await waitFor(() => expect(live).toHaveTextContent("Wetter aktualisiert"));
const first = live.textContent;
@@ -458,14 +687,14 @@ describe("NewInspectionPage", () => {
renderPage();
await userEvent.type(screen.getByLabelText(/^Wetter/), "windig");
expect(screen.getByRole("status")).toBeEmptyDOMElement();
expect(screen.getByTestId("weather-notice")).toBeEmptyDOMElement();
});
it("keeps the weather announcement region present while it is empty", () => {
renderPage();
// Hidden while empty, it would swallow the first announcement.
const live = screen.getByRole("status");
const live = screen.getByTestId("weather-notice");
expect(live).toBeEmptyDOMElement();
expect(hidesWhileEmpty(live)).toBe(false);
});
@@ -14,12 +14,14 @@ import {
previousLabel,
previousValueOf,
takeOverAll,
takeoverSkipNotice,
undoTakeOver,
type TakeoverField,
} from "@/lib/inspections/takeover";
import { type LastInspection, useLastInspection } from "@/lib/inspections/use-last-inspection";
import type { CreateInspectionInput } from "@/lib/inspections/types";
import { useCurrentUserId } from "@/lib/auth/auth-context";
import type { ColonyTakeoverField, CreateInspectionInput } from "@/lib/inspections/types";
import { useAuth, useCurrentUserId } from "@/lib/auth/auth-context";
import { MAX_OCCUPIED_FRAMES } from "@/lib/colonies/labels";
import { photosNotTakenOver } from "@/lib/offline/queue-texts";
import { syncPendingEntries } from "@/lib/offline/sync";
import { OFFLINE_QUEUE_QUERY_KEY, useCaptureMutation } from "@/lib/offline/use-capture-mutation";
@@ -50,6 +52,7 @@ import { ChoiceGroup } from "@/components/form/choice-group";
import { TriStateField } from "@/components/form/tri-state-field";
import { NumberStepper } from "@/components/form/number-stepper";
import { PreviousValueHint } from "@/components/form/previous-value-hint";
import { ColonyTakeoverSection } from "@/components/colonies/colony-takeover-section";
import { StickySubmit } from "@/components/form/sticky-submit";
import { DraftBanner } from "@/components/form/draft-banner";
import { PHOTO_SESSION_QUERY_KEY, PhotoCaptureField } from "@/components/photos/photo-capture-field";
@@ -57,8 +60,11 @@ import {
FEED_STATUS_OPTIONS,
INSPECTION_OBSERVATIONS,
InspectionFormValues,
currentDraftValues,
feedLegacyHint,
initialInspectionValues,
toInspectionInput,
syncTakeoverSelection,
toCreateInspectionInput,
} from "./inspection-form-values";
const UNDO_TOAST_MS = 8000;
@@ -82,6 +88,13 @@ const TAKEOVER_LABELS: Record<TakeoverField, string> = {
honeySuperStatus: "Honigraum",
};
/** The Volkskarte field an answer feeds, for ticking it when the beekeeper edits it. */
const TOUCHED_TAKEOVER_FIELD: Partial<Record<keyof InspectionFormValues, ColonyTakeoverField>> = {
colonyStrength: "occupiedFrames",
broodPattern: "broodStatus",
feedStatus: "feedStatus",
};
type SetValues = (update: (current: InspectionFormValues) => InspectionFormValues) => void;
/** "2026-10-01T00:00:00.000Z" -> "01.10.2026", whatever the phone's time zone. */
@@ -186,6 +199,8 @@ function InspectionCaptureForm({
const router = useRouter();
const queryClient = useQueryClient();
const userId = useCurrentUserId();
const { hasPermission } = useAuth();
const canUpdateColony = hasPermission("colonies:update");
const [values, setValues] = useState<InspectionFormValues>(() =>
initialInspectionValues(initialColonyId),
@@ -196,19 +211,49 @@ function InspectionCaptureForm({
const draft = useFormDraft("inspection-new", values, dirty);
const { inspection: last } = useLastInspection(values.colonyId);
const weatherNotice = useWeatherAutofill(values, setValues);
const { data: colonies } = useQuery({
queryKey: ["colonies"],
queryFn: () => fetchColonies(),
});
const colonyCard = colonies?.find((colony) => colony.id === values.colonyId);
const stand = useStandRound(roundApiaryId, values.colonyId);
const [roundSaved, setRoundSaved] = useState<RoundSaved | null>(null);
/**
* Every change of the answers also keeps the Volkskarte selection in step.
* `autoTick: false` for values copied from the last inspection: observations
* are never silently handed to the colony card.
*/
function update(
change: (current: InspectionFormValues) => InspectionFormValues,
options?: { autoTick?: boolean; touched?: ColonyTakeoverField },
) {
setValues((current) => {
const next = change(current);
// A chosen feed level replaces the old text the hint talked about.
const settled =
next.feedStatus !== null && next.feedLegacyText !== null
? { ...next, feedLegacyText: null }
: next;
return syncTakeoverSelection(current, settled, options);
});
}
function set<K extends keyof InspectionFormValues>(
key: K,
value: InspectionFormValues[K],
) {
const typedWeather = key === "weather" || key === "temperature";
setValues((current) => ({
...current,
[key]: value,
...(typedWeather ? { weatherAuto: false } : {}),
}));
update(
(current) => ({
...current,
[key]: value,
...(typedWeather ? { weatherAuto: false } : {}),
// Choosing „Nicht geprüft“ on purpose also ends the old-text hint.
...(key === "feedStatus" ? { feedLegacyText: null } : {}),
}),
{ touched: TOUCHED_TAKEOVER_FIELD[key] },
);
setDirty(true);
if (key === "colonyId") setColonyError(null);
if (key === "date") setDateError(null);
@@ -291,6 +336,13 @@ function InspectionCaptureForm({
queryKey: ["colonies", outcome.result.colonyId, "inspections"],
});
toast.success("Durchsicht gespeichert");
const takeover = outcome.result.colonyTakeover;
if (takeover?.applied.length) {
// The colony card changed: its page and lists must not show the old values.
void queryClient.invalidateQueries({ queryKey: ["colonies"] });
}
const notice = takeoverSkipNotice(takeover?.skipped ?? []);
if (notice) toast.info(notice);
void handOverPhotos(submittedPhotoSession.current, outcome.result.id);
}
const advanced = stand.round ? stand.markDone(input.colonyId) : null;
@@ -315,7 +367,7 @@ function InspectionCaptureForm({
return;
}
submittedPhotoSession.current = values.photoSessionId;
capture.submit(toInspectionInput(values));
capture.submit(toCreateInspectionInput(values, canUpdateColony));
}
/**
@@ -344,12 +396,13 @@ function InspectionCaptureForm({
// A draft from before photos existed has no session: the form keeps its own.
const photoSessionId = restored.photoSessionId ?? freshSessionId;
// A draft from before these fields existed lacks them.
setValues({
...initialInspectionValues(restored.colonyId),
...restored,
photoSessionId,
weatherAuto: restored.weatherAuto ?? (restored.weather === "" && restored.temperature === ""),
});
setValues(
currentDraftValues(restored, {
photoSessionId,
weatherAuto:
restored.weatherAuto ?? (restored.weather === "" && restored.temperature === ""),
}),
);
setDirty(true);
if (photoSessionId !== freshSessionId) {
void takePhotosAlong(freshSessionId, photoSessionId);
@@ -368,7 +421,7 @@ function InspectionCaptureForm({
}
function takeOver(field: TakeoverField, value: NonNullable<ReturnType<typeof previousValueOf>>) {
setValues((current) => ({ ...current, [field]: value }));
update((current) => ({ ...current, [field]: value }), { autoTick: false });
setDirty(true);
}
@@ -380,12 +433,12 @@ function InspectionCaptureForm({
return;
}
const before = values;
setValues(next);
update(() => next, { autoTick: false });
setDirty(true);
toast.success(`${taken.length} Werte übernommen`, {
action: {
label: "Rückgängig",
onClick: () => setValues((current) => undoTakeOver(current, before, next, taken)),
onClick: () => update((current) => undoTakeOver(current, before, next, taken), { autoTick: false }),
},
duration: UNDO_TOAST_MS,
});
@@ -569,6 +622,7 @@ function InspectionCaptureForm({
id={id}
value={values.colonyStrength}
onChange={(count) => set("colonyStrength", count)}
max={MAX_OCCUPIED_FRAMES}
unitLabel="besetzte Waben"
decrementLabel="Eine besetzte Wabe weniger"
incrementLabel="Eine besetzte Wabe mehr"
@@ -638,6 +692,12 @@ function InspectionCaptureForm({
onChange={(feedStatus) => set("feedStatus", feedStatus)}
className="grid-cols-2 sm:grid-cols-4"
/>
{values.feedLegacyText !== null ? (
<p role="status" className="flex items-start gap-2 text-sm">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning" aria-hidden />
{feedLegacyHint(values.feedLegacyText)}
</p>
) : null}
{hintFor("feedStatus")}
</>
)}
@@ -650,7 +710,12 @@ function InspectionCaptureForm({
description="Automatisch aus den Wetterdaten des Stands (Tageshöchstwert) – änderbar"
>
{/* Present while empty (only visually hidden), or its first message is not announced. */}
<p role="status" aria-live="polite" className="text-sm text-muted-foreground empty:sr-only">
<p
role="status"
aria-live="polite"
data-testid="weather-notice"
className="text-sm text-muted-foreground empty:sr-only"
>
{weatherNotice}
</p>
<Field label="Wetter">
@@ -750,6 +815,22 @@ function InspectionCaptureForm({
onChange={() => setDirty(true)}
/>
<ColonyTakeoverSection
colony={
colonyCard
? {
occupiedFrames: colonyCard.occupiedFrames ?? null,
broodStatus: colonyCard.broodStatus ?? null,
feedStatus: colonyCard.feedStatus ?? null,
}
: null
}
values={values}
selected={values.applyToColony}
onChange={(applyToColony) => set("applyToColony", applyToColony)}
canUpdateColony={canUpdateColony}
/>
<StickySubmit
note={
capture.submitError ? (
@@ -0,0 +1,108 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ColonyTakeoverSection } from "./colony-takeover-section";
import { initialInspectionValues } from "@/app/(app)/inspections/new/inspection-form-values";
const colony = { occupiedFrames: 6, broodStatus: "AVERAGE", feedStatus: "GOOD" } as const;
function values(overrides = {}) {
return { ...initialInspectionValues("colony-1"), ...overrides };
}
function renderSection(props: Partial<React.ComponentProps<typeof ColonyTakeoverSection>> = {}) {
const onChange = vi.fn();
render(
<ColonyTakeoverSection
colony={colony}
values={values({ colonyStrength: "9", broodPattern: 4, feedStatus: "WEAK" })}
selected={["occupiedFrames", "broodStatus", "feedStatus"]}
onChange={onChange}
canUpdateColony
{...props}
/>,
);
return onChange;
}
describe("ColonyTakeoverSection", () => {
it("shows each filled field as „bisher → neu“ with a checked box", () => {
renderSection();
expect(screen.getByRole("checkbox", { name: "Besetzte Waben: 6 → 9" })).toBeChecked();
expect(screen.getByRole("checkbox", { name: "Brut: Mittel → Gut" })).toBeChecked();
expect(screen.getByRole("checkbox", { name: "Futter: Ausreichend → Niedrig" })).toBeChecked();
});
it("lists only fields filled in this inspection", () => {
renderSection({ values: values({ feedStatus: "CRITICAL" }), selected: ["feedStatus"] });
expect(screen.getAllByRole("checkbox")).toHaveLength(1);
expect(screen.getByRole("checkbox", { name: "Futter: Ausreichend → Kritisch" })).toBeChecked();
});
it("says „noch nicht erfasst“ for a colony card without a value", () => {
renderSection({
colony: { occupiedFrames: null, broodStatus: null, feedStatus: null },
values: values({ feedStatus: "WEAK" }),
selected: [],
});
expect(
screen.getByRole("checkbox", { name: "Futter: noch nicht erfasst → Niedrig" }),
).not.toBeChecked();
});
it("reports the new selection when a box is toggled", async () => {
const onChange = renderSection();
await userEvent.click(screen.getByRole("checkbox", { name: "Brut: Mittel → Gut" }));
expect(onChange).toHaveBeenCalledWith(["occupiedFrames", "feedStatus"]);
});
it("adds a box back in the fixed field order", async () => {
const onChange = renderSection({ selected: ["feedStatus"] });
await userEvent.click(screen.getByRole("checkbox", { name: "Besetzte Waben: 6 → 9" }));
expect(onChange).toHaveBeenCalledWith(["occupiedFrames", "feedStatus"]);
});
it("announces how many values are taken over", () => {
renderSection({ selected: ["feedStatus"] });
expect(screen.getByRole("status")).toHaveTextContent("1 Wert wird auf die Volkskarte übernommen.");
});
it("is hidden without colonies:update", () => {
renderSection({ canUpdateColony: false });
expect(screen.queryByRole("group", { name: /Volkskarte/ })).not.toBeInTheDocument();
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
});
it("is hidden when nothing was filled in, but keeps its status region mounted", () => {
renderSection({ values: values(), selected: [] });
expect(screen.queryByRole("group", { name: /Volkskarte/ })).not.toBeInTheDocument();
expect(screen.getByRole("status")).toBeInTheDocument();
});
it("says „bisher unbekannt“ when the card values are not known (cache empty)", () => {
renderSection({ colony: null, selected: [] });
expect(screen.getByRole("checkbox", { name: "Futter: bisher unbekannt → Niedrig" })).toBeInTheDocument();
expect(screen.getByRole("checkbox", { name: "Besetzte Waben: bisher unbekannt → 9" })).toBeInTheDocument();
});
it("can be operated with Tab and Space", async () => {
const onChange = renderSection();
await userEvent.tab();
expect(screen.getByRole("checkbox", { name: "Besetzte Waben: 6 → 9" })).toHaveFocus();
await userEvent.keyboard(" ");
expect(onChange).toHaveBeenCalledWith(["broodStatus", "feedStatus"]);
});
});
@@ -0,0 +1,145 @@
"use client";
import { Layers } from "lucide-react";
import type { InspectionFormValues } from "@/app/(app)/inspections/new/inspection-form-values";
import { FEED_CONDITION_LABELS, ratingToCondition } from "@/lib/colonies/condition";
import { COLONY_CONDITION_LABELS } from "@/lib/colonies/labels";
import type { Colony, ColonyCondition } from "@/lib/colonies/types";
import { COLONY_TAKEOVER_FIELDS, type ColonyTakeoverField } from "@/lib/inspections/types";
export interface ColonyTakeoverSectionProps {
/** What the colony card holds now; null when it is not known (nothing loaded yet). */
colony: Pick<Colony, "occupiedFrames" | "broodStatus" | "feedStatus"> | null;
/** What this inspection recorded. */
values: Pick<InspectionFormValues, "colonyStrength" | "broodPattern" | "feedStatus">;
selected: ColonyTakeoverField[];
onChange: (fields: ColonyTakeoverField[]) => void;
/** Without colonies:update the section is not offered at all. */
canUpdateColony: boolean;
}
interface TakeoverRow {
field: ColonyTakeoverField;
label: string;
}
const NOT_RECORDED = "noch nicht erfasst";
const UNKNOWN = "bisher unbekannt";
function conditionText(
condition: ColonyCondition | null | undefined,
labels: Record<ColonyCondition, string>,
): string {
if (condition === undefined) return UNKNOWN;
return condition === null ? NOT_RECORDED : labels[condition];
}
function rowText(name: string, before: string, after: string): string {
return before === after ? `${name}: ${after} (unverändert)` : `${name}: ${before} → ${after}`;
}
/** One row per colony-card value this inspection has an answer for. */
function takeoverRows({ colony, values }: Pick<ColonyTakeoverSectionProps, "colony" | "values">): TakeoverRow[] {
const rows: TakeoverRow[] = [];
for (const field of COLONY_TAKEOVER_FIELDS) {
if (field === "occupiedFrames" && values.colonyStrength.trim() !== "") {
const before =
colony === null
? UNKNOWN
: colony.occupiedFrames === null
? NOT_RECORDED
: String(colony.occupiedFrames);
rows.push({
field,
label: rowText("Besetzte Waben", before, String(Number(values.colonyStrength))),
});
}
const brood = ratingToCondition(values.broodPattern);
if (field === "broodStatus" && brood !== null) {
rows.push({
field,
label: rowText(
"Brut",
conditionText(colony?.broodStatus, COLONY_CONDITION_LABELS),
COLONY_CONDITION_LABELS[brood],
),
});
}
if (field === "feedStatus" && values.feedStatus !== null) {
rows.push({
field,
label: rowText(
"Futter",
conditionText(colony?.feedStatus, FEED_CONDITION_LABELS),
FEED_CONDITION_LABELS[values.feedStatus],
),
});
}
}
return rows;
}
function countText(count: number): string {
if (count === 0) return "Es wird nichts auf die Volkskarte übernommen.";
if (count === 1) return "1 Wert wird auf die Volkskarte übernommen.";
return `${count} Werte werden auf die Volkskarte übernommen.`;
}
/**
* „Volkskarte“ above the save button: which of today's answers also go onto
* the colony card, each as „bisher → neu“ with a box. Ticked are exactly the
* fields filled in this inspection; nothing is taken over that was not asked.
*/
export function ColonyTakeoverSection({
colony,
values,
selected,
onChange,
canUpdateColony,
}: ColonyTakeoverSectionProps) {
if (!canUpdateColony) {
return null;
}
const rows = takeoverRows({ colony, values });
function toggle(field: ColonyTakeoverField, checked: boolean) {
onChange(
COLONY_TAKEOVER_FIELDS.filter((candidate) =>
candidate === field ? checked : selected.includes(candidate),
),
);
}
const takenCount = rows.filter((row) => selected.includes(row.field)).length;
return (
<div className="flex flex-col gap-2">
{rows.length > 0 ? (
<fieldset className="flex flex-col gap-2 rounded-xl bg-muted/50 p-3">
<legend className="flex items-center gap-2 px-1 text-sm font-semibold">
<Layers className="size-4" aria-hidden />
Volkskarte
</legend>
<p className="text-sm text-muted-foreground">
Diese Angaben auch auf der Volkskarte eintragen:
</p>
{rows.map((row) => (
<label key={row.field} className="flex min-h-11 items-center gap-3 text-sm">
<input
type="checkbox"
className="size-5 shrink-0 accent-primary"
checked={selected.includes(row.field)}
onChange={(event) => toggle(row.field, event.target.checked)}
/>
{row.label}
</label>
))}
</fieldset>
) : null}
{/* Mounted even while empty, or its first message is not announced. */}
<p role="status" aria-live="polite" className="text-sm text-muted-foreground empty:hidden">
{rows.length > 0 ? countText(takenCount) : ""}
</p>
</div>
);
}
@@ -4,13 +4,14 @@ import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { NumberStepper } from "./number-stepper";
function Harness({ initial = "" }: { initial?: string }) {
function Harness({ initial = "", max }: { initial?: string; max?: number }) {
const [value, setValue] = useState(initial);
return (
<NumberStepper
id="mites"
value={value}
onChange={setValue}
max={max}
unitLabel="Milben"
decrementLabel="Eine Milbe weniger"
incrementLabel="Eine Milbe mehr"
@@ -108,4 +109,47 @@ describe("NumberStepper", () => {
}
expect(screen.getByRole("textbox")).toHaveAccessibleDescription("Milben");
});
it("never goes above max with „+“", async () => {
render(<Harness initial="39" max={40} />);
const more = screen.getByRole("button", { name: /Eine Milbe mehr/ });
await userEvent.click(more);
await userEvent.click(more);
expect(screen.getByRole("textbox")).toHaveValue("40");
});
it("caps typed numbers at max", async () => {
render(<Harness max={40} />);
await userEvent.type(screen.getByRole("textbox"), "55");
expect(screen.getByRole("textbox")).toHaveValue("40");
});
it("announces „Höchstens 40“ when a typed number is clamped and no hint without max", async () => {
render(<Harness max={40} />);
expect(screen.getByRole("status")).toBeEmptyDOMElement();
await userEvent.type(screen.getByRole("textbox"), "55");
expect(screen.getByRole("status")).toHaveTextContent("Höchstens 40");
});
it("announces the limit when „+“ stops at max", async () => {
render(<Harness initial="40" max={40} />);
await userEvent.click(screen.getByRole("button", { name: /Eine Milbe mehr/ }));
expect(screen.getByRole("status")).toHaveTextContent("Höchstens 40");
});
it("leaves an already higher stored value alone when „+“ is tapped", async () => {
render(<Harness initial="45" max={40} />);
await userEvent.click(screen.getByRole("button", { name: /Eine Milbe mehr/ }));
expect(screen.getByRole("textbox")).toHaveValue("45");
});
});
@@ -1,6 +1,6 @@
"use client";
import { ChangeEvent, useRef } from "react";
import { ChangeEvent, useRef, useState } from "react";
import { Input } from "@/components/ui/input";
export interface NumberStepperProps {
@@ -9,6 +9,8 @@ export interface NumberStepperProps {
value: string;
onChange: (value: string) => void;
min?: number;
/** Highest count; typing more or tapping „+“ stops here. Default: 4 digits. */
max?: number;
step?: number;
/** Visible unit next to the field, e.g. "Milben"; the name comes from the Field label. */
unitLabel: string;
@@ -39,6 +41,7 @@ export function NumberStepper({
value,
onChange,
min = 0,
max: maxProp,
step = 1,
unitLabel,
decrementLabel,
@@ -46,6 +49,8 @@ export function NumberStepper({
describedBy,
invalid,
}: NumberStepperProps) {
const max = maxProp ?? MAX_VALUE;
const [limitHit, setLimitHit] = useState(false);
const unitId = `${id}-unit`;
const current = value === "" ? null : Number(value);
const canDecrement = current !== null && current - step >= min;
@@ -53,7 +58,13 @@ export function NumberStepper({
const inputRef = useRef<HTMLInputElement>(null);
function increment() {
onChange(String(Math.min(MAX_VALUE, current === null ? min + step : current + step)));
// A stored value above max (an older entry) stays as it is.
if (current !== null && current >= max) {
setLimitHit(maxProp !== undefined);
return;
}
setLimitHit(false);
onChange(String(Math.min(max, current === null ? min + step : current + step)));
}
function decrement() {
@@ -70,7 +81,10 @@ export function NumberStepper({
}
function handleInput(event: ChangeEvent<HTMLInputElement>) {
onChange(digitsOnly(event.target.value));
const digits = digitsOnly(event.target.value);
const clamped = digits !== "" && Number(digits) > max;
setLimitHit(clamped && maxProp !== undefined);
onChange(clamped ? String(max) : digits);
}
return (
@@ -108,6 +122,11 @@ export function NumberStepper({
{unitLabel}
</span>
</div>
{maxProp !== undefined ? (
<p role="status" aria-live="polite" className="text-xs text-muted-foreground empty:hidden">
{limitHit ? `Höchstens ${maxProp}` : ""}
</p>
) : null}
{value !== "" ? (
<button
type="button"
@@ -15,7 +15,7 @@ import {
type QueueError,
type QueueItem,
} from "@/lib/offline/db";
import { entriesSynced, foreignEntriesWaiting, photosAttached } from "@/lib/offline/queue-texts";
import { entriesSynced, foreignEntriesWaiting, photosAttached, rejectionText } from "@/lib/offline/queue-texts";
import { syncPendingEntries } from "@/lib/offline/sync";
import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation";
import { discardReadyPhoto, listPendingPhotos, resetPhotoError } from "@/lib/photos/store";
@@ -76,7 +76,7 @@ function QueueStatus({ lastError }: { lastError?: QueueError }) {
return lastError ? (
<p className="flex items-start gap-2 text-sm text-destructive">
<AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden />
<span>Abgelehnt: {lastError.message}</span>
<span>Abgelehnt: {rejectionText(lastError.message)}</span>
</p>
) : (
<p className="flex items-start gap-2 text-sm text-muted-foreground">
@@ -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,