fix: T4a browser check findings
Backend Image / build-and-push (push) Failing after 2m45s
Frontend Image / build-and-push (push) Successful in 5m52s

- Withdrawal wording from one backend helper everywhere ("bis
  einschließlich TT.MM. – ab TT.MM. wieder erntefähig", year when it
  crosses New Year); withdrawal-status returns lastBlockedDay and the
  ready text, the frontend no longer computes dates
- Calendar events name colony or apiary, withdrawal events carry
  product and detail; compact withdrawal rows on mobile
- One bottling warning per colony and treatment; pre-save hint
- Offline: online-only actions disabled with a reason, cached data
  shows its timestamp, open sheets block submit offline
- Mobile: no horizontal scroll at 360 px, touch targets >= 44 px,
  toasts and the add button no longer cover content
- Cleanup sheet without destructive preselection; natural sort of
  colony codes; collapsible automation rule cards; varroa list newest
  first with chart axes; text fixes

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 18:26:09 +02:00
1 parent 32c61c8e14
commit c2f71bcb03
101 files changed
+2189 -271

No files matched your search

@@ -11,6 +11,7 @@ import {
getPack,
isPackStale,
listPacks,
livingColoniesFromPacks,
loadPack,
packAgeText,
purgeOldPacks,
@@ -308,3 +309,14 @@ describe("offline packs", () => {
});
});
});
describe("livingColoniesFromPacks order", () => {
it("sorts colony codes naturally (V-2 before V-10)", () => {
const pack = storedPack(
offlinePack({
colonies: [packColony("c1", "V-10"), packColony("c2", "V-2"), packColony("c3", "V-010")],
}),
);
expect(livingColoniesFromPacks([pack]).map((colony) => colony.code)).toEqual(["V-2", "V-010", "V-10"]);
});
});
@@ -1,3 +1,4 @@
import { compareColonyCodes } from "@/lib/colonies/sort";
import { ApiError, apiFetch } from "@/lib/auth/api-client";
import { isLivingColony } from "@/lib/colonies/status";
import { isNetworkError } from "@/lib/http/fetch-with-timeout";
@@ -221,9 +222,7 @@ export function livingColoniesFromPacks(packs: readonly StoredPack[]): PackColon
}
}
}
return [...byId.values()].sort((a, b) =>
a.code.localeCompare(b.code, "de", { numeric: true }),
);
return [...byId.values()].sort((a, b) => compareColonyCodes(a.code, b.code));
}
/**
@@ -1,4 +1,4 @@
import { AutomationTriggerType } from "./types";
import { AutomationRule, AutomationTriggerType } from "./types";
export const RULE_LABELS: Record<AutomationTriggerType, string> = {
QUEENLESS_NO_EGGS: "Keine Königin gesehen und keine Stifte",
@@ -307,3 +307,20 @@ export const SIMPLE_RULE_NOTIFY_DEFAULTS: Partial<Record<AutomationTriggerType,
TREATMENT_FOLLOW_UP: false,
FEEDING_FOLLOW_UP: false,
};
/**
* One line for the closed rule card: the due time and the extra notification
* when the rule has them, else what the rule does (the badge shows on/off).
*/
export function ruleSummary(rule: AutomationRule): string {
const config = (rule.config ?? {}) as { dueInDays?: unknown; notify?: unknown; format?: unknown };
const parts: string[] = [];
if (typeof config.format === "string" && config.format !== "") parts.push(`Format ${config.format}`);
if (typeof config.dueInDays === "number") {
parts.push(`Frist ${config.dueInDays} ${config.dueInDays === 1 ? "Tag" : "Tage"}`);
}
const notify =
typeof config.notify === "boolean" ? config.notify : SIMPLE_RULE_NOTIFY_DEFAULTS[rule.triggerType];
if (notify) parts.push("meldet zusätzlich");
return parts.length > 0 ? parts.join(" · ") : RULE_DESCRIPTIONS[rule.triggerType];
}
@@ -0,0 +1,56 @@
import { useQuery } from "@tanstack/react-query";
import { fetchHoneyBatchTraceability } from "@/lib/honey-batches/api";
import { fetchWithdrawalStatus } from "@/lib/treatments/api";
import { compareColonyCodes } from "@/lib/colonies/sort";
import { toCalendarDay } from "@/lib/dates";
export interface BottlingWithdrawalHint {
colonyId: string;
colonyCode: string;
product: string;
/** Server text: "bis einschließlich 26.10. – ab 27.10. wieder erntefähig". */
withdrawalText: string;
}
/**
* The withdrawal windows (Wartezeit) the honey of a batch was harvested in:
* each harvest is checked at its own day for its own colonies. One hint per
* colony and treatment, however many harvests of the batch it fed.
*/
export async function loadBottlingWithdrawalHints(
honeyBatchId: string,
): Promise<BottlingWithdrawalHint[]> {
const { harvests } = await fetchHoneyBatchTraceability(honeyBatchId);
const perHarvest = await Promise.all(
harvests.map(async (harvest) => {
const statuses = await fetchWithdrawalStatus(
harvest.colonies.map((colony) => colony.id),
toCalendarDay(harvest.date),
);
return statuses.flatMap((status) => {
const colony = harvest.colonies.find((candidate) => candidate.id === status.colonyId);
return colony ? [{ ...status, colonyCode: colony.code }] : [];
});
}),
);
const unique = new Map<string, BottlingWithdrawalHint>();
for (const status of perHarvest.flat()) {
unique.set(`${status.colonyId}|${status.product}|${status.withdrawalText}`, status);
}
return [...unique.values()].sort((left, right) =>
compareColonyCodes(left.colonyCode, right.colonyCode),
);
}
/** Hints for the chosen honey batch; `failed` when the check could not be made. */
export function useBottlingWithdrawalHints(honeyBatchId: string): {
hints: BottlingWithdrawalHint[];
failed: boolean;
} {
const query = useQuery({
queryKey: ["honey-batches", honeyBatchId, "withdrawal-hints"],
queryFn: () => loadBottlingWithdrawalHints(honeyBatchId),
enabled: honeyBatchId !== "",
});
return { hints: query.data ?? [], failed: query.isError };
}
@@ -1,4 +1,5 @@
"use client";
import { compareColonyCodes } from "@/lib/colonies/sort";
import { useQuery } from "@tanstack/react-query";
import { fetchApiaries } from "@/lib/apiaries/api";
@@ -28,7 +29,7 @@ interface Choices<T> {
const BULK_CHOICES_KEY = ["bulk-choices"] as const;
function byCode(a: BulkColony, b: BulkColony): number {
return a.code.localeCompare(b.code, "de", { numeric: true });
return compareColonyCodes(a.code, b.code);
}
function livingOf(colonies: readonly BulkColony[]): BulkColony[] {
@@ -16,6 +16,12 @@ export interface CalendarEvent {
title: string;
relatedType: string;
relatedId: string;
/** Colony code, or apiary name for harvests and apiary tasks; absent for general tasks. */
context?: string;
/** WITHDRAWAL only: the treatment product. */
product?: string;
/** WITHDRAWAL only: "bis einschließlich 26.10. – ab 27.10. wieder erntefähig". */
detail?: string;
}
export interface CalendarQuery {
@@ -0,0 +1,15 @@
import { describe, expect, it, vi } from "vitest";
import { apiFetch } from "@/lib/auth/api-client";
import { fetchColonies } from "./api";
vi.mock("@/lib/auth/api-client", () => ({ apiFetch: vi.fn() }));
describe("fetchColonies", () => {
it("returns the colonies in natural code order (V-02 and V-2 before V-10)", async () => {
vi.mocked(apiFetch).mockResolvedValue([{ code: "V-010" }, { code: "V-2" }, { code: "V-10" }, { code: "V-02" }]);
const colonies = await fetchColonies();
expect(colonies.map((colony) => colony.code)).toEqual(["V-02", "V-2", "V-010", "V-10"]);
});
});
+2 -1
View File
@@ -1,4 +1,5 @@
import { apiFetch } from "@/lib/auth/api-client";
import { sortColoniesByCode } from "./sort";
import {
Colony,
ColonyHistory,
@@ -20,7 +21,7 @@ export function fetchColonies(filter: ColonyFilter = {}): Promise<Colony[]> {
if (filter.status) params.set("status", filter.status);
if (filter.apiaryId) params.set("apiaryId", filter.apiaryId);
const query = params.toString();
return apiFetch<Colony[]>(`/colonies${query ? `?${query}` : ""}`);
return apiFetch<Colony[]>(`/colonies${query ? `?${query}` : ""}`).then(sortColoniesByCode);
}
export function fetchColony(id: string): Promise<Colony> {
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { compareColonyCodes, sortColoniesByCode } from "./sort";
describe("colony code order", () => {
it("sorts numbers inside codes by value, not letter by letter", () => {
const codes = ["V-010", "V-10", "V-2", "V-02", "V-1"];
expect([...codes].sort(compareColonyCodes)).toEqual(["V-1", "V-02", "V-2", "V-010", "V-10"]);
});
it("returns a sorted copy and leaves the input alone", () => {
const colonies = [{ code: "V-10" }, { code: "V-9" }];
const sorted = sortColoniesByCode(colonies);
expect(sorted.map((colony) => colony.code)).toEqual(["V-9", "V-10"]);
expect(colonies.map((colony) => colony.code)).toEqual(["V-10", "V-9"]);
});
});
+15
View File
@@ -0,0 +1,15 @@
/**
* Colony codes in the order a beekeeper counts them: V-2 before V-10, not
* letter by letter. One collator, shared by every picker and list.
*/
const CODE_COLLATOR = new Intl.Collator("de", { numeric: true, sensitivity: "base" });
export function compareColonyCodes(left: string, right: string): number {
// Same number written differently (V-02, V-2): fall back to the text, so the order is stable.
return CODE_COLLATOR.compare(left, right) || (left < right ? -1 : left > right ? 1 : 0);
}
/** A copy of `colonies` in natural code order; the input stays as it is. */
export function sortColoniesByCode<T extends { code: string }>(colonies: readonly T[]): T[] {
return [...colonies].sort((left, right) => compareColonyCodes(left.code, right.code));
}
@@ -0,0 +1,7 @@
const pad = (value: number): string => String(value).padStart(2, "0");
/** „Stand: 14.10. 09:05“ - when the data shown from the cache was loaded (local time). */
export function formatStand(updatedAt: number): string {
const date = new Date(updatedAt);
return `Stand: ${pad(date.getDate())}.${pad(date.getMonth() + 1)}. ${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
@@ -88,6 +88,14 @@ describe("roundTarget", () => {
expect(target?.href).toBe("/inspections/new?colonyId=c2&round=a1");
});
it("sorts codes with long numbers naturally (V-2 before V-010)", () => {
const target = roundTarget([
suggestion({ id: "1", colonyId: "c1", colonyCode: "V-010" }),
suggestion({ id: "2", colonyId: "c2", colonyCode: "V-2" }),
]);
expect(target?.colonies.map((colony) => colony.code)).toEqual(["V-2", "V-010"]);
});
it("is null without a colony at an apiary", () => {
expect(roundTarget([suggestion({ apiaryId: null, apiaryName: null })])).toBeNull();
expect(roundTarget([])).toBeNull();
@@ -1,3 +1,4 @@
import { compareColonyCodes } from "@/lib/colonies/sort";
import type { Suggestion, SuggestionIntent } from "./types";
export type SuggestionRow =
@@ -92,7 +93,7 @@ export function roundTarget(suggestions: readonly Suggestion[]): RoundTarget | n
if (apiary === null) return null;
const sorted = [...colonies.entries()]
.map(([id, code]) => ({ id, code }))
.sort((a, b) => compareText(a.code, b.code));
.sort((a, b) => compareColonyCodes(a.code, b.code));
const href = `/inspections/new?colonyId=${encodeURIComponent(sorted[0].id)}&round=${encodeURIComponent(apiary.id)}`;
const plainHref = `/inspections/new?colonyId=${encodeURIComponent(sorted[0].id)}`;
return { apiary, colonies: sorted, href, plainHref };
@@ -0,0 +1,10 @@
import { describe, expect, it } from "vitest";
import { entryCountText } from "./plural";
describe("entryCountText", () => {
it("uses the singular for exactly one entry", () => {
expect(entryCountText(0)).toBe("0 Einträge");
expect(entryCountText(1)).toBe("1 Eintrag");
expect(entryCountText(2)).toBe("2 Einträge");
});
});
+4
View File
@@ -0,0 +1,4 @@
/** "1 Eintrag", "0 Einträge", "2 Einträge". */
export function entryCountText(count: number): string {
return `${count} ${count === 1 ? "Eintrag" : "Einträge"}`;
}
@@ -60,7 +60,12 @@ export interface UpdateTreatmentInput {
export interface WithdrawalStatus {
colonyId: string;
/** First day honey may be taken again (ISO). */
activeUntil: string;
/** Last blocked day, inclusive (YYYY-MM-DD). */
lastBlockedDay: string;
/** Ready text from the server: "bis einschließlich 26.10. – ab 27.10. wieder erntefähig". */
withdrawalText: string;
product: string;
}
@@ -8,7 +8,7 @@ vi.mock("sonner", () => ({ toast: { warning: vi.fn() } }));
const withdrawal: PlausibilityWarning = {
code: "WITHDRAWAL_ACTIVE",
severity: "WARNUNG",
message: "B3: Wartezeit nach Oxuvar endet am 20.08.2026.",
message: "B3: Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.",
refs: [],
};
@@ -20,7 +20,7 @@ describe("toastWarnings", () => {
expect(toast.warning).toHaveBeenCalledWith(
"Gespeichert · 1 Warnung",
expect.objectContaining({
description: "B3: Wartezeit nach Oxuvar endet am 20.08.2026.",
description: "B3: Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.",
}),
);
});
@@ -1,3 +1,4 @@
import { compareColonyCodes } from "@/lib/colonies/sort";
import type { Colony } from "@/lib/colonies/types";
import { isLivingColony } from "@/lib/colonies/status";
import type { WinterCheckWithColony } from "./types";
@@ -76,7 +77,7 @@ export function buildWinterRows(
return [...groups.values()]
.map((group) => ({
...group,
rows: [...group.rows].sort((a, b) => a.code.localeCompare(b.code, "de", { numeric: true })),
rows: [...group.rows].sort((a, b) => compareColonyCodes(a.code, b.code)),
}))
.sort((a, b) => a.apiaryName.localeCompare(b.apiaryName, "de"));
}