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:
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();
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user