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

@@ -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",
};