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
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user