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

@@ -0,0 +1,17 @@
import { Info } from "lucide-react";
/**
* "Weicht vom Artikel ab", right under the field it is about. Plain text the
* field points at with aria-describedby - not a live region, the one live
* region of the form stays at the top.
*/
export function DifferenceHint({ id, message }: { id: string; message: string | null }) {
if (!message) return null;
return (
<p id={id} className="flex items-start gap-2 text-sm">
<Info className="mt-0.5 size-4 shrink-0" aria-hidden />
<span className="font-medium">Hinweis</span>
<span>{message}</span>
</p>
);
}
@@ -276,6 +276,53 @@ describe("NewBottlingBatchPage", () => {
expect(screen.getByText("Hinweis")).toBeInTheDocument();
});
it("shows the differs hint under its own field, outside every live region", async () => {
renderPage();
await chooseProduct();
await waitFor(() => expect(screen.getByLabelText(/Glasgröße/)).toHaveValue(250));
const jarSize = screen.getByLabelText(/Glasgröße/);
await userEvent.clear(jarSize);
await userEvent.type(jarSize, "500");
fireEvent.change(screen.getByLabelText(/Mindesthaltbarkeitsdatum/), {
target: { value: "2029-01-01" },
});
const jarHint = screen.getByText("Glasgröße weicht vom Artikel ab (Artikel: 250 g).");
const bbdHint = screen.getByText("MHD weicht vom Artikel ab (Artikel: 14.10.2028).");
expect(screen.getByLabelText(/Glasgröße/).getAttribute("aria-describedby")).toContain(
jarHint.closest("p")!.id,
);
expect(screen.getByLabelText(/Mindesthaltbarkeitsdatum/).getAttribute("aria-describedby")).toContain(
bbdHint.closest("p")!.id,
);
expect(jarHint.closest("[aria-live]")).toBeNull();
expect(bbdHint.closest("[aria-live]")).toBeNull();
expect(screen.getAllByText(/weicht vom Artikel ab/)).toHaveLength(2);
});
it("clears jar size and best before typed for the previous product when the new one has no values", async () => {
vi.mocked(fetchProducts).mockResolvedValue([
{ id: "p-1", name: "Frühtracht 250 g", kind: "BOTTLED_HONEY" },
{ id: "p-3", name: "Honig ohne Angaben", kind: "BOTTLED_HONEY" },
] as never);
renderPage();
await chooseProduct();
await waitFor(() => expect(screen.getByLabelText(/Glasgröße/)).toHaveValue(250));
const jarSize = screen.getByLabelText(/Glasgröße/);
await userEvent.clear(jarSize);
await userEvent.type(jarSize, "300");
fireEvent.change(screen.getByLabelText(/Mindesthaltbarkeitsdatum/), {
target: { value: "2027-01-31" },
});
await chooseProduct("Honig ohne Angaben");
await screen.findByText(/Am Artikel ist keine Haltbarkeit hinterlegt/);
expect(screen.getByLabelText(/Mindesthaltbarkeitsdatum/)).toHaveValue("");
expect(screen.getByLabelText(/Glasgröße/)).toHaveValue(500);
expect(screen.queryByText(/weicht vom Artikel ab/)).not.toBeInTheDocument();
});
it("fills jar size and best before from the product and names the source", async () => {
renderPage();
await chooseProduct();
@@ -343,7 +390,7 @@ describe("NewBottlingBatchPage", () => {
renderPage();
await fillAndSubmit();
const dialog = await screen.findByRole("dialog");
const dialog = await screen.findByRole("alertdialog");
expect(dialog).toHaveTextContent(
"Wassergehalt 20,5 % über der gesetzlichen Grenze. Trotzdem abfüllen?",
);
@@ -364,15 +411,39 @@ describe("NewBottlingBatchPage", () => {
await waitFor(() => expect(push).toHaveBeenCalledWith("/bottling/b-1"));
});
it("words the toast after the confirmed save as a fact, not as the pre-save text", async () => {
vi.mocked(createBottlingBatch)
.mockRejectedValueOnce(confirmationRequired(22))
.mockResolvedValueOnce({
id: "b-1",
warnings: [
{
code: "WATER_CONTENT_ABOVE_LEGAL",
severity: "BESTAETIGEN",
message: "Der Wassergehalt (22 %) liegt über der gesetzlichen Grenze von 20 %. Abfüllen nur mit Bestätigung.",
refs: [],
},
],
} as never);
renderPage();
await fillAndSubmit();
await userEvent.click(await screen.findByRole("button", { name: "Trotzdem abfüllen" }));
await waitFor(() => expect(toast.warning).toHaveBeenCalled());
const options = vi.mocked(toast.warning).mock.calls[0][1] as { description: string };
expect(options.description).toBe("Wassergehalt 22\u00a0% über der Grenze – mit Bestätigung abgefüllt.");
expect(options.description).not.toMatch(/nur mit Bestätigung/);
});
it("cancel writes nothing, keeps the form and puts the focus back on the button", async () => {
vi.mocked(createBottlingBatch).mockRejectedValueOnce(confirmationRequired(20.5));
renderPage();
await fillAndSubmit();
await screen.findByRole("dialog");
await screen.findByRole("alertdialog");
await userEvent.click(screen.getByRole("button", { name: "Abbrechen" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
await waitFor(() => expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument());
expect(createBottlingBatch).toHaveBeenCalledTimes(1);
expect(screen.getByLabelText(/Anzahl Gläser/)).toHaveValue(24);
expect(push).not.toHaveBeenCalled();
@@ -388,11 +459,11 @@ describe("NewBottlingBatchPage", () => {
.mockResolvedValueOnce({ id: "b-1" } as never);
renderPage();
await fillAndSubmit();
await screen.findByRole("dialog");
await screen.findByRole("alertdialog");
await userEvent.click(screen.getByRole("button", { name: "Trotzdem abfüllen" }));
await waitFor(() =>
expect(screen.getByRole("dialog")).toHaveTextContent(
expect(screen.getByRole("alertdialog")).toHaveTextContent(
"Wassergehalt 21 % über der gesetzlichen Grenze.",
),
);
@@ -524,11 +595,11 @@ describe("NewBottlingBatchPage", () => {
await userEvent.type(screen.getByLabelText(/Anzahl Gläser/), "24");
await userEvent.type(screen.getByLabelText(/Mindesthaltbarkeitsdatum/), "2028-07-01");
await userEvent.click(screen.getByRole("button", { name: "Abfüllung anlegen" }));
const dialog = await screen.findByRole("dialog");
const dialog = await screen.findByRole("alertdialog");
await userEvent.click(screen.getByRole("button", { name: "Trotzdem abfüllen" }));
await waitFor(() => expect(dialog).toHaveTextContent("Die Anfrage läuft noch."));
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByRole("alertdialog")).toBeInTheDocument();
});
it("a double click on Trotzdem abfüllen sends exactly one request", async () => {
@@ -538,12 +609,12 @@ describe("NewBottlingBatchPage", () => {
await userEvent.type(screen.getByLabelText(/Anzahl Gläser/), "24");
await userEvent.type(screen.getByLabelText(/Mindesthaltbarkeitsdatum/), "2028-07-01");
await userEvent.click(screen.getByRole("button", { name: "Abfüllung anlegen" }));
await screen.findByRole("dialog");
await screen.findByRole("alertdialog");
await userEvent.dblClick(screen.getByRole("button", { name: "Trotzdem abfüllen" }));
expect(createBottlingBatch).toHaveBeenCalledTimes(2);
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByRole("alertdialog")).toBeInTheDocument();
});
it("Tab stays inside the dialog", async () => {
@@ -552,7 +623,7 @@ describe("NewBottlingBatchPage", () => {
await userEvent.type(screen.getByLabelText(/Anzahl Gläser/), "24");
await userEvent.type(screen.getByLabelText(/Mindesthaltbarkeitsdatum/), "2028-07-01");
await userEvent.click(screen.getByRole("button", { name: "Abfüllung anlegen" }));
const dialog = await screen.findByRole("dialog");
const dialog = await screen.findByRole("alertdialog");
const outside = [
screen.getByLabelText(/Anzahl Gläser/),
@@ -578,7 +649,7 @@ describe("NewBottlingBatchPage", () => {
onCancel={() => undefined}
/>,
);
expect(screen.getByRole("dialog")).toHaveTextContent("Wassergehalt 20,5 %");
expect(screen.getByRole("alertdialog")).toHaveTextContent("Wassergehalt 20,5 %");
rerender(
<WaterContentConfirmDialog
@@ -590,7 +661,7 @@ describe("NewBottlingBatchPage", () => {
onCancel={() => undefined}
/>,
);
const closing = screen.queryByRole("dialog");
const closing = screen.queryByRole("alertdialog");
if (closing) expect(closing).toHaveTextContent("Wassergehalt 20,5 %");
});
@@ -9,8 +9,8 @@ import { fetchHoneyBatches } from "@/lib/honey-batches/api";
import { fetchProducts } from "@/lib/products/api";
import { createBottlingBatch, fetchBottlingPrefill } from "@/lib/bottling/api";
import { WATER_CONTENT_ABOVE_LEGAL, type BottlingPrefill } from "@/lib/bottling/types";
import { postSaveWarnings } from "@/lib/bottling/post-save-warnings";
import { ApiError } from "@/lib/auth/api-client";
import type { PlausibilityWarning } from "@/lib/warnings/types";
import { PrefillStatus } from "@/components/warnings/prefill-status";
import { useBottlingWithdrawalHints } from "@/lib/bottling/use-withdrawal-hints";
import { useFormDraft } from "@/lib/forms/use-form-draft";
@@ -31,6 +31,7 @@ import { StickySubmit } from "@/components/form/sticky-submit";
import { DraftBanner } from "@/components/form/draft-banner";
import { WaterContentConfirmDialog } from "./water-content-confirm-dialog";
import { WaterContentNotes } from "./water-content-notes";
import { DifferenceHint } from "./difference-hint";
import {
BottlingFormValues,
initialBottlingValues,
@@ -89,6 +90,10 @@ type BottlingDraft = BottlingFormValues & { manual?: ManualEdits };
/** The prefill with the batch and product it was asked for. */
type PrefillResult = BottlingPrefill & { honeyBatchId: string; productId: string; date: string };
function joinIds(...ids: (string | undefined)[]): string | undefined {
return ids.filter(Boolean).join(" ") || undefined;
}
function germanDay(day: string): string {
return day.split("-").reverse().join(".");
}
@@ -178,14 +183,25 @@ export default function NewBottlingBatchPage() {
key: K,
value: BottlingFormValues[K],
) {
setValues((current) => ({ ...current, [key]: value }));
if (key === "productId") {
// A new product brings new master data; earlier typing no longer applies.
// A new product brings new master data; jar size and best before typed
// for the previous product no longer apply and must not stay next to
// the new product's text (e.g. "Am Artikel ist keine Haltbarkeit ...").
const fresh = initialBottlingValues("");
setValues((current) => ({
...current,
productId: value as string,
jarSize: fresh.jarSize,
bestBeforeDate: fresh.bestBeforeDate,
}));
setManual(NO_MANUAL_EDITS);
setFromDraft(NO_MANUAL_EDITS);
} else if (key === "jarSize" || key === "bestBeforeDate") {
setValues((current) => ({ ...current, [key]: value }));
setManual((current) => ({ ...current, [key]: true }));
setFromDraft((current) => ({ ...current, [key]: false }));
} else {
setValues((current) => ({ ...current, [key]: value }));
}
setDirty(true);
if ((REQUIRED_KEYS as string[]).includes(key)) {
@@ -207,14 +223,17 @@ export default function NewBottlingBatchPage() {
},
{ idempotencyKey: idempotencyKey.current },
),
onSuccess: (bottlingBatch) => {
onSuccess: (bottlingBatch, acknowledged) => {
draft.clear();
idempotencyKey.current = newIdempotencyKey();
lastBody.current = null;
void queryClient.invalidateQueries({ queryKey: ["bottling-batches"] });
void queryClient.invalidateQueries({ queryKey: ["honey-batches"] });
if (bottlingBatch.warnings?.length) {
toastWarnings(bottlingBatch.warnings, "Abfüllung angelegt");
toastWarnings(
postSaveWarnings(bottlingBatch.warnings, acknowledged ? confirmation : null),
"Abfüllung angelegt",
);
} else {
toast.success("Abfüllung angelegt");
}
@@ -326,25 +345,27 @@ export default function NewBottlingBatchPage() {
: bestBeforeBase;
// The entered values against the product, before saving (hints, never a block).
const localNotes: PlausibilityWarning[] = [];
if (prefill && formValues.productId) {
if (prefill.jarSize != null && values.jarSize.trim() !== "" && Number(values.jarSize) !== prefill.jarSize) {
localNotes.push({
code: "JAR_SIZE_DIFFERS",
severity: "HINWEIS",
message: `Glasgröße weicht vom Artikel ab (Artikel: ${prefill.jarSize} g).`,
refs: [],
});
}
if (prefill.bestBeforeDate && values.bestBeforeDate && values.bestBeforeDate !== prefill.bestBeforeDate) {
localNotes.push({
code: "BEST_BEFORE_DIFFERS",
severity: "HINWEIS",
message: `MHD weicht vom Artikel ab (Artikel: ${germanDay(prefill.bestBeforeDate)}).`,
refs: [],
});
}
}
// Shown right under the field they are about, not in the live region.
const jarSizeDiffers =
prefill &&
formValues.productId &&
prefill.jarSize != null &&
values.jarSize.trim() !== "" &&
Number(values.jarSize) !== prefill.jarSize
? `Glasgröße weicht vom Artikel ab (Artikel: ${prefill.jarSize} g).`
: null;
const bestBeforeDiffers =
prefill &&
formValues.productId &&
prefill.bestBeforeDate &&
values.bestBeforeDate &&
values.bestBeforeDate !== prefill.bestBeforeDate
? `MHD weicht vom Artikel ab (Artikel: ${germanDay(prefill.bestBeforeDate)}).`
: null;
const shownAtField = [
jarSizeDiffers ? "JAR_SIZE_DIFFERS" : null,
bestBeforeDiffers ? "BEST_BEFORE_DIFFERS" : null,
].filter((code): code is string => code !== null);
const prefillLoading = prefillQuery.isFetching;
const submitDescribedBy =
@@ -420,7 +441,7 @@ export default function NewBottlingBatchPage() {
/>
)}
</Field>
<WaterContentNotes prefill={prefill} localNotes={localNotes} />
<WaterContentNotes prefill={prefill} shownAtFieldCodes={shownAtField} />
<PrefillStatus
enabled={prefillEnabled}
isLoading={prefillQuery.isLoading}
@@ -487,19 +508,22 @@ export default function NewBottlingBatchPage() {
hint={jarSizeHint}
>
{({ id, describedBy, invalid }) => (
<Input
id={id}
data-field="jarSize"
type="number"
inputMode="numeric"
min={1}
required
className="h-11"
aria-describedby={describedBy}
aria-invalid={invalid}
value={values.jarSize}
onChange={(event) => set("jarSize", event.target.value)}
/>
<>
<Input
id={id}
data-field="jarSize"
type="number"
inputMode="numeric"
min={1}
required
className="h-11"
aria-describedby={joinIds(describedBy, jarSizeDiffers ? `${id}-differs` : undefined)}
aria-invalid={invalid}
value={values.jarSize}
onChange={(event) => set("jarSize", event.target.value)}
/>
<DifferenceHint id={`${id}-differs`} message={jarSizeDiffers} />
</>
)}
</Field>
<Field
@@ -531,19 +555,22 @@ export default function NewBottlingBatchPage() {
hint={bestBeforeHint}
>
{({ id, describedBy, invalid }) => (
<Input
id={id}
data-field="bestBeforeDate"
type="date"
required
className="h-11"
aria-describedby={describedBy}
aria-invalid={invalid}
value={values.bestBeforeDate}
onChange={(event) =>
set("bestBeforeDate", event.target.value)
}
/>
<>
<Input
id={id}
data-field="bestBeforeDate"
type="date"
required
className="h-11"
aria-describedby={joinIds(describedBy, bestBeforeDiffers ? `${id}-differs` : undefined)}
aria-invalid={invalid}
value={values.bestBeforeDate}
onChange={(event) =>
set("bestBeforeDate", event.target.value)
}
/>
<DifferenceHint id={`${id}-differs`} message={bestBeforeDiffers} />
</>
)}
</Field>
</FormSection>
@@ -47,7 +47,7 @@ export function WaterContentConfirmDialog({
if (!open && !isPending) onCancel();
}}
>
<DialogContent finalFocus={returnFocusTo}>
<DialogContent role="alertdialog" finalFocus={returnFocusTo}>
<DialogHeader>
<DialogTitle>Wassergehalt zu hoch</DialogTitle>
<DialogDescription>
@@ -2,7 +2,7 @@ import { AlertTriangle, Info, ShieldAlert } from "lucide-react";
import type { ComponentType } from "react";
import { formatWaterContent } from "@/lib/bottling/format";
import type { BottlingPrefill } from "@/lib/bottling/types";
import type { PlausibilityWarning, WarningSeverity } from "@/lib/warnings/types";
import type { WarningSeverity } from "@/lib/warnings/types";
const SEVERITY_LOOK: Record<WarningSeverity, { label: string; Icon: ComponentType<{ className?: string }> }> = {
HINWEIS: { label: "Hinweis", Icon: Info },
@@ -17,17 +17,18 @@ const SEVERITY_LOOK: Record<WarningSeverity, { label: string; Icon: ComponentTyp
*/
export function WaterContentNotes({
prefill,
localNotes = [],
shownAtFieldCodes = [],
}: {
prefill: BottlingPrefill | undefined;
/** Findings the form works out itself (entered value vs product), shown as hints. */
localNotes?: PlausibilityWarning[];
/**
* Codes the form shows next to their field (entered value vs product). They
* are left out here: one live region, no second announcement.
*/
shownAtFieldCodes?: string[];
}) {
const localCodes = new Set(localNotes.map((note) => note.code));
const warnings = [
...(prefill?.warnings ?? []).filter((warning) => !localCodes.has(warning.code)),
...localNotes,
];
const warnings = (prefill?.warnings ?? []).filter(
(warning) => !shownAtFieldCodes.includes(warning.code),
);
const hasContent = (prefill !== undefined && prefill.waterContent !== null) || warnings.length > 0;
return (
<div role="status" aria-live="polite">
@@ -4,6 +4,7 @@ import { FormEvent, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { addHoneyMeasurement } from "@/lib/honey-batches/api";
import type { CreateHoneyMeasurementInput } from "@/lib/honey-batches/types";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -20,6 +21,7 @@ import { emptyToNull } from "@/lib/forms/empty-to-null";
export function AddMeasurementDialog({ honeyBatchId }: { honeyBatchId: string }) {
const queryClient = useQueryClient();
const { isOffline, hintId, describedBy } = useOnlineOnly();
const [open, setOpen] = useState(false);
const [waterContent, setWaterContent] = useState("");
const [measuredAt, setMeasuredAt] = useState(todayLocalIsoDate());
@@ -56,8 +58,15 @@ export function AddMeasurementDialog({ honeyBatchId }: { honeyBatchId: string })
}
return (
<div className="flex flex-col items-end gap-1">
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="sm">Neue Messung</Button>} />
<DialogTrigger
render={
<Button size="touch" disabled={isOffline} aria-describedby={describedBy}>
Neue Messung
</Button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Honigmessung</DialogTitle>
@@ -107,5 +116,7 @@ export function AddMeasurementDialog({ honeyBatchId }: { honeyBatchId: string })
</form>
</DialogContent>
</Dialog>
<OnlineOnlyHint id={hintId} />
</div>
);
}
@@ -10,6 +10,7 @@ import {
} from "@/lib/honey-batches/api";
import { HoneyBatch } from "@/lib/honey-batches/types";
import { fetchStorageContainers } from "@/lib/storage/api";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -26,6 +27,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
// represent.
export function HoneyBatchContainerAllocationsCard({ honeyBatch }: { honeyBatch: HoneyBatch }) {
const queryClient = useQueryClient();
const { isOffline, hintId, describedBy } = useOnlineOnly();
const [storageContainerId, setStorageContainerId] = useState("");
const [amount, setAmount] = useState("");
const [error, setError] = useState<string | null>(null);
@@ -167,9 +169,16 @@ export function HoneyBatchContainerAllocationsCard({ honeyBatch }: { honeyBatch:
{error}
</p>
) : null}
<Button type="submit" variant="outline" disabled={assignMutation.isPending}>
<Button
type="submit"
variant="outline"
size="touch"
disabled={assignMutation.isPending || isOffline}
aria-describedby={describedBy}
>
{assignMutation.isPending ? "Wird zugewiesen…" : "Behälter zuweisen"}
</Button>
<OnlineOnlyHint id={hintId} />
</form>
</CardContent>
</Card>
@@ -10,6 +10,7 @@ import { HoneyBatch, HoneyBatchStatus } from "@/lib/honey-batches/types";
import { HONEY_BATCH_STATUS_LABELS } from "@/lib/honey-batches/labels";
import { fetchExtractions } from "@/lib/extractions/api";
import { fetchStorageContainers } from "@/lib/storage/api";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -38,6 +39,8 @@ export function HoneyBatchEditDialog({ honeyBatch }: { honeyBatch: HoneyBatch })
const [amount, setAmount] = useState(honeyBatch.amount?.toString() ?? "");
const [waterContent, setWaterContent] = useState(honeyBatch.waterContent?.toString() ?? "");
const { isOffline, hintId, describedBy } = useOnlineOnly();
const { data: extractions } = useQuery({
queryKey: ["extractions"],
queryFn: () => fetchExtractions(),
@@ -72,10 +75,11 @@ export function HoneyBatchEditDialog({ honeyBatch }: { honeyBatch: HoneyBatch })
}
return (
<div className="flex flex-col items-end gap-1">
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="ghost" size="sm">
<Button variant="ghost" size="touch" disabled={isOffline} aria-describedby={describedBy}>
<Pencil className="size-3.5" />
Bearbeiten
</Button>
@@ -174,5 +178,7 @@ export function HoneyBatchEditDialog({ honeyBatch }: { honeyBatch: HoneyBatch })
</form>
</DialogContent>
</Dialog>
<OnlineOnlyHint id={hintId} />
</div>
);
}
@@ -6,6 +6,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchHoneyBatchRelocations, relocateHoneyBatch } from "@/lib/honey-batches/api";
import { HoneyBatch, RelocateHoneyBatchInput } from "@/lib/honey-batches/types";
import { fetchStorageContainers } from "@/lib/storage/api";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -25,6 +26,7 @@ import { emptyToNull } from "@/lib/forms/empty-to-null";
export function HoneyBatchStorageCard({ honeyBatch }: { honeyBatch: HoneyBatch }) {
const queryClient = useQueryClient();
const { isOffline, hintId, describedBy } = useOnlineOnly();
const [open, setOpen] = useState(false);
const [storageContainerId, setStorageContainerId] = useState("");
const [notes, setNotes] = useState("");
@@ -72,8 +74,15 @@ export function HoneyBatchStorageCard({ honeyBatch }: { honeyBatch: HoneyBatch }
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Lagerbehälter</CardTitle>
<div className="flex flex-col items-end gap-1">
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="sm">Umlagern</Button>} />
<DialogTrigger
render={
<Button size="touch" disabled={isOffline} aria-describedby={describedBy}>
Umlagern
</Button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Honigcharge umlagern</DialogTitle>
@@ -111,6 +120,8 @@ export function HoneyBatchStorageCard({ honeyBatch }: { honeyBatch: HoneyBatch }
</form>
</DialogContent>
</Dialog>
<OnlineOnlyHint id={hintId} />
</div>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
<p>
@@ -37,6 +37,16 @@ describe("HoneyBatchWithdrawDialog", () => {
expect(screen.getByRole("button", { name: "Entnahme buchen" })).toBeDisabled();
});
it("lists the reasons but not the placeholder „Grund wählen“ as a choice", async () => {
renderDialog();
await open();
await userEvent.click(screen.getByRole("combobox"));
const placeholder = await screen.findByRole("option", { name: "Grund wählen" });
expect(placeholder).toHaveAttribute("aria-disabled", "true");
expect(screen.getByRole("option", { name: "Hofladen" })).not.toHaveAttribute("aria-disabled", "true");
});
it("refuses more than the rest and less than a gram in words", async () => {
renderDialog();
const quantity = await open();
@@ -147,6 +147,7 @@ export function HoneyBatchWithdrawDialog({
value={reason}
onChange={setReason}
noneLabel="Grund wählen"
noneIsPlaceholder
labels={WITHDRAWAL_REASON_LABELS}
/>
</div>
@@ -0,0 +1,77 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { reportRequestOutcome } from "@/lib/offline/connectivity";
import type { HoneyBatch } from "@/lib/honey-batches/types";
import { HoneyBatchEditDialog } from "./honey-batch-edit-dialog";
import { HoneyBatchStorageCard } from "./honey-batch-storage-card";
import { HoneyBatchContainerAllocationsCard } from "./honey-batch-container-allocations-card";
import { AddMeasurementDialog } from "./add-measurement-dialog";
vi.mock("@/lib/honey-batches/api", () => ({
updateHoneyBatch: vi.fn(),
fetchHoneyBatchRelocations: vi.fn(async () => []),
relocateHoneyBatch: vi.fn(),
fetchHoneyBatchContainerAllocations: vi.fn(async () => []),
setHoneyBatchContainerAllocation: vi.fn(),
removeHoneyBatchContainerAllocation: vi.fn(),
addHoneyMeasurement: vi.fn(),
}));
vi.mock("@/lib/extractions/api", () => ({ fetchExtractions: vi.fn(async () => []) }));
vi.mock("@/lib/storage/api", () => ({ fetchStorageContainers: vi.fn(async () => []) }));
const HONEY_BATCH = {
id: "h1",
batchNumber: "H-1",
extractionId: "x1",
honeyType: null,
amount: 20,
waterContent: null,
storageContainerId: null,
storageContainer: null,
status: "STORED",
createdAt: "2026-01-01T00:00:00.000Z",
measurements: [],
} as HoneyBatch;
const ACTIONS: { name: string; button: RegExp; ui: () => React.ReactElement }[] = [
{ name: "Bearbeiten", button: /Bearbeiten/, ui: () => <HoneyBatchEditDialog honeyBatch={HONEY_BATCH} /> },
{ name: "Umlagern", button: /^Umlagern$/, ui: () => <HoneyBatchStorageCard honeyBatch={HONEY_BATCH} /> },
{
name: "Behälter zuweisen",
button: /Behälter zuweisen/,
ui: () => <HoneyBatchContainerAllocationsCard honeyBatch={HONEY_BATCH} />,
},
{ name: "Neue Messung", button: /Neue Messung/, ui: () => <AddMeasurementDialog honeyBatchId="h1" /> },
];
function renderUi(ui: React.ReactElement) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
}
describe("online-only actions of the honey batch page", () => {
beforeEach(() => act(() => reportRequestOutcome("reached-server")));
afterEach(() => act(() => reportRequestOutcome("reached-server")));
for (const action of ACTIONS) {
it(`${action.name}: enabled and 44 px high online, no hint`, () => {
renderUi(action.ui());
const button = screen.getByRole("button", { name: action.button });
expect(button).toBeEnabled();
expect(button).toHaveClass("h-11");
expect(screen.queryByText("Nur mit Verbindung")).not.toBeInTheDocument();
});
it(`${action.name}: disabled offline with the visible reason „Nur mit Verbindung“`, () => {
renderUi(action.ui());
act(() => reportRequestOutcome("network-error"));
const button = screen.getByRole("button", { name: action.button });
const reason = screen.getByText("Nur mit Verbindung");
expect(button).toBeDisabled();
expect(button.getAttribute("aria-describedby")).toBe(reason.id);
expect(button).toHaveClass("h-11");
});
}
});
@@ -134,4 +134,17 @@ describe("CreateHoneyBatchDialog", () => {
expect(createHoneyBatch).toHaveBeenCalled();
expect(toastWarnings).toHaveBeenCalledWith([{ code: "X" }], "Honigcharge angelegt");
});
it("omits an empty batch number instead of sending null (the DTO rejects null)", async () => {
vi.mocked(createHoneyBatch).mockResolvedValue({ id: "b1" } as never);
renderDialog();
await openAndChooseExtraction();
await userEvent.click(screen.getByRole("button", { name: "Anlegen" }));
const payload = vi.mocked(createHoneyBatch).mock.calls[0][0];
expect(payload.extractionId).toBe("x1");
expect(payload.batchNumber).toBeUndefined();
expect(JSON.parse(JSON.stringify(payload))).not.toHaveProperty("batchNumber");
});
});
@@ -86,8 +86,9 @@ export function CreateHoneyBatchDialog() {
return;
}
mutation.mutate({
// Empty: the server generates the number (automation format).
batchNumber: emptyToNull(batchNumber),
// Empty: omitted, the server generates the number (automation format).
// The create DTO is IsOptionalNotNull, so null would be a 400.
batchNumber: emptyToNull(batchNumber) ?? undefined,
extractionId,
honeyType: emptyToNull(honeyType),
amount: emptyToNull(amount, Number),
@@ -93,4 +93,32 @@ describe("ProductDetailPage", () => {
const link = (await screen.findByText("K-1")).closest("a");
expect(link).toHaveAttribute("href", "/production-batches/b1");
});
it("shows the jar weight and the shelf life in the details when they are set", async () => {
vi.mocked(fetchProduct).mockResolvedValue({
id: "candle",
name: "Honig 250",
description: null,
sku: null,
packagingSize: 250,
shelfLifeMonths: 18,
weight: null,
salesUnit: null,
status: "ACTIVE",
kind: "BOTTLED_HONEY",
shopCategory: "HONIG",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
renderPage();
expect(await screen.findByText("Gewicht pro Glas: 250 g")).toBeInTheDocument();
expect(screen.getByText("Haltbarkeit: 18 Monate")).toBeInTheDocument();
});
it("hides jar weight and shelf life when they are not set", async () => {
renderPage();
await screen.findByTestId("total-remaining");
expect(screen.queryByText(/Gewicht pro Glas/)).not.toBeInTheDocument();
expect(screen.queryByText(/Haltbarkeit/)).not.toBeInTheDocument();
});
});
@@ -79,6 +79,12 @@ export default function ProductDetailPage() {
<p>
Kategorie im Webshop: {SHOP_CATEGORY_LABELS[product.shopCategory]}
</p>
{product.packagingSize != null ? (
<p>Gewicht pro Glas: {product.packagingSize.toLocaleString("de-DE")} g</p>
) : null}
{product.shelfLifeMonths != null ? (
<p>Haltbarkeit: {product.shelfLifeMonths} Monate</p>
) : null}
{product.sku ? <p>SKU: {product.sku}</p> : null}
{product.salesUnit ? (
<p>Verkaufseinheit: {product.salesUnit}</p>
@@ -36,6 +36,13 @@ async function openDialog(product: Product = PRODUCT) {
}
describe("EditProductDialog", () => {
it("labels the second weight field so it cannot be mistaken for the jar weight", async () => {
await openDialog();
expect(screen.getByLabelText(/Gewicht pro Glas \(g\)/)).toBeInTheDocument();
expect(screen.getByLabelText(/Artikelgewicht gesamt \(g\)/)).toBeInTheDocument();
expect(screen.queryByLabelText(/^Gewicht$/)).not.toBeInTheDocument();
});
beforeEach(() => {
vi.mocked(updateProduct).mockReset();
vi.mocked(updateProduct).mockResolvedValue(PRODUCT);
@@ -205,15 +205,20 @@ export function EditProductDialog({ product }: { product: Product }) {
) : null}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="product-edit-weight">Gewicht</Label>
<Label htmlFor="product-edit-weight">Artikelgewicht gesamt (g)</Label>
<Input
id="product-edit-weight"
type="number"
inputMode="decimal"
step="0.01"
min={0}
aria-describedby="product-edit-weight-hint"
value={weight}
onChange={(event) => setWeight(event.target.value)}
/>
<p id="product-edit-weight-hint" className="text-xs text-muted-foreground">
Gewicht des verpackten Artikels in Gramm, getrennt vom Gewicht pro Glas.
</p>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="product-edit-status">Status</Label>
@@ -221,8 +221,8 @@ export default function NewProductPage() {
fieldCount={3}
>
<Field
label="Gewicht (g)"
hint="in Gramm"
label="Artikelgewicht gesamt (g)"
hint="Gewicht des verpackten Artikels in Gramm, getrennt vom Gewicht pro Glas"
>
{({ id, describedBy }) => (
<Input
@@ -35,6 +35,12 @@ describe("DangerZoneSection", () => {
logoutMock.mockResolvedValue(undefined);
});
it("the delete button is 44 px high", () => {
renderZone();
expect(screen.getByRole("button", { name: /Konto löschen/ })).toHaveClass("h-11");
});
it("signs out and deletes the unsent entries of a deactivated account", async () => {
vi.mocked(deactivateAccount).mockResolvedValue(undefined);
renderZone();
@@ -58,7 +58,7 @@ function DeactivateAccountDialog() {
return (
<Dialog open={open} onOpenChange={(next) => (next ? undefined : handleClose())}>
<Button type="button" variant="destructive" onClick={() => setOpen(true)}>
<Button type="button" size="touch" variant="destructive" onClick={() => setOpen(true)}>
<Trash2 className="size-4" />
Konto löschen
</Button>
@@ -88,10 +88,10 @@ function DeactivateAccountDialog() {
</p>
) : null}
<DialogFooter>
<Button type="button" variant="outline" onClick={handleClose}>
<Button type="button" size="touch" variant="outline" onClick={handleClose}>
Abbrechen
</Button>
<Button type="submit" variant="destructive" disabled={mutation.isPending}>
<Button type="submit" size="touch" variant="destructive" disabled={mutation.isPending}>
{mutation.isPending ? "Wird deaktiviert…" : "Konto löschen"}
</Button>
</DialogFooter>
@@ -85,7 +85,7 @@ function EnrollDialog({ onEnabled }: { onEnabled: () => void }) {
return (
<Dialog open={open} onOpenChange={(next) => (next ? undefined : handleClose())}>
<Button type="button" onClick={handleTrigger}>
<Button type="button" size="touch" onClick={handleTrigger}>
Einrichten
</Button>
<DialogContent>
@@ -134,10 +134,10 @@ function EnrollDialog({ onEnabled }: { onEnabled: () => void }) {
</p>
) : null}
<DialogFooter>
<Button type="button" variant="outline" onClick={handleClose}>
<Button type="button" size="touch" variant="outline" onClick={handleClose}>
Abbrechen
</Button>
<Button type="submit" disabled={confirmMutation.isPending}>
<Button type="submit" size="touch" disabled={confirmMutation.isPending}>
{confirmMutation.isPending ? "Prüfe…" : "Bestätigen"}
</Button>
</DialogFooter>
@@ -183,7 +183,7 @@ function DisableDialog({ onDisabled }: { onDisabled: () => void }) {
return (
<Dialog open={open} onOpenChange={(next) => (next ? undefined : handleClose())}>
<Button type="button" variant="outline" onClick={() => setOpen(true)}>
<Button type="button" size="touch" variant="outline" onClick={() => setOpen(true)}>
Deaktivieren
</Button>
<DialogContent>
@@ -224,10 +224,10 @@ function DisableDialog({ onDisabled }: { onDisabled: () => void }) {
</p>
) : null}
<DialogFooter>
<Button type="button" variant="outline" onClick={handleClose}>
<Button type="button" size="touch" variant="outline" onClick={handleClose}>
Abbrechen
</Button>
<Button type="submit" variant="destructive" disabled={disableMutation.isPending}>
<Button type="submit" size="touch" variant="destructive" disabled={disableMutation.isPending}>
{disableMutation.isPending ? "Wird deaktiviert…" : "Deaktivieren"}
</Button>
</DialogFooter>
@@ -92,4 +92,12 @@ describe("ProfilePage", () => {
expect(vi.mocked(uploadAvatar).mock.calls[0]?.[0]).toBe(file);
await vi.waitFor(() => expect(refreshUserMock).toHaveBeenCalled());
});
it("keeps the profile buttons at 44 px (touch size)", async () => {
renderWithClient(<ProfilePage />);
for (const name of ["Bild wählen", "Speichern", "Passwort ändern"]) {
expect(await screen.findByRole("button", { name })).toHaveClass("h-11");
}
});
});
@@ -87,6 +87,7 @@ function AvatarSection() {
/>
<Button
type="button"
size="touch"
variant="outline"
disabled={uploadMutation.isPending}
onClick={() => fileInputRef.current?.click()}
@@ -96,6 +97,7 @@ function AvatarSection() {
{user.hasAvatar ? (
<Button
type="button"
size="touch"
variant="ghost"
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate()}
@@ -205,7 +207,7 @@ function ProfileInfoSection() {
{error}
</p>
) : null}
<Button type="submit" className="self-start" disabled={mutation.isPending}>
<Button type="submit" size="touch" className="self-start" disabled={mutation.isPending}>
{mutation.isPending ? "Speichert…" : "Speichern"}
</Button>
</form>
@@ -276,7 +278,7 @@ function PasswordSection() {
{error}
</p>
) : null}
<Button type="submit" className="self-start" disabled={mutation.isPending}>
<Button type="submit" size="touch" className="self-start" disabled={mutation.isPending}>
{mutation.isPending ? "Ändert…" : "Passwort ändern"}
</Button>
</form>
@@ -72,7 +72,7 @@ function renderCard() {
async function connect(user: ReturnType<typeof userEvent.setup>) {
await user.click(await screen.findByRole("button", { name: "Telegram verbinden" }));
const dialog = await screen.findByRole("dialog");
await user.type(within(dialog).getByLabelText("Ihr Passwort"), "geheim");
await user.type(within(dialog).getByLabelText("Dein Passwort"), "geheim");
await user.click(within(dialog).getByRole("button", { name: "Link erzeugen" }));
}
@@ -105,7 +105,7 @@ describe("TelegramLinkCard", () => {
renderCard();
await user.click(await screen.findByRole("button", { name: "Telegram verbinden" }));
const dialog = await screen.findByRole("dialog");
await user.type(within(dialog).getByLabelText("Ihr Passwort"), "geheim");
await user.type(within(dialog).getByLabelText("Dein Passwort"), "geheim");
await user.type(within(dialog).getByLabelText("Code aus der Authenticator-App"), "123456");
await user.click(within(dialog).getByRole("button", { name: "Link erzeugen" }));
@@ -197,7 +197,7 @@ describe("TelegramLinkCard", () => {
renderCard();
await user.click(await screen.findByRole("button", { name: "Trennen" }));
const dialog = await screen.findByRole("dialog");
const dialog = await screen.findByRole("alertdialog");
expect(unlinkTelegram).not.toHaveBeenCalled();
await user.click(within(dialog).getByRole("button", { name: "Trennen" }));
@@ -222,7 +222,7 @@ describe("TelegramLinkCard", () => {
vi.mocked(fetchNotificationSettings).mockResolvedValue(notLinked);
await user.click(await screen.findByRole("button", { name: "Trennen" }));
const dialog = await screen.findByRole("dialog");
const dialog = await screen.findByRole("alertdialog");
await user.click(within(dialog).getByRole("button", { name: "Trennen" }));
await screen.findByRole("button", { name: "Telegram verbinden" });
@@ -235,7 +235,7 @@ describe("TelegramLinkCard", () => {
renderCard();
await user.click(await screen.findByRole("button", { name: "Trennen" }));
const dialog = await screen.findByRole("dialog");
const dialog = await screen.findByRole("alertdialog");
await user.click(within(dialog).getByRole("button", { name: "Trennen" }));
const connectButton = await screen.findByRole("button", { name: "Telegram verbinden" });
@@ -281,7 +281,7 @@ export function TelegramLinkCard() {
open={isStepUpOpen}
onOpenChange={setIsStepUpOpen}
title="Telegram verbinden?"
description="Zur Sicherheit fragen wir noch einmal nach Ihrem Passwort. Danach erscheint ein Link, der 10 Minuten gilt."
description="Zur Sicherheit fragen wir noch einmal nach deinem Passwort. Danach erscheint ein Link, der 10 Minuten gilt."
confirmLabel="Link erzeugen"
needsMfa={Boolean(user?.mfaEnabled)}
onConfirm={createLink}
@@ -178,17 +178,32 @@ describe("BackupPage", () => {
});
await userEvent.click(screen.getByRole("button", { name: "Backup importieren" }));
const dialog = await screen.findByRole("dialog");
await userEvent.type(within(dialog).getByLabelText("Ihr Passwort"), "geheim");
await userEvent.type(within(dialog).getByLabelText("Dein Passwort"), "geheim");
await userEvent.click(within(dialog).getByRole("button", { name: "Einspielen" }));
}
it("explains why „Backup importieren“ is off until a file is chosen", async () => {
vi.mocked(getBackupSchedule).mockResolvedValue(schedule());
renderWithClient(<BackupPage />);
await screen.findByText("Aktiv");
const button = screen.getByRole("button", { name: "Backup importieren" });
expect(button).toBeDisabled();
expect(button).toHaveAccessibleDescription("Bitte zuerst eine Backup-Datei wählen.");
fireEvent.change(screen.getByLabelText("Backup-Datei einspielen"), {
target: { files: [new File(["{}"], "backup.json", { type: "application/json" })] },
});
expect(button).toBeEnabled();
expect(screen.queryByText("Bitte zuerst eine Backup-Datei wählen.")).toBeNull();
});
it("the backup import still asks for the password through StepUpDialog", async () => {
vi.mocked(getBackupSchedule).mockResolvedValue(schedule());
vi.mocked(importBackup).mockResolvedValue({ imported: { apiary: 1 }, skipped: [], sealed: true });
renderWithClient(<BackupPage />);
expect(await screen.findByText("Aktiv")).toBeInTheDocument();
// No loose password field on the page any more.
expect(screen.queryByLabelText("Ihr Passwort")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Dein Passwort")).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText("Backup-Datei einspielen"), {
target: { files: [new File(["{}"], "backup.json", { type: "application/json" })] },
});
@@ -196,7 +211,7 @@ describe("BackupPage", () => {
await userEvent.click(screen.getByRole("button", { name: "Backup importieren" }));
const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByRole("button", { name: "Einspielen" })).toBeDisabled();
await userEvent.type(within(dialog).getByLabelText("Ihr Passwort"), "geheim");
await userEvent.type(within(dialog).getByLabelText("Dein Passwort"), "geheim");
await userEvent.click(within(dialog).getByRole("button", { name: "Einspielen" }));
await vi.waitFor(() =>
@@ -222,7 +237,7 @@ describe("BackupPage", () => {
expect(toast.error).not.toHaveBeenCalled();
// Still open, file still chosen: a second try needs only the password.
await userEvent.type(within(dialog).getByLabelText("Ihr Passwort"), "richtig");
await userEvent.type(within(dialog).getByLabelText("Dein Passwort"), "richtig");
await userEvent.click(within(dialog).getByRole("button", { name: "Einspielen" }));
await vi.waitFor(() => expect(importBackup).toHaveBeenCalledTimes(2));
expect(vi.mocked(importBackup).mock.calls[1]?.[0]).toBeInstanceOf(File);
@@ -165,17 +165,23 @@ function BackupSection() {
className="text-sm file:mr-3 file:rounded-lg file:border-0 file:bg-muted file:px-3 file:py-2 file:text-sm file:font-medium"
/>
<p className="text-muted-foreground">
Einspielen überschreibt Konten und Daten. Zur Bestätigung fragen wir Ihr Passwort
Einspielen überschreibt Konten und Daten. Zur Bestätigung fragen wir dein Passwort
{needsMfa ? " und den aktuellen Code aus der Authenticator-App" : ""}.
</p>
<Button
type="button"
variant="outline"
disabled={!file || importMutation.isPending}
aria-describedby={file ? undefined : "backup-import-hint"}
onClick={() => setIsImportDialogOpen(true)}
>
Backup importieren
</Button>
{file ? null : (
<p id="backup-import-hint" className="text-xs text-muted-foreground">
Bitte zuerst eine Backup-Datei wählen.
</p>
)}
<StepUpDialog
open={isImportDialogOpen}
onOpenChange={setIsImportDialogOpen}
@@ -149,7 +149,7 @@ describe("TelegramCard", () => {
expect(toast.success).toHaveBeenCalledWith("Wird erneut versucht");
await user.click(screen.getByRole("button", { name: "Alle fehlgeschlagenen erneut versuchen" }));
const dialog = await screen.findByRole("dialog");
const dialog = await screen.findByRole("alertdialog");
expect(dialog).toHaveTextContent("Fehlgeschlagene Nachrichten erneut senden?");
expect(dialog).toHaveTextContent("Mindestens 1 sind fehlgeschlagen.");
expect(dialog).toHaveTextContent("Es werden höchstens 100 gesendet, neueste zuerst.");
@@ -168,7 +168,7 @@ describe("TelegramCard", () => {
await screen.findByRole("button", { name: "Alle fehlgeschlagenen erneut versuchen" }),
);
const dialog = await screen.findByRole("dialog");
const dialog = await screen.findByRole("alertdialog");
expect(dialog).toHaveTextContent("Fehlgeschlagene Nachrichten erneut senden?");
expect(dialog).toHaveTextContent("Mindestens 150 sind fehlgeschlagen.");
expect(dialog).toHaveTextContent("Es werden höchstens 100 gesendet, neueste zuerst.");
@@ -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));
@@ -12,8 +12,8 @@ describe("stepUpErrorText", () => {
});
it("passes a German reauth message through unchanged", () => {
expect(stepUpErrorText(new ApiError(401, "Das Passwort ist falsch."), "x")).toBe(
"Das Passwort ist falsch.",
expect(stepUpErrorText(new ApiError(401, "Das Passwort stimmt nicht."), "x")).toBe(
"Das Passwort stimmt nicht.",
);
});
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { postSaveWarnings } from "./post-save-warnings";
import type { PlausibilityWarning } from "@/lib/warnings/types";
const WATER: PlausibilityWarning = {
code: "WATER_CONTENT_ABOVE_LEGAL",
severity: "BESTAETIGEN",
message: "Der Wassergehalt (22 %) liegt über der Grenze. Abfüllen nur mit Bestätigung.",
refs: [],
};
const OTHER: PlausibilityWarning = {
code: "WITHDRAWAL_ACTIVE",
severity: "WARNUNG",
message: "Wartezeit läuft.",
refs: [],
};
describe("postSaveWarnings", () => {
it("words the confirmed water content as a fact with the value", () => {
const [warning] = postSaveWarnings([WATER], 22);
expect(warning.message).toBe("Wassergehalt 22\u00a0% über der Grenze – mit Bestätigung abgefüllt.");
expect(warning.severity).toBe("BESTAETIGEN");
});
it("falls back to a text without the value and leaves other warnings alone", () => {
const result = postSaveWarnings([WATER, OTHER], null);
expect(result[0].message).toBe("Wassergehalt über der Grenze – mit Bestätigung abgefüllt.");
expect(result[1]).toBe(OTHER);
});
});
@@ -0,0 +1,25 @@
import type { PlausibilityWarning } from "@/lib/warnings/types";
import { formatWaterContent } from "./format";
import { WATER_CONTENT_ABOVE_LEGAL } from "./types";
/**
* The server words its warnings for the time before saving ("... Abfüllen nur
* mit Bestätigung."). After a confirmed save that text is out of date, so the
* confirmed finding is worded as the fact it now is.
*/
export function postSaveWarnings(
warnings: readonly PlausibilityWarning[],
confirmedWaterContent: number | null,
): PlausibilityWarning[] {
return warnings.map((warning) =>
warning.code === WATER_CONTENT_ABOVE_LEGAL
? {
...warning,
message:
confirmedWaterContent === null
? "Wassergehalt über der Grenze – mit Bestätigung abgefüllt."
: `Wassergehalt ${formatWaterContent(confirmedWaterContent)} über der Grenze – mit Bestätigung abgefüllt.`,
}
: warning,
);
}
@@ -92,15 +92,75 @@ describe("fetch-with-timeout", () => {
return new Response(new ReadableStream({ start() {} }), { status: 200 });
}
it("a stalled body ends in TimeoutError and reports network-error", async () => {
async function timeOut(): Promise<void> {
vi.stubGlobal("fetch", vi.fn(hangingFetch));
const pending = fetchReportingOutcome("/x", { timeoutMs: 4_000 });
const assertion = expect(pending).rejects.toMatchObject({ name: "TimeoutError" });
await vi.advanceTimersByTimeAsync(4_000);
await assertion;
}
async function answer(): Promise<void> {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("{}", { status: 200 })));
await fetchReportingOutcome("/x", { timeoutMs: 4_000 });
}
it("one timeout leaves the app online, two in a row set offline", async () => {
reportRequestOutcome("reached-server");
await timeOut();
expect(getConnectivity()).toBe("online");
await timeOut();
expect(getConnectivity()).toBe("offline");
await answer();
expect(getConnectivity()).toBe("online");
});
it("timeout, answer, timeout stays online", async () => {
reportRequestOutcome("reached-server");
await timeOut();
await answer();
await timeOut();
expect(getConnectivity()).toBe("online");
});
it("a caller abort is never counted", async () => {
reportRequestOutcome("reached-server");
await timeOut();
for (let i = 0; i < 3; i += 1) {
vi.stubGlobal("fetch", vi.fn(hangingFetch));
const caller = new AbortController();
const pending = fetchReportingOutcome("/x", { timeoutMs: 4_000, signal: caller.signal });
const assertion = expect(pending).rejects.toMatchObject({ name: "AbortError" });
caller.abort(new DOMException("weg", "AbortError"));
await assertion;
}
expect(getConnectivity()).toBe("online");
await timeOut();
expect(getConnectivity()).toBe("offline");
});
it("a stalled body ends in TimeoutError and one such timeout does not flip the app offline", async () => {
reportRequestOutcome("reached-server");
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(stalledBodyResponse()));
const pending = fetchReportingOutcome("/x", { timeoutMs: 4_000 });
const assertion = expect(pending).rejects.toMatchObject({ name: "TimeoutError" });
await vi.advanceTimersByTimeAsync(4_000);
await assertion;
expect(getConnectivity()).toBe("online");
});
it("a timed-out request does not clear an offline state set by a real network error", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch")));
await expect(fetchReportingOutcome("/x", { timeoutMs: 4_000 })).rejects.toBeInstanceOf(TypeError);
expect(getConnectivity()).toBe("offline");
reportRequestOutcome("reached-server");
vi.stubGlobal("fetch", vi.fn(hangingFetch));
const pending = fetchReportingOutcome("/x", { timeoutMs: 4_000 });
const assertion = expect(pending).rejects.toMatchObject({ name: "TimeoutError" });
await vi.advanceTimersByTimeAsync(4_000);
await assertion;
expect(getConnectivity()).toBe("offline");
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("{}", { status: 200 })));
await fetchReportingOutcome("/x", { timeoutMs: 4_000 });
expect(getConnectivity()).toBe("online");
});
it("returns a readable response once headers and body arrived in time", async () => {
@@ -87,9 +87,9 @@ export function isNetworkError(error: unknown): boolean {
}
// fetchWithTimeout plus the connectivity signal: ANY HTTP answer (including
// 502/503/504 from a proxy) counts as "reached-server"; a timeout or network
// TypeError counts as "network-error". A caller-initiated AbortError
// (unmount/cancel) says nothing about the network and is not reported.
// 502/503/504 from a proxy) counts as "reached-server" (clears offline at once);
// a network TypeError counts as "network-error". A timeout or abort (our own
// 4 s deadline, unmount, cancel) says nothing about the network: not reported.
export async function fetchReportingOutcome(
input: RequestInfo | URL,
init: RequestInit & { timeoutMs: number },
@@ -102,8 +102,12 @@ export async function fetchReportingOutcome(
reportRequestOutcome("reached-server");
return response;
} catch (error) {
if (isNetworkError(error) && !(error instanceof DOMException && error.name === "AbortError")) {
// A TypeError proves the network is gone. Our own timeout counts only when
// repeated (see connectivity.ts); a caller abort says nothing.
if (error instanceof TypeError) {
reportRequestOutcome("network-error");
} else if (error instanceof DOMException && error.name === "TimeoutError") {
reportRequestOutcome("timeout");
}
throw error;
}
@@ -1,12 +1,16 @@
import { useSyncExternalStore } from "react";
export type Connectivity = "online" | "offline";
export type RequestOutcome = "reached-server" | "network-error";
export type RequestOutcome = "reached-server" | "network-error" | "timeout";
/** Consecutive timeouts without any HTTP answer that count as offline. */
export const TIMEOUTS_BEFORE_OFFLINE = 2;
// Offline = the browser says so, or the last request failed at network level
// and no request has reached the server since. navigator.onLine alone is
// unreliable (it stays true on a captive portal or a dead uplink).
let lastOutcome: RequestOutcome = "reached-server";
let lastOutcome: "reached-server" | "network-error" = "reached-server";
let consecutiveTimeouts = 0;
const listeners = new Set<() => void>();
function notify(): void {
@@ -14,6 +18,14 @@ function notify(): void {
}
export function reportRequestOutcome(outcome: RequestOutcome): void {
if (outcome === "timeout") {
// One slow request proves nothing; two in a row without an answer do.
consecutiveTimeouts += 1;
if (consecutiveTimeouts < TIMEOUTS_BEFORE_OFFLINE) return;
outcome = "network-error";
} else {
consecutiveTimeouts = 0;
}
if (outcome === lastOutcome) {
return;
}
@@ -55,4 +55,6 @@ export interface PhenologyState {
gts: number | null;
gtsThroughDay: string | null;
note: SeasonNote;
/** Days between inspections now; null in winter or without the interval rule. */
intervalDays: number | null;
}
@@ -1,3 +1,5 @@
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import {
DOMAIN_EVENT_LABELS,
@@ -55,3 +57,20 @@ describe("system labels", () => {
expect(formatAge(86400)).toBe("vor 1 Tag");
});
});
describe("DOMAIN_EVENT_LABELS completeness", () => {
it("names PhenologyRecomputeRequested and WinterCheckRecorded", () => {
expect(DOMAIN_EVENT_LABELS.PhenologyRecomputeRequested).toBe("Saison neu berechnen angefordert");
expect(DOMAIN_EVENT_LABELS.WinterCheckRecorded).toBe("Winterkontrolle erfasst");
});
it("has a German label for every backend domain event type", () => {
const catalog = resolve(process.cwd(), "../ApiCore-Backend/src/domain-events/domain-event-types.ts");
if (!existsSync(catalog)) return; // frontend checked out alone
const source = readFileSync(catalog, "utf8");
const block = source.slice(source.indexOf("const CATALOG"), source.indexOf("DOMAIN_EVENT_TYPES"));
const types = [...block.matchAll(/^\s+([A-Z][A-Za-z]+): true,/gm)].map((match) => match[1]);
expect(types.length).toBeGreaterThan(20);
expect(types.filter((type) => !(type in DOMAIN_EVENT_LABELS))).toEqual([]);
});
});
@@ -55,6 +55,8 @@ export const DOMAIN_EVENT_LABELS: Record<string, string> = {
HoneyBatchCorrected: "Honigcharge korrigiert",
PhenologyReported: "Pflanzenmeldung erfasst",
PhenologyCorrected: "Pflanzenmeldung korrigiert",
PhenologyRecomputeRequested: "Saison neu berechnen angefordert",
WinterCheckRecorded: "Winterkontrolle erfasst",
SeasonPhaseChanged: "Saisonabschnitt gewechselt",
};