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:
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",
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user