fix: T4 browser-check findings, interval on season state, T6 plan refresh

- Honey batch create omits an empty batch number (was 400); product form
  labels the legacy total weight, detail shows jar weight and shelf life;
  bottling hints next to their fields, values cleared on product change,
  post-save water-content wording; withdraw reason placeholder not
  selectable; honey batch actions offline-disabled with reason, 44 px.
- Offline signal: one slow request no longer flips the app offline; two
  consecutive timeouts do, any HTTP answer clears it, aborts never count.
- Phenology sheet as a side card on desktop with focus return; confirm
  dialogs as alertdialog; step-up texts in du-form ("Das Passwort stimmt
  nicht."); backup import gives its disabled reason; missing event labels
  plus a guard test; profile buttons 44 px.
- GET /phenology/state returns intervalDays via the shared
  inspectionIntervalOf (autopilot, digest, season state use one source).
- T6 plan aligned with the code after T4, plus owner decisions of
  2026-10-15 (Berlin day for restrictions, COMPLIANCE category, override
  without step-up).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-15 12:47:08 +02:00
1 parent 9760ce154f
commit 7a1480138d
63 files changed
+1076 -251

No files matched your search

@@ -29,7 +29,7 @@ describe("StepUpDialog", () => {
const confirm = screen.getByRole("button", { name: "Verbinden" });
expect(confirm).toBeDisabled();
await userEvent.type(screen.getByLabelText("Ihr Passwort"), "geheim");
await userEvent.type(screen.getByLabelText("Dein Passwort"), "geheim");
await userEvent.click(confirm);
await waitFor(() => expect(onConfirm).toHaveBeenCalledWith({ password: "geheim" }));
@@ -39,7 +39,7 @@ describe("StepUpDialog", () => {
it("asks for the six-digit code with MFA and sends both", async () => {
const { onConfirm } = renderDialog({ needsMfa: true });
await userEvent.type(screen.getByLabelText("Ihr Passwort"), "geheim");
await userEvent.type(screen.getByLabelText("Dein Passwort"), "geheim");
const confirm = screen.getByRole("button", { name: "Verbinden" });
await userEvent.type(screen.getByLabelText("Code aus der Authenticator-App"), "12ab34");
expect(confirm).toBeDisabled();
@@ -56,21 +56,21 @@ describe("StepUpDialog", () => {
const onConfirm = vi.fn().mockRejectedValue(new ApiError(401, "Das Passwort ist falsch."));
const { onOpenChange } = renderDialog({ onConfirm });
await userEvent.type(screen.getByLabelText("Ihr Passwort"), "falsch");
await userEvent.type(screen.getByLabelText("Dein Passwort"), "falsch");
await userEvent.click(screen.getByRole("button", { name: "Verbinden" }));
await waitFor(() =>
expect(screen.getByRole("alert")).toHaveTextContent("Das Passwort ist falsch."),
);
expect(onOpenChange).not.toHaveBeenCalledWith(false);
expect(screen.getByLabelText("Ihr Passwort")).toHaveValue("");
expect(screen.getByLabelText("Ihr Passwort")).toHaveFocus();
expect(screen.getByLabelText("Dein Passwort")).toHaveValue("");
expect(screen.getByLabelText("Dein Passwort")).toHaveFocus();
});
it("shows the German too-many-attempts text on 429", async () => {
renderDialog({ onConfirm: vi.fn().mockRejectedValue(new ApiError(429, "ThrottlerException")) });
await userEvent.type(screen.getByLabelText("Ihr Passwort"), "x");
await userEvent.type(screen.getByLabelText("Dein Passwort"), "x");
await userEvent.click(screen.getByRole("button", { name: "Verbinden" }));
await waitFor(() =>
@@ -83,7 +83,7 @@ describe("StepUpDialog", () => {
it("says the session expired on a bare 401", async () => {
renderDialog({ onConfirm: vi.fn().mockRejectedValue(new ApiError(401, "Unauthorized")) });
await userEvent.type(screen.getByLabelText("Ihr Passwort"), "x");
await userEvent.type(screen.getByLabelText("Dein Passwort"), "x");
await userEvent.click(screen.getByRole("button", { name: "Verbinden" }));
await waitFor(() =>
@@ -96,17 +96,17 @@ describe("StepUpDialog", () => {
it("makes the inputs read-only while the action runs", async () => {
renderDialog({ onConfirm: vi.fn(() => new Promise<void>(() => {})) });
await userEvent.type(screen.getByLabelText("Ihr Passwort"), "geheim");
await userEvent.type(screen.getByLabelText("Dein Passwort"), "geheim");
await userEvent.click(screen.getByRole("button", { name: "Verbinden" }));
await waitFor(() => expect(screen.getByLabelText("Ihr Passwort")).toHaveAttribute("readonly"));
await waitFor(() => expect(screen.getByLabelText("Dein Passwort")).toHaveAttribute("readonly"));
});
it("cannot be closed with Escape while the action runs, and shows the pending label", async () => {
const onConfirm = vi.fn(() => new Promise<void>(() => {}));
const { onOpenChange } = renderDialog({ onConfirm, pendingLabel: "Wird eingespielt…" });
await userEvent.type(screen.getByLabelText("Ihr Passwort"), "geheim");
await userEvent.type(screen.getByLabelText("Dein Passwort"), "geheim");
await userEvent.click(screen.getByRole("button", { name: "Verbinden" }));
expect(await screen.findByRole("button", { name: "Wird eingespielt…" })).toBeDisabled();
@@ -88,7 +88,7 @@ function StepUpForm({
return (
<form className="flex flex-col gap-3" onSubmit={handleSubmit} noValidate>
<div className="flex flex-col gap-1.5">
<Label htmlFor={passwordId}>Ihr Passwort</Label>
<Label htmlFor={passwordId}>Dein Passwort</Label>
<Input
id={passwordId}
ref={passwordRef}
@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ConfirmDialog } from "./confirm-dialog";
function renderDialog(onConfirm = vi.fn()) {
render(
<ConfirmDialog
triggerLabel="Löschen"
title="Wirklich löschen?"
description="Das lässt sich nicht rückgängig machen."
confirmLabel="Ja, löschen"
onConfirm={onConfirm}
/>,
);
return onConfirm;
}
describe("ConfirmDialog", () => {
it("is an alertdialog named by its title and described by its text", async () => {
renderDialog();
await userEvent.click(screen.getByRole("button", { name: "Löschen" }));
const dialog = await screen.findByRole("alertdialog", { name: "Wirklich löschen?" });
expect(dialog).toHaveAccessibleDescription("Das lässt sich nicht rückgängig machen.");
});
it("confirms, cancels and closes with Escape as before", async () => {
const onConfirm = renderDialog();
await userEvent.click(screen.getByRole("button", { name: "Löschen" }));
await screen.findByRole("alertdialog");
await userEvent.keyboard("{Escape}");
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(onConfirm).not.toHaveBeenCalled();
await userEvent.click(screen.getByRole("button", { name: "Löschen" }));
await userEvent.click(await screen.findByRole("button", { name: "Ja, löschen" }));
expect(onConfirm).toHaveBeenCalledTimes(1);
});
});
@@ -65,7 +65,7 @@ export function ConfirmDialog({
>
{triggerLabel}
</Button>
<DialogContent>
<DialogContent role="alertdialog">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
@@ -45,6 +45,11 @@ type EnumSelectProps<T extends string> = EnumSelectBaseProps<T> &
* Status"). It stands for the empty value "".
*/
noneLabel: string;
/**
* The "none" row is only a placeholder ("Grund wählen"): it shows in
* the trigger while nothing is chosen but cannot be picked.
*/
noneIsPlaceholder?: boolean;
value: NoInfer<T> | "";
onChange: (value: NoInfer<T> | "") => void;
}
@@ -103,7 +108,9 @@ export function EnumSelect<T extends string>(props: EnumSelectProps<T>) {
</SelectTrigger>
<SelectContent>
{props.noneLabel !== undefined ? (
<SelectItem value={NONE_VALUE}>{props.noneLabel}</SelectItem>
<SelectItem value={NONE_VALUE} disabled={props.noneIsPlaceholder}>
{props.noneLabel}
</SelectItem>
) : null}
{values.map((value) => (
<SelectItem key={value} value={value}>
@@ -37,4 +37,14 @@ describe("PreviousValueHint", () => {
expect(screen.getByText(/Aus 2 Ernten: 42,5 kg/)).toBeInTheDocument();
expect(screen.queryByText(/Letztes Mal/)).not.toBeInTheDocument();
});
it("keeps the separator and „übernehmen“ in one non-wrapping unit", () => {
render(<PreviousValueHint fieldLabel="Menge" valueLabel="20 kg" onTakeOver={() => undefined} />);
const button = screen.getByRole("button", { name: /übernehmen/ });
const unit = button.parentElement as HTMLElement;
expect(unit).toHaveClass("whitespace-nowrap");
expect(unit).toHaveTextContent(/^·\s*übernehmen/);
expect(screen.getByText(/Letztes Mal: 20 kg/)).not.toHaveTextContent("·");
});
});
@@ -28,16 +28,20 @@ export function PreviousValueHint({
return (
<p className={cn("flex flex-wrap items-center gap-x-2 text-sm text-muted-foreground", className)}>
<span>
{prefix}: {valueLabel} <span aria-hidden="true">·</span>
{prefix}: {valueLabel}
</span>
{/* Separator and button wrap as one unit: the dot is never left alone at a line end. */}
<span className="inline-flex items-center whitespace-nowrap">
<span aria-hidden="true">·</span>
<button
type="button"
onClick={onTakeOver}
className="min-h-11 rounded-lg px-2 font-medium text-foreground underline underline-offset-4 outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
>
übernehmen{" "}
<span className="sr-only">– {fieldLabel}</span>
</button>
</span>
<button
type="button"
onClick={onTakeOver}
className="min-h-11 rounded-lg px-2 font-medium text-foreground underline underline-offset-4 outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
>
übernehmen{" "}
<span className="sr-only">– {fieldLabel}</span>
</button>
</p>
);
}
@@ -66,7 +66,7 @@ async function discardRejectedEntry(nextSummary: { own: number; failed: number;
await userEvent.click(
await within(sheet).findByRole("button", { name: /^Verwerfen: Durchsicht vom / }),
);
const confirm = await screen.findByRole("dialog", { name: /^Durchsicht vom .+ verwerfen\?$/ });
const confirm = await screen.findByRole("alertdialog", { name: /^Durchsicht vom .+ verwerfen\?$/ });
await userEvent.click(within(confirm).getByRole("button", { name: "Verwerfen" }));
await waitFor(() => expect(discardQueueItem).toHaveBeenCalledWith(REJECTED));
return sheet;
@@ -162,7 +162,7 @@ describe("PendingQueueSheet", () => {
await userEvent.click(
within(sheet).getByRole("button", { name: `Verwerfen: Durchsicht vom ${shown(REJECTED)}` }),
);
const confirm = await screen.findByRole("dialog", {
const confirm = await screen.findByRole("alertdialog", {
name: `Durchsicht vom ${shown(REJECTED)} verwerfen?`,
});
expect(confirm).toHaveTextContent("Der Eintrag und seine 2 Fotos werden von diesem Gerät gelöscht");
@@ -204,7 +204,7 @@ describe("PendingQueueSheet", () => {
const name = `Foto vom ${shown({ ...REJECTED, queuedAt: REJECTED_PHOTO.createdAt })}`;
await userEvent.click(await within(sheet).findByRole("button", { name: `Verwerfen: ${name}` }));
const confirm = await screen.findByRole("dialog", { name: `${name} verwerfen?` });
const confirm = await screen.findByRole("alertdialog", { name: `${name} verwerfen?` });
await userEvent.click(within(confirm).getByRole("button", { name: "Verwerfen" }));
await waitFor(() => expect(discardReadyPhoto).toHaveBeenCalledWith("photo-1"));
@@ -226,7 +226,7 @@ describe("PendingQueueSheet", () => {
await userEvent.click(discardRejected);
expect(discardQueueItem).not.toHaveBeenCalled();
const confirm = await screen.findByRole("dialog", {
const confirm = await screen.findByRole("alertdialog", {
name: `Durchsicht vom ${shown(REJECTED)} verwerfen?`,
});
await userEvent.click(within(confirm).getByRole("button", { name: "Verwerfen" }));
@@ -270,7 +270,7 @@ describe("PendingQueueSheet", () => {
expect(names[2]).toMatch(/\(2\)$/);
await userEvent.click(within(sheet).getAllByRole("button", { name: /^Verwerfen: / })[2]);
expect(
await screen.findByRole("dialog", { name: `${names[2]?.replace("Verwerfen: ", "")} verwerfen?` }),
await screen.findByRole("alertdialog", { name: `${names[2]?.replace("Verwerfen: ", "")} verwerfen?` }),
).toBeInTheDocument();
});
@@ -3,7 +3,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fetchAutopilotToday } from "@/lib/autopilot/api";
import { createPhenologyReport, fetchPhenologyState } from "@/lib/phenology/api";
import type { PhenologyState } from "@/lib/phenology/types";
import { ApiarySeason } from "./apiary-season";
@@ -14,10 +13,6 @@ vi.mock("@/lib/phenology/api", async (importOriginal) => ({
fetchPhenologyReports: vi.fn().mockResolvedValue([]),
createPhenologyReport: vi.fn(),
}));
vi.mock("@/lib/autopilot/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/autopilot/api")>()),
fetchAutopilotToday: vi.fn(),
}));
const auth = vi.hoisted(() => ({ permissions: new Set<string>() }));
vi.mock("@/lib/auth/auth-context", () => ({
useCurrentUserId: () => "user-1",
@@ -39,6 +34,7 @@ function state(overrides: Partial<PhenologyState>): PhenologyState {
gts: null,
gtsThroughDay: null,
note: null,
intervalDays: 7,
...overrides,
};
}
@@ -62,23 +58,6 @@ beforeEach(() => {
state({ apiaryId: "a-2", apiaryName: "Wald", segment: "SOMMER" }),
state({}),
]);
vi.mocked(fetchAutopilotToday)
.mockReset()
.mockResolvedValue({
nextStep: null,
inspectionDue: [
{
apiaryId: "a-1",
apiaryName: "Obstwiese",
segment: "SCHWARMZEIT",
intervalDays: 7,
colonies: [],
},
],
suggestionsOpen: 0,
seasons: [],
tasksVisible: true,
});
});
describe("ApiarySeason", () => {
@@ -105,8 +84,8 @@ describe("ApiarySeason", () => {
expect(await screen.findByText("Kalender")).toBeInTheDocument();
});
it("falls back to the season without an interval when the interval is unknown", async () => {
vi.mocked(fetchAutopilotToday).mockRejectedValue(new Error("403"));
it("shows just the season when no interval applies", async () => {
vi.mocked(fetchPhenologyState).mockResolvedValue([state({ intervalDays: null })]);
renderSeason();
expect(await screen.findByText("Schwarmzeit")).toBeInTheDocument();
@@ -121,6 +100,15 @@ describe("ApiarySeason", () => {
expect(screen.getByRole("button", { name: "Salweide blüht" })).toBeInTheDocument();
});
it("takes the interval from the state, without asking the autopilot", async () => {
vi.mocked(fetchPhenologyState).mockResolvedValue([
state({ segment: "SOMMER", intervalDays: 14 }),
]);
renderSeason();
expect(await screen.findByText("Sommer · alle 14 Tage")).toBeInTheDocument();
});
it("hides the button without phenology:create", async () => {
auth.permissions = new Set();
renderSeason();
@@ -1,12 +1,11 @@
"use client";
import { useState } from "react";
import { useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Flower2 } from "lucide-react";
import { SeasonChip } from "@/components/autopilot/season-chip";
import { PhenologyReportSheet } from "@/components/phenology/phenology-report-sheet";
import { Button } from "@/components/ui/button";
import { autopilotTodayKey, fetchAutopilotToday } from "@/lib/autopilot/api";
import { useAuth } from "@/lib/auth/auth-context";
import { fetchPhenologyState, phenologyStateKey } from "@/lib/phenology/api";
import { PHASE_REPORT_LABELS, SEASON_NOTE_LABELS, phaseSourceText } from "@/lib/phenology/labels";
@@ -22,21 +21,13 @@ interface ApiarySeasonProps {
export function ApiarySeason({ apiary }: ApiarySeasonProps) {
const { hasPermission } = useAuth();
const [isSheetOpen, setSheetOpen] = useState(false);
const reportButton = useRef<HTMLButtonElement>(null);
const stateQuery = useQuery({
queryKey: phenologyStateKey,
queryFn: fetchPhenologyState,
});
// The interval is only known for apiaries with colonies to inspect; without
// it (or without the right to see it) the chip names just the season.
const todayQuery = useQuery({
queryKey: autopilotTodayKey,
queryFn: fetchAutopilotToday,
});
const state = stateQuery.data?.find((entry) => entry.apiaryId === apiary.id);
const intervalDays =
todayQuery.data?.inspectionDue.find((entry) => entry.apiaryId === apiary.id)?.intervalDays ??
null;
const source = state ? phaseSourceText(state.phaseSource, state.reportedOn) : null;
const canReport = hasPermission("phenology:create");
@@ -46,7 +37,7 @@ export function ApiarySeason({ apiary }: ApiarySeasonProps) {
<section aria-label="Saison" className="flex flex-wrap items-center gap-x-3 gap-y-2">
{state ? (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm">
<SeasonChip segment={state.segment} intervalDays={intervalDays} />
<SeasonChip segment={state.segment} intervalDays={state.intervalDays} />
{state.phase ? <span>{PHASE_REPORT_LABELS[state.phase]}</span> : null}
{source ? (
<>
@@ -63,11 +54,22 @@ export function ApiarySeason({ apiary }: ApiarySeasonProps) {
) : null}
{canReport ? (
<>
<Button type="button" size="touch" variant="outline" onClick={() => setSheetOpen(true)}>
<Button
ref={reportButton}
type="button"
size="touch"
variant="outline"
onClick={() => setSheetOpen(true)}
>
<Flower2 aria-hidden />
Pflanze blüht
</Button>
<PhenologyReportSheet open={isSheetOpen} onOpenChange={setSheetOpen} apiary={apiary} />
<PhenologyReportSheet
open={isSheetOpen}
onOpenChange={setSheetOpen}
apiary={apiary}
returnFocusTo={reportButton}
/>
</>
) : null}
</section>
@@ -256,7 +256,7 @@ describe("PhenologyReportSheet", () => {
}),
);
expect(revokePhenologyReport).not.toHaveBeenCalled();
const dialog = await screen.findByRole("dialog", {
const dialog = await screen.findByRole("alertdialog", {
name: "Für alle Stände zurückziehen?",
});
await userEvent.click(within(dialog).getByRole("button", { name: "Zurückziehen" }));
@@ -268,6 +268,21 @@ describe("PhenologyReportSheet", () => {
);
});
it("Escape on the revoke question closes only that question, not the sheet", async () => {
vi.mocked(fetchPhenologyReports).mockResolvedValue([report({ id: "r-1" })]);
const onOpenChange = renderSheet();
await userEvent.click(
await screen.findByRole("button", { name: /Für alle Stände zurückziehen/ }),
);
await screen.findByRole("alertdialog", { name: "Für alle Stände zurückziehen?" });
await userEvent.keyboard("{Escape}");
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(onOpenChange).not.toHaveBeenCalled();
expect(screen.getByRole("dialog", { name: "Entwicklung melden" })).toBeInTheDocument();
});
it("does not withdraw when the question is cancelled", async () => {
vi.mocked(fetchPhenologyReports).mockResolvedValue([report({ id: "r-1" })]);
renderSheet();
@@ -277,7 +292,7 @@ describe("PhenologyReportSheet", () => {
name: /Für alle Stände zurückziehen/,
}),
);
const dialog = await screen.findByRole("dialog", {
const dialog = await screen.findByRole("alertdialog", {
name: "Für alle Stände zurückziehen?",
});
await userEvent.click(within(dialog).getByRole("button", { name: "Abbrechen" }));
@@ -298,6 +313,13 @@ describe("PhenologyReportSheet", () => {
);
});
it("is a max-width card from md up, not a full-width bottom sheet", () => {
renderSheet();
const dialog = screen.getByRole("dialog", { name: "Entwicklung melden" });
expect(dialog).toHaveClass("md:w-[28rem]", "md:inset-x-auto", "md:right-4");
});
it("has a neutral heading", () => {
renderSheet();
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState, type RefObject } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Flower2 } from "lucide-react";
import { toast } from "sonner";
@@ -41,6 +41,8 @@ interface PhenologyReportSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
apiary: { id: string; name: string };
/** Where the focus goes when the sheet closes: the button that opened it. */
returnFocusTo?: RefObject<HTMLElement | null>;
}
type Scope = "all" | "apiary";
@@ -48,7 +50,12 @@ type Scope = "all" | "apiary";
const QUEUED_TEXT = "gespeichert – wird gesendet, sobald wieder Verbindung besteht";
/** Which plant is in bloom? One tap reports it for today. */
export function PhenologyReportSheet({ open, onOpenChange, apiary }: PhenologyReportSheetProps) {
export function PhenologyReportSheet({
open,
onOpenChange,
apiary,
returnFocusTo,
}: PhenologyReportSheetProps) {
// While a report or a withdrawal is on its way the sheet stays open, so the
// answer (or the server's reason) is never lost behind a closed sheet.
const [isBusy, setBusy] = useState(false);
@@ -61,7 +68,10 @@ export function PhenologyReportSheet({ open, onOpenChange, apiary }: PhenologyRe
<SheetContent
side="bottom"
showCloseButton={false}
className="max-h-[85dvh] gap-0 overflow-y-auto rounded-t-2xl pb-(--safe-area-bottom)"
finalFocus={returnFocusTo}
// Below md a full-width bottom sheet; from md a card at the bottom right,
// so it does not cover the sidebar.
className="max-h-[85dvh] gap-0 overflow-y-auto rounded-t-2xl pb-(--safe-area-bottom) md:inset-x-auto md:right-4 md:bottom-4 md:w-[28rem] md:max-w-[calc(100vw-2rem)] md:rounded-2xl md:border"
>
{/* Remounted per opening: a fresh idempotency key and the preselected scope. */}
{open ? (
@@ -131,7 +131,7 @@ describe("PhotoCaptureField", () => {
expect(await screen.findAllByRole("img", { name: /^Foto \d$/ })).toHaveLength(2);
await userEvent.click(screen.getByRole("button", { name: "Entfernen: Foto 1" }));
const confirm = await screen.findByRole("dialog", { name: "Foto 1 entfernen?" });
const confirm = await screen.findByRole("alertdialog", { name: "Foto 1 entfernen?" });
await userEvent.click(within(confirm).getByRole("button", { name: "Entfernen" }));
await waitFor(async () => expect(await listSessionPhotos(sessionId)).toHaveLength(1));