fix: T4a browser check findings
- 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:
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"]);
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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"));
|
||||
}
|
||||
Reference in new issue
Block a user