feat: T4 wave 4 - rules suggest, takeover, chains, autopilot A1-A3, suggestions page, varroa bands UI
- Automation rules propose suggestions instead of creating tasks; notify per rule config; corrected varroa measurements refresh or resolve their suggestion - Inspection values can be taken over into the colony card (occupiedFrames 0-40, strength deprecated); warning counts use the same visibility rule as the notification list - Biotech chains: swarm check after queen cells, queen acceptance check, split schedules (raise own, queen cell, queen) - Season autopilot core with A1 inspection due, A2 varroa measurement, A3 recheck after treatment; colony sync and daily step - Frontend: suggestions page with groups, bulk accept, undo, paging, focus management and permission gating - Frontend: varroa traffic light from seasonal bands, sample size, one range request for the colony chart - Notifications: mark-read answers 404 for rows the user may no longer see Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
64a2b7dad9
commit
02eb16924e
142 files changed
+11847
-1251
No files matched your search
@@ -1,5 +1,5 @@
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { toApiDate, toLocalIsoDate, todayLocalIsoDate } from "./dates";
|
||||
import { berlinCalendarDay, toApiDate, toLocalIsoDate, todayLocalIsoDate } from "./dates";
|
||||
|
||||
/**
|
||||
* The beekeeper's phone runs in Europe/Berlin. Between 00:00 and 02:00 local
|
||||
@@ -69,3 +69,12 @@ describe("local calendar dates", () => {
|
||||
expect(() => toApiDate("2026-02-30")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("berlinCalendarDay", () => {
|
||||
it("is the Berlin day whatever the device time zone", () => {
|
||||
expect(berlinCalendarDay("2026-08-31T22:30:00.000Z")).toBe("2026-09-01");
|
||||
expect(berlinCalendarDay("2026-12-31T23:30:00.000Z")).toBe("2027-01-01");
|
||||
expect(berlinCalendarDay("2026-08-31T21:59:59.000Z")).toBe("2026-08-31");
|
||||
expect(berlinCalendarDay("2026-06-15T00:00:00.000Z")).toBe("2026-06-15");
|
||||
});
|
||||
});
|
||||
@@ -42,3 +42,15 @@ const CALENDAR_DAY_LENGTH = 10;
|
||||
export function toCalendarDay(isoTimestamp: string): string {
|
||||
return isoTimestamp.slice(0, CALENDAR_DAY_LENGTH);
|
||||
}
|
||||
|
||||
const BERLIN_DAY = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Europe/Berlin",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
|
||||
/** The Europe/Berlin calendar day of an instant, as "YYYY-MM-DD" (the backend's day). */
|
||||
export function berlinCalendarDay(isoTimestamp: string): string {
|
||||
return BERLIN_DAY.format(new Date(isoTimestamp));
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { apiFetch } from "@/lib/auth/api-client";
|
||||
import { CreateOptions, idempotencyHeaders } from "@/lib/offline/idempotency";
|
||||
import type {
|
||||
AcceptSuggestionResult,
|
||||
Suggestion,
|
||||
SuggestionQuery,
|
||||
SuggestionSummary,
|
||||
} from "./types";
|
||||
|
||||
export const SUGGESTIONS_KEY = "suggestions";
|
||||
|
||||
/** Rows per page of the open list. */
|
||||
export const SUGGESTION_PAGE_SIZE = 200;
|
||||
|
||||
export function suggestionsKey(query?: SuggestionQuery) {
|
||||
return [SUGGESTIONS_KEY, query ?? {}] as const;
|
||||
}
|
||||
|
||||
export const suggestionSummaryKey = [SUGGESTIONS_KEY, "summary"] as const;
|
||||
|
||||
export function fetchSuggestions(query: SuggestionQuery = {}): Promise<Suggestion[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (query.status) params.set("status", query.status);
|
||||
if (query.colonyId) params.set("colonyId", query.colonyId);
|
||||
if (query.apiaryId) params.set("apiaryId", query.apiaryId);
|
||||
if (query.limit) params.set("limit", String(query.limit));
|
||||
if (query.offset) params.set("offset", String(query.offset));
|
||||
const search = params.toString();
|
||||
return apiFetch<Suggestion[]>(`/suggestions${search ? `?${search}` : ""}`);
|
||||
}
|
||||
|
||||
export function fetchSuggestionSummary(): Promise<SuggestionSummary> {
|
||||
return apiFetch<SuggestionSummary>("/suggestions/summary");
|
||||
}
|
||||
|
||||
function post<T>(path: string, body: unknown, options: CreateOptions): Promise<T> {
|
||||
return apiFetch<T>(path, {
|
||||
method: "POST",
|
||||
headers: idempotencyHeaders(options),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function suggestionPath(id: string, action: string): string {
|
||||
return `/suggestions/${encodeURIComponent(id)}/${action}`;
|
||||
}
|
||||
|
||||
/** `dueDate` is a plain day (YYYY-MM-DD); without one the suggestion's own day is used. */
|
||||
export function acceptSuggestion(
|
||||
id: string,
|
||||
input: { dueDate?: string | null },
|
||||
options: CreateOptions = {},
|
||||
): Promise<AcceptSuggestionResult> {
|
||||
return post(suggestionPath(id, "accept"), input, options);
|
||||
}
|
||||
|
||||
/** 1 to 50 PLAN suggestions, all or nothing. */
|
||||
export function bulkAcceptSuggestions(
|
||||
ids: string[],
|
||||
options: CreateOptions = {},
|
||||
): Promise<AcceptSuggestionResult[]> {
|
||||
return post("/suggestions/bulk-accept", { ids }, options);
|
||||
}
|
||||
|
||||
export function dismissSuggestion(
|
||||
id: string,
|
||||
note: string | null,
|
||||
options: CreateOptions = {},
|
||||
): Promise<Suggestion> {
|
||||
return post(suggestionPath(id, "dismiss"), note ? { note } : {}, options);
|
||||
}
|
||||
|
||||
export function bulkDismissSuggestions(
|
||||
ids: string[],
|
||||
options: CreateOptions = {},
|
||||
): Promise<{ dismissed: number }> {
|
||||
return post("/suggestions/bulk-dismiss", { ids }, options);
|
||||
}
|
||||
|
||||
/** Only possible within 30 days after the decision. */
|
||||
export function reopenSuggestion(id: string, options: CreateOptions = {}): Promise<Suggestion> {
|
||||
return post(suggestionPath(id, "reopen"), {}, options);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { suggestionErrorText } from "./errors";
|
||||
|
||||
describe("suggestionErrorText", () => {
|
||||
it("uses the server's German text for a 409", () => {
|
||||
expect(suggestionErrorText(new ApiError(409, "Der Vorschlag ist nicht mehr offen."))).toBe(
|
||||
"Der Vorschlag ist nicht mehr offen.",
|
||||
);
|
||||
});
|
||||
|
||||
it("maps a Nest 400 with a message array to a German fallback", () => {
|
||||
const error = new ApiError(400, "dueDate must match", { message: ["dueDate must match"] });
|
||||
expect(suggestionErrorText(error)).toBe("Die Eingabe ist ungültig.");
|
||||
});
|
||||
|
||||
it("reads issues[].message when present", () => {
|
||||
const error = new ApiError(400, "Bad", {
|
||||
message: "Bad",
|
||||
issues: [{ field: "ids", message: "Bitte ein Datum wählen." }],
|
||||
});
|
||||
expect(suggestionErrorText(error)).toBe("Bitte ein Datum wählen.");
|
||||
});
|
||||
|
||||
it("explains a 404", () => {
|
||||
expect(suggestionErrorText(new ApiError(404, "Not Found"))).toBe(
|
||||
"Der Vorschlag ist nicht mehr da.",
|
||||
);
|
||||
});
|
||||
|
||||
it("says nothing was saved when offline", () => {
|
||||
expect(suggestionErrorText(new TypeError("Failed to fetch"))).toBe(
|
||||
"Keine Verbindung – nichts gespeichert.",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back for anything else", () => {
|
||||
expect(suggestionErrorText(new Error("x"))).toBe(
|
||||
"Das hat nicht geklappt. Bitte noch einmal versuchen.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { isNetworkError } from "@/lib/http/fetch-with-timeout";
|
||||
|
||||
export const OFFLINE_TEXT = "Keine Verbindung – nichts gespeichert.";
|
||||
const INVALID_TEXT = "Die Eingabe ist ungültig.";
|
||||
const GONE_TEXT = "Der Vorschlag ist nicht mehr da.";
|
||||
const GENERIC_TEXT = "Das hat nicht geklappt. Bitte noch einmal versuchen.";
|
||||
|
||||
function firstIssueMessage(body: unknown): string | null {
|
||||
if (typeof body !== "object" || body === null || !("issues" in body)) return null;
|
||||
const { issues } = body as { issues: unknown };
|
||||
if (!Array.isArray(issues)) return null;
|
||||
for (const issue of issues) {
|
||||
const message = (issue as { message?: unknown } | null)?.message;
|
||||
if (typeof message === "string" && message) return message;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A user-facing German text for a failed decision. Service answers (409,
|
||||
* `issues[]`) are German already; Nest's DTO errors (`message: string[]`,
|
||||
* English) are replaced.
|
||||
*/
|
||||
export function suggestionErrorText(error: unknown): string {
|
||||
if (error instanceof ApiError) {
|
||||
if (error.status === 404) return GONE_TEXT;
|
||||
const issue = firstIssueMessage(error.body);
|
||||
if (issue) return issue;
|
||||
if (error.status === 400) return INVALID_TEXT;
|
||||
if (error.status === 409) return error.message;
|
||||
return GENERIC_TEXT;
|
||||
}
|
||||
return isNetworkError(error) ? OFFLINE_TEXT : GENERIC_TEXT;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { groupSuggestions, roundTarget } from "./grouping";
|
||||
import type { Suggestion } from "./types";
|
||||
|
||||
function suggestion(overrides: Partial<Suggestion>): Suggestion {
|
||||
return {
|
||||
id: "s",
|
||||
ruleKey: "INSPECTION_INTERVAL",
|
||||
source: "AUTOPILOT",
|
||||
mode: "DO",
|
||||
intent: "INSPECT",
|
||||
status: "OPEN",
|
||||
priority: "NORMAL",
|
||||
relatedType: "COLONY",
|
||||
relatedId: null,
|
||||
colonyId: "c1",
|
||||
colonyCode: "A1",
|
||||
apiaryId: "a1",
|
||||
apiaryName: "Hang",
|
||||
title: "Durchsicht fällig",
|
||||
reasons: [],
|
||||
dueDate: null,
|
||||
expiresAt: null,
|
||||
plan: null,
|
||||
decidedAt: null,
|
||||
decisionNote: null,
|
||||
createdAt: "2026-05-01T00:00:00.000Z",
|
||||
cleanup: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("groupSuggestions", () => {
|
||||
it("groups by apiary, then „Betrieb“, and combines the same intent per apiary", () => {
|
||||
const list = [
|
||||
suggestion({ id: "1", colonyId: "c1", colonyCode: "A1" }),
|
||||
suggestion({ id: "2", intent: "HONEY_SUPER", mode: "PLAN", apiaryId: "a2", apiaryName: "Aue", colonyId: "c9", colonyCode: "B1" }),
|
||||
suggestion({ id: "3", colonyId: "c2", colonyCode: "A2" }),
|
||||
suggestion({ id: "4", intent: "STOCK_REORDER", mode: "PLAN", apiaryId: null, apiaryName: null, colonyId: null, colonyCode: null }),
|
||||
];
|
||||
|
||||
const groups = groupSuggestions(list);
|
||||
|
||||
expect(groups.map((g) => g.label)).toEqual(["Aue", "Hang", "Betrieb"]);
|
||||
const hang = groups[1];
|
||||
expect(hang.apiaryId).toBe("a1");
|
||||
expect(hang.rows).toHaveLength(1);
|
||||
const [row] = hang.rows;
|
||||
expect(row.kind).toBe("combined");
|
||||
if (row.kind === "combined") {
|
||||
expect(row.intent).toBe("INSPECT");
|
||||
expect(row.suggestions.map((s) => s.id)).toEqual(["1", "3"]);
|
||||
}
|
||||
expect(groups[0].rows[0].kind).toBe("single");
|
||||
expect(groups[2].apiaryId).toBeNull();
|
||||
expect(groups[2].key).toBe("operation");
|
||||
});
|
||||
|
||||
it("keeps the order of the list inside a group", () => {
|
||||
const groups = groupSuggestions([
|
||||
suggestion({ id: "1", intent: "FEED", colonyId: "c1" }),
|
||||
suggestion({ id: "2", intent: "INSPECT", colonyId: "c2" }),
|
||||
suggestion({ id: "3", intent: "FEED", colonyId: "c3" }),
|
||||
]);
|
||||
|
||||
const kinds = groups[0].rows.map((r) => (r.kind === "combined" ? r.intent : r.suggestion.intent));
|
||||
expect(kinds).toEqual(["FEED", "INSPECT"]);
|
||||
});
|
||||
|
||||
it("returns no groups for an empty list", () => {
|
||||
expect(groupSuggestions([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("roundTarget", () => {
|
||||
it("lists the due colonies in code order and leads to the first one", () => {
|
||||
const target = roundTarget([
|
||||
suggestion({ id: "1", colonyId: "c10", colonyCode: "A10" }),
|
||||
suggestion({ id: "2", colonyId: "c2", colonyCode: "A2" }),
|
||||
suggestion({ id: "3", colonyId: "c2", colonyCode: "A2" }),
|
||||
]);
|
||||
|
||||
expect(target?.apiary).toEqual({ id: "a1", name: "Hang" });
|
||||
expect(target?.colonies).toEqual([
|
||||
{ id: "c2", code: "A2" },
|
||||
{ id: "c10", code: "A10" },
|
||||
]);
|
||||
expect(target?.href).toBe("/inspections/new?colonyId=c2&round=a1");
|
||||
});
|
||||
|
||||
it("is null without a colony at an apiary", () => {
|
||||
expect(roundTarget([suggestion({ apiaryId: null, apiaryName: null })])).toBeNull();
|
||||
expect(roundTarget([])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { Suggestion, SuggestionIntent } from "./types";
|
||||
|
||||
export type SuggestionRow =
|
||||
| { kind: "single"; suggestion: Suggestion }
|
||||
| { kind: "combined"; intent: SuggestionIntent; suggestions: Suggestion[] };
|
||||
|
||||
export interface SuggestionGroupModel {
|
||||
key: string;
|
||||
label: string;
|
||||
apiaryId: string | null;
|
||||
rows: SuggestionRow[];
|
||||
}
|
||||
|
||||
const OPERATION_KEY = "operation";
|
||||
const OPERATION_LABEL = "Betrieb";
|
||||
|
||||
function compareText(a: string, b: string): number {
|
||||
return a.localeCompare(b, "de", { numeric: true });
|
||||
}
|
||||
|
||||
function rowsOf(list: readonly Suggestion[]): SuggestionRow[] {
|
||||
const byIntent = new Map<SuggestionIntent, Suggestion[]>();
|
||||
for (const suggestion of list) {
|
||||
byIntent.set(suggestion.intent, [...(byIntent.get(suggestion.intent) ?? []), suggestion]);
|
||||
}
|
||||
const rows: SuggestionRow[] = [];
|
||||
const done = new Set<SuggestionIntent>();
|
||||
for (const suggestion of list) {
|
||||
if (done.has(suggestion.intent)) continue;
|
||||
done.add(suggestion.intent);
|
||||
const same = byIntent.get(suggestion.intent) ?? [];
|
||||
rows.push(
|
||||
same.length > 1
|
||||
? { kind: "combined", intent: suggestion.intent, suggestions: same }
|
||||
: { kind: "single", suggestion },
|
||||
);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* One group per stand (by name), then „Betrieb“ for everything without a
|
||||
* stand. Inside a group the API's order (rank) is kept; the same intent is
|
||||
* one row, placed where its first suggestion is.
|
||||
*/
|
||||
export function groupSuggestions(list: readonly Suggestion[]): SuggestionGroupModel[] {
|
||||
const stands = new Map<string, Suggestion[]>();
|
||||
const operation: Suggestion[] = [];
|
||||
for (const suggestion of list) {
|
||||
if (suggestion.apiaryId === null) {
|
||||
operation.push(suggestion);
|
||||
} else {
|
||||
stands.set(suggestion.apiaryId, [...(stands.get(suggestion.apiaryId) ?? []), suggestion]);
|
||||
}
|
||||
}
|
||||
const groups: SuggestionGroupModel[] = [...stands.entries()].map(([apiaryId, members]) => ({
|
||||
key: apiaryId,
|
||||
label: members[0].apiaryName ?? "Stand",
|
||||
apiaryId,
|
||||
rows: rowsOf(members),
|
||||
}));
|
||||
groups.sort((a, b) => compareText(a.label, b.label));
|
||||
if (operation.length > 0) {
|
||||
groups.push({ key: OPERATION_KEY, label: OPERATION_LABEL, apiaryId: null, rows: rowsOf(operation) });
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
export interface RoundTarget {
|
||||
apiary: { id: string; name: string };
|
||||
colonies: { id: string; code: string }[];
|
||||
/** The first inspection of the round. */
|
||||
href: string;
|
||||
/** The first inspection without round parameters (no signed-in user to keep the round). */
|
||||
plainHref: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The stand round for due inspections of one stand: the colonies once each,
|
||||
* in code order. Null without a colony at a stand.
|
||||
*/
|
||||
export function roundTarget(suggestions: readonly Suggestion[]): RoundTarget | null {
|
||||
const colonies = new Map<string, string>();
|
||||
let apiary: RoundTarget["apiary"] | null = null;
|
||||
for (const suggestion of suggestions) {
|
||||
if (suggestion.apiaryId === null || suggestion.colonyId === null) continue;
|
||||
apiary ??= { id: suggestion.apiaryId, name: suggestion.apiaryName ?? "Stand" };
|
||||
if (suggestion.apiaryId === apiary.id) {
|
||||
colonies.set(suggestion.colonyId, suggestion.colonyCode ?? suggestion.colonyId);
|
||||
}
|
||||
}
|
||||
if (apiary === null) return null;
|
||||
const sorted = [...colonies.entries()]
|
||||
.map(([id, code]) => ({ id, code }))
|
||||
.sort((a, b) => compareText(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,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { captureHref, INTENT_ICONS, INTENT_LABELS } from "./labels";
|
||||
import { SUGGESTION_INTENTS } from "./types";
|
||||
|
||||
describe("suggestion labels", () => {
|
||||
it("every intent has a label and an icon", () => {
|
||||
for (const intent of SUGGESTION_INTENTS) {
|
||||
expect(INTENT_LABELS[intent]).toBeTruthy();
|
||||
expect(INTENT_ICONS[intent]).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("opens the matching capture form with the colony", () => {
|
||||
expect(captureHref("INSPECT", "c 1")).toBe("/inspections/new?colonyId=c%201");
|
||||
expect(captureHref("VARROA_MEASURE", "c1")).toBe("/varroa/new?colonyId=c1");
|
||||
expect(captureHref("VARROA_RECHECK", "c1")).toBe("/varroa/new?colonyId=c1");
|
||||
expect(captureHref("FEED", "c1")).toBe("/feedings/new?colonyId=c1");
|
||||
expect(captureHref("HONEY_SUPER", "c1")).toBe("/inspections/new?colonyId=c1");
|
||||
});
|
||||
|
||||
it("opens the winter page for a winter check and omits a missing colony", () => {
|
||||
expect(captureHref("WINTER_CHECK", "c1")).toBe("/winter");
|
||||
expect(captureHref("INSPECT", null)).toBe("/inspections/new");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
Bug,
|
||||
CalendarCheck,
|
||||
ClipboardCheck,
|
||||
Crown,
|
||||
Droplet,
|
||||
Eraser,
|
||||
HeartPulse,
|
||||
RefreshCw,
|
||||
ShoppingCart,
|
||||
Snowflake,
|
||||
Split,
|
||||
Sprout,
|
||||
Sun,
|
||||
Syringe,
|
||||
UserCheck,
|
||||
Utensils,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import type { SuggestionIntent } from "./types";
|
||||
|
||||
/** Used for combined rows („Durchsicht fällig · 6 Völker“) and as the icon's name. */
|
||||
export const INTENT_LABELS: Record<SuggestionIntent, string> = {
|
||||
INSPECT: "Durchsicht fällig",
|
||||
SWARM_CHECK: "Schwarmkontrolle",
|
||||
QUEEN_CHECK: "Königin prüfen",
|
||||
QUEEN_ACCEPTANCE: "Zusetzen prüfen",
|
||||
QUEEN_REPLACE: "Königin ersetzen",
|
||||
SPLIT_PLAN: "Ableger prüfen",
|
||||
VARROA_MEASURE: "Varroa messen",
|
||||
VARROA_RECHECK: "Varroa nachkontrollieren",
|
||||
VARROA_TREAT: "Behandlung prüfen",
|
||||
SUMMER_TREATMENT: "Sommerbehandlung",
|
||||
FEED: "Füttern",
|
||||
HONEY_SUPER: "Honigraum prüfen",
|
||||
WINTER_CHECK: "Winterkontrolle",
|
||||
HEALTH_CHECK: "Gesundheit prüfen",
|
||||
FOLLOW_UP: "Nachkontrolle",
|
||||
STOCK_REORDER: "Nachbestellen",
|
||||
CLEANUP: "Aufräumen",
|
||||
};
|
||||
|
||||
export const INTENT_ICONS: Record<SuggestionIntent, LucideIcon> = {
|
||||
INSPECT: ClipboardCheck,
|
||||
SWARM_CHECK: Sprout,
|
||||
QUEEN_CHECK: Crown,
|
||||
QUEEN_ACCEPTANCE: UserCheck,
|
||||
QUEEN_REPLACE: RefreshCw,
|
||||
SPLIT_PLAN: Split,
|
||||
VARROA_MEASURE: Bug,
|
||||
VARROA_RECHECK: Bug,
|
||||
VARROA_TREAT: Syringe,
|
||||
SUMMER_TREATMENT: Sun,
|
||||
FEED: Utensils,
|
||||
HONEY_SUPER: Droplet,
|
||||
WINTER_CHECK: Snowflake,
|
||||
HEALTH_CHECK: HeartPulse,
|
||||
FOLLOW_UP: CalendarCheck,
|
||||
STOCK_REORDER: ShoppingCart,
|
||||
CLEANUP: Eraser,
|
||||
};
|
||||
|
||||
/** The form a DO suggestion („Erfassen“) opens; the inspection form for the rest. */
|
||||
const CAPTURE_PATHS: Partial<Record<SuggestionIntent, string>> = {
|
||||
VARROA_MEASURE: "/varroa/new",
|
||||
VARROA_RECHECK: "/varroa/new",
|
||||
FEED: "/feedings/new",
|
||||
WINTER_CHECK: "/winter",
|
||||
};
|
||||
|
||||
/** Winter checks are entered from the winter page, which has no colony parameter. */
|
||||
const PATHS_WITHOUT_COLONY = new Set(["/winter"]);
|
||||
|
||||
const DEFAULT_CAPTURE_PATH = "/inspections/new";
|
||||
|
||||
export function captureHref(intent: SuggestionIntent, colonyId: string | null): string {
|
||||
const path = CAPTURE_PATHS[intent] ?? DEFAULT_CAPTURE_PATH;
|
||||
if (!colonyId || PATHS_WITHOUT_COLONY.has(path)) return path;
|
||||
return `${path}?colonyId=${encodeURIComponent(colonyId)}`;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { Task, TaskPriority, TaskRelatedType } from "@/lib/tasks/types";
|
||||
|
||||
export const SUGGESTION_INTENTS = [
|
||||
"INSPECT",
|
||||
"SWARM_CHECK",
|
||||
"QUEEN_CHECK",
|
||||
"QUEEN_ACCEPTANCE",
|
||||
"QUEEN_REPLACE",
|
||||
"SPLIT_PLAN",
|
||||
"VARROA_MEASURE",
|
||||
"VARROA_RECHECK",
|
||||
"VARROA_TREAT",
|
||||
"SUMMER_TREATMENT",
|
||||
"FEED",
|
||||
"HONEY_SUPER",
|
||||
"WINTER_CHECK",
|
||||
"HEALTH_CHECK",
|
||||
"FOLLOW_UP",
|
||||
"STOCK_REORDER",
|
||||
"CLEANUP",
|
||||
] as const;
|
||||
|
||||
export type SuggestionIntent = (typeof SUGGESTION_INTENTS)[number];
|
||||
|
||||
export type SuggestionStatus =
|
||||
| "OPEN"
|
||||
| "ACCEPTED"
|
||||
| "DISMISSED"
|
||||
| "RESOLVED"
|
||||
| "EXPIRED"
|
||||
| "OBSOLETE";
|
||||
|
||||
/** PLAN: „Übernehmen“ plans a task. DO: „Erfassen“ opens the form. */
|
||||
export type SuggestionMode = "PLAN" | "DO";
|
||||
|
||||
export type SuggestionSource = "RULE" | "CHAIN" | "AUTOPILOT";
|
||||
|
||||
export interface SuggestionPlanStep {
|
||||
step: string;
|
||||
offsetDays: number;
|
||||
windowDays?: number;
|
||||
}
|
||||
|
||||
/** Mirror of the API's SuggestionView (ApiCore-Backend/src/suggestions). */
|
||||
export interface Suggestion {
|
||||
id: string;
|
||||
ruleKey: string;
|
||||
source: SuggestionSource;
|
||||
mode: SuggestionMode;
|
||||
intent: SuggestionIntent;
|
||||
status: SuggestionStatus;
|
||||
priority: TaskPriority;
|
||||
relatedType: TaskRelatedType;
|
||||
relatedId: string | null;
|
||||
colonyId: string | null;
|
||||
colonyCode: string | null;
|
||||
apiaryId: string | null;
|
||||
apiaryName: string | null;
|
||||
title: string;
|
||||
reasons: string[];
|
||||
/** YYYY-MM-DD */
|
||||
dueDate: string | null;
|
||||
/** YYYY-MM-DD */
|
||||
expiresAt: string | null;
|
||||
plan: SuggestionPlanStep[] | null;
|
||||
decidedAt: string | null;
|
||||
decisionNote: string | null;
|
||||
createdAt: string;
|
||||
/** Only for CLEANUP suggestions. */
|
||||
cleanup: { taskIds: string[]; queenId: string | null } | null;
|
||||
}
|
||||
|
||||
export interface SuggestionSummary {
|
||||
open: number;
|
||||
byPriority: Record<TaskPriority, number>;
|
||||
}
|
||||
|
||||
export interface SuggestionQuery {
|
||||
status?: "OPEN" | "DISMISSED";
|
||||
colonyId?: string;
|
||||
apiaryId?: string;
|
||||
/** Rows per call (1 to 500, the API default is 200). */
|
||||
limit?: number;
|
||||
/** Skips this many rows of the sorted list (paging). */
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface AcceptSuggestionResult {
|
||||
suggestion: Suggestion;
|
||||
tasks: Task[];
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchSuggestionSummary, suggestionSummaryKey } from "./api";
|
||||
|
||||
const REFRESH_MS = 60_000;
|
||||
|
||||
/** The open count for the navigation; refreshed like the notification bell. */
|
||||
export function useSuggestionSummary() {
|
||||
return useQuery({
|
||||
queryKey: suggestionSummaryKey,
|
||||
queryFn: fetchSuggestionSummary,
|
||||
refetchInterval: REFRESH_MS,
|
||||
});
|
||||
}
|
||||
@@ -25,6 +25,9 @@ export interface Task {
|
||||
// Set when an automation rule suggested the task (not a person) - e.g.
|
||||
// "STOCK_ITEM_LOW". Shown as a suggestion, never as work already done.
|
||||
sourceAutomationTrigger: string | null;
|
||||
// Set when the task was planned from an accepted suggestion. Optional: older
|
||||
// cached tasks lack the field.
|
||||
sourceSuggestionId?: string | null;
|
||||
createdAt: string;
|
||||
completedAt: string | null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { apiFetch } from "@/lib/auth/api-client";
|
||||
import { CreateOptions, idempotencyHeaders } from "@/lib/offline/idempotency";
|
||||
import type { VarroaBands, VarroaThresholdSegments } from "./bands";
|
||||
import {
|
||||
CreateVarroaMeasurementInput,
|
||||
UpdateVarroaMeasurementInput,
|
||||
@@ -31,6 +32,19 @@ export function updateVarroaMeasurement(
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchVarroaThreshold(): Promise<{ thresholdMitesPerDay: number | null }> {
|
||||
return apiFetch<{ thresholdMitesPerDay: number | null }>("/varroa-measurements/threshold");
|
||||
/** The bands in force on a day (for a colony's type); `date` is YYYY-MM-DD. */
|
||||
export function fetchVarroaThresholds(colonyId: string | null, date: string): Promise<VarroaBands> {
|
||||
const query = new URLSearchParams({ date });
|
||||
if (colonyId) query.set("colonyId", colonyId);
|
||||
return apiFetch<VarroaBands>(`/varroa-measurements/thresholds?${query.toString()}`);
|
||||
}
|
||||
|
||||
/** The bands over a range of days (YYYY-MM-DD, at most 731 days) in one request. */
|
||||
export function fetchVarroaThresholdRange(
|
||||
colonyId: string,
|
||||
from: string,
|
||||
to: string,
|
||||
): Promise<VarroaThresholdSegments> {
|
||||
const query = new URLSearchParams({ colonyId, from, to });
|
||||
return apiFetch<VarroaThresholdSegments>(`/varroa-measurements/thresholds?${query.toString()}`);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
SAMPLE_BEES_ERROR,
|
||||
infestationPercent,
|
||||
liveNote,
|
||||
ratingNote,
|
||||
sampleBeesError,
|
||||
segmentAt,
|
||||
varroaLight,
|
||||
type VarroaBand,
|
||||
type VarroaBands,
|
||||
} from "./bands";
|
||||
|
||||
const NATURAL: VarroaBand = {
|
||||
method: "NATURAL_MITE_FALL",
|
||||
unit: "MITES_PER_DAY",
|
||||
yellowFrom: 5,
|
||||
redAbove: 10,
|
||||
reason: "NATURAL_FALL_SEASON",
|
||||
youngColony: false,
|
||||
};
|
||||
const SUGAR: VarroaBand = {
|
||||
method: "POWDERED_SUGAR",
|
||||
unit: "PERCENT",
|
||||
yellowFrom: 2,
|
||||
redAbove: 5,
|
||||
reason: "INFESTATION_AUTUMN",
|
||||
youngColony: false,
|
||||
};
|
||||
const AUGUST: VarroaBands = {
|
||||
day: "2026-08-10",
|
||||
bands: { NATURAL_MITE_FALL: NATURAL, POWDERED_SUGAR: SUGAR },
|
||||
postTreatmentUntil: null,
|
||||
active: true,
|
||||
};
|
||||
|
||||
describe("varroaLight", () => {
|
||||
it("10,0/Tag in August is yellow, 10,1 red", () => {
|
||||
expect(varroaLight(10, NATURAL)).toBe("yellow");
|
||||
expect(varroaLight(10.1, NATURAL)).toBe("red");
|
||||
expect(varroaLight(4.9, NATURAL)).toBe("green");
|
||||
expect(varroaLight(5, NATURAL)).toBe("yellow");
|
||||
});
|
||||
|
||||
it("has no yellow stage without yellowFrom", () => {
|
||||
const winter = { ...NATURAL, yellowFrom: null, redAbove: 0.5 };
|
||||
expect(varroaLight(0.5, winter)).toBe("green");
|
||||
expect(varroaLight(0.6, winter)).toBe("red");
|
||||
});
|
||||
|
||||
it("has no light without a value or band", () => {
|
||||
expect(varroaLight(null, NATURAL)).toBeNull();
|
||||
expect(varroaLight(3, null)).toBeNull();
|
||||
expect(varroaLight(3, undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("infestationPercent", () => {
|
||||
it("is mites per sample in percent, rounded to two decimals", () => {
|
||||
expect(infestationPercent(12, 500)).toBe(2.4);
|
||||
expect(infestationPercent(1, 300)).toBe(0.33);
|
||||
expect(infestationPercent(null, 500)).toBeNull();
|
||||
expect(infestationPercent(12, null)).toBeNull();
|
||||
expect(infestationPercent(12, 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("liveNote", () => {
|
||||
it("names the light with word and limit", () => {
|
||||
expect(liveNote("NATURAL_MITE_FALL", 12.4, AUGUST)).toEqual({
|
||||
kind: "red",
|
||||
text: "Kritisch (Grenze 10, LWG)",
|
||||
});
|
||||
expect(liveNote("POWDERED_SUGAR", 2.4, AUGUST)).toEqual({
|
||||
kind: "yellow",
|
||||
text: "Achtung (Grenze 5 %, LWG)",
|
||||
});
|
||||
expect(liveNote("NATURAL_MITE_FALL", 1, AUGUST)?.text).toMatch(/^Gut/);
|
||||
});
|
||||
|
||||
it("powdered sugar in April has no light", () => {
|
||||
const april: VarroaBands = { ...AUGUST, day: "2026-04-10", bands: { NATURAL_MITE_FALL: NATURAL } };
|
||||
expect(liveNote("POWDERED_SUGAR", 8, april)).toBeNull();
|
||||
});
|
||||
|
||||
it("shows „Behandlungseffekt – Nachkontrolle ab 16.08.“ inside the post-treatment window", () => {
|
||||
const effect = { ...AUGUST, postTreatmentUntil: "2026-08-16" };
|
||||
expect(liveNote("NATURAL_MITE_FALL", 12, effect)).toEqual({
|
||||
kind: "effect",
|
||||
text: "Behandlungseffekt – Nachkontrolle ab 16.08.",
|
||||
});
|
||||
expect(liveNote("DRONE_BROOD_CONTROL", 12, effect)).toBeNull();
|
||||
});
|
||||
|
||||
it("has no note without bands", () => {
|
||||
expect(liveNote("NATURAL_MITE_FALL", 12, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ratingNote", () => {
|
||||
it("renders a stored rating", () => {
|
||||
expect(
|
||||
ratingNote({ light: "green", reasonCode: "NATURAL_FALL_SEASON", band: NATURAL, postTreatmentUntil: null }),
|
||||
).toEqual({ kind: "green", text: "Gut (Grenze 10, LWG)" });
|
||||
expect(
|
||||
ratingNote({ light: "POST_TREATMENT", reasonCode: "POST_TREATMENT", band: null, postTreatmentUntil: "2026-08-16" })
|
||||
?.text,
|
||||
).toBe("Behandlungseffekt – Nachkontrolle ab 16.08.");
|
||||
expect(ratingNote({ light: null, reasonCode: null, band: null, postTreatmentUntil: null })).toBeNull();
|
||||
expect(ratingNote(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("segmentAt", () => {
|
||||
const segments = [
|
||||
{ from: "2026-10-01", to: "2026-10-31", bands: {}, postTreatmentUntil: null, active: true },
|
||||
{ from: "2026-11-01", to: "2026-11-30", bands: {}, postTreatmentUntil: null, active: true },
|
||||
];
|
||||
|
||||
it("finds the segment at its first and last day", () => {
|
||||
expect(segmentAt(segments, "2026-10-01")).toBe(segments[0]);
|
||||
expect(segmentAt(segments, "2026-10-31")).toBe(segments[0]);
|
||||
expect(segmentAt(segments, "2026-11-01")).toBe(segments[1]);
|
||||
expect(segmentAt(segments, "2026-11-30")).toBe(segments[1]);
|
||||
});
|
||||
|
||||
it("returns null outside the covered range", () => {
|
||||
expect(segmentAt(segments, "2026-09-30")).toBeNull();
|
||||
expect(segmentAt(segments, "2026-12-01")).toBeNull();
|
||||
expect(segmentAt([], "2026-10-01")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sampleBeesError", () => {
|
||||
it("accepts an empty field and whole numbers from 1 to 2000", () => {
|
||||
for (const ok of ["", " ", "1", "500", "2000"]) expect(sampleBeesError(ok)).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses everything else with one sentence", () => {
|
||||
for (const bad of ["0", "2001", "-5", "1.5", "abc", "1e3"]) {
|
||||
expect(sampleBeesError(bad)).toBe(SAMPLE_BEES_ERROR);
|
||||
}
|
||||
expect(SAMPLE_BEES_ERROR).toBe("Bitte 1 bis 2000 Bienen angeben.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("infestationPercent out of range", () => {
|
||||
it("has no value for a sample outside 1 to 2000 or with a fraction", () => {
|
||||
expect(infestationPercent(12, 2001)).toBeNull();
|
||||
expect(infestationPercent(12, 1.5)).toBeNull();
|
||||
expect(infestationPercent(12, 2000)).toBe(0.6);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { VarroaMethod } from "./types";
|
||||
import { formatPercent, formatThreshold } from "./format";
|
||||
|
||||
export type VarroaLight = "green" | "yellow" | "red";
|
||||
|
||||
export interface VarroaBand {
|
||||
method: VarroaMethod;
|
||||
unit: "MITES_PER_DAY" | "PERCENT";
|
||||
/** null: no yellow stage (winter). */
|
||||
yellowFrom: number | null;
|
||||
redAbove: number;
|
||||
/** Reason code of the band (season, winter, summer, autumn). */
|
||||
reason: string;
|
||||
youngColony: boolean;
|
||||
}
|
||||
|
||||
export interface VarroaBands {
|
||||
day: string;
|
||||
bands: Partial<Record<VarroaMethod, VarroaBand>>;
|
||||
/** Recheck day while a treatment effect is in force, otherwise null. */
|
||||
postTreatmentUntil: string | null;
|
||||
/** false while the rule is switched off. */
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/** The bands for a stretch of days; consecutive segments cover a range without gaps. */
|
||||
export interface VarroaThresholdSegment {
|
||||
/** YYYY-MM-DD, inclusive. */
|
||||
from: string;
|
||||
/** YYYY-MM-DD, inclusive. */
|
||||
to: string;
|
||||
bands: VarroaBands["bands"];
|
||||
postTreatmentUntil: string | null;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface VarroaThresholdSegments {
|
||||
segments: VarroaThresholdSegment[];
|
||||
}
|
||||
|
||||
/** The segment that holds `day` (YYYY-MM-DD), inclusive at both ends; null outside. */
|
||||
export function segmentAt(
|
||||
segments: readonly VarroaThresholdSegment[],
|
||||
day: string,
|
||||
): VarroaThresholdSegment | null {
|
||||
return segments.find((segment) => segment.from <= day && day <= segment.to) ?? null;
|
||||
}
|
||||
|
||||
/** The rating the backend stores with every measurement of a colony. */
|
||||
export interface VarroaRating {
|
||||
light: VarroaLight | "POST_TREATMENT" | null;
|
||||
reasonCode: string | null;
|
||||
band: VarroaBand | null;
|
||||
postTreatmentUntil: string | null;
|
||||
}
|
||||
|
||||
/** What the visible note is: a light or the treatment effect. */
|
||||
export interface VarroaNote {
|
||||
kind: VarroaLight | "effect";
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** Chip „500 Bienen (≈ 50 g)“; never preselected. */
|
||||
export const SAMPLE_BEES_CHOICE = 500;
|
||||
|
||||
const MIN_SAMPLE_BEES = 1;
|
||||
const MAX_SAMPLE_BEES = 2000;
|
||||
export const SAMPLE_BEES_ERROR = "Bitte 1 bis 2000 Bienen angeben.";
|
||||
|
||||
const LIGHT_WORDS: Record<VarroaLight, string> = {
|
||||
green: "Gut",
|
||||
yellow: "Achtung",
|
||||
red: "Kritisch",
|
||||
};
|
||||
|
||||
const SAMPLE_METHODS: readonly VarroaMethod[] = ["POWDERED_SUGAR", "WASHING_METHOD"];
|
||||
|
||||
/** Only powdered sugar and washing count a sample of bees. */
|
||||
export function usesSample(method: VarroaMethod): boolean {
|
||||
return SAMPLE_METHODS.includes(method);
|
||||
}
|
||||
|
||||
function isValidSample(value: number): boolean {
|
||||
return Number.isInteger(value) && value >= MIN_SAMPLE_BEES && value <= MAX_SAMPLE_BEES;
|
||||
}
|
||||
|
||||
/** The message for a typed sample size; an empty field is fine (the sample is optional). */
|
||||
export function sampleBeesError(text: string): string | null {
|
||||
if (text.trim() === "") return null;
|
||||
return /^\d+$/.test(text.trim()) && isValidSample(Number(text)) ? null : SAMPLE_BEES_ERROR;
|
||||
}
|
||||
|
||||
function roundTwo(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
/** Red above `redAbove`, yellow from `yellowFrom`, otherwise green. */
|
||||
export function varroaLight(
|
||||
value: number | null,
|
||||
band: VarroaBand | null | undefined,
|
||||
): VarroaLight | null {
|
||||
if (value === null || !band) return null;
|
||||
if (value > band.redAbove) return "red";
|
||||
if (band.yellowFrom !== null && value >= band.yellowFrom) return "yellow";
|
||||
return "green";
|
||||
}
|
||||
|
||||
/** Mites per sample in percent, rounded to two decimals as the backend stores it. */
|
||||
export function infestationPercent(
|
||||
miteCount: number | null,
|
||||
sampleBees: number | null,
|
||||
): number | null {
|
||||
if (miteCount === null || sampleBees === null) return null;
|
||||
if (!Number.isFinite(miteCount) || !isValidSample(sampleBees)) return null;
|
||||
return roundTwo((miteCount / sampleBees) * 100);
|
||||
}
|
||||
|
||||
function limitText(band: VarroaBand): string {
|
||||
const limit = formatThreshold(band.redAbove);
|
||||
return band.unit === "PERCENT" ? `Grenze ${limit} %, LWG` : `Grenze ${limit}, LWG`;
|
||||
}
|
||||
|
||||
/** "Behandlungseffekt – Nachkontrolle ab 16.08." from "2026-08-16". */
|
||||
export function postTreatmentText(until: string): string {
|
||||
return `Behandlungseffekt – Nachkontrolle ab ${until.slice(8, 10)}.${until.slice(5, 7)}.`;
|
||||
}
|
||||
|
||||
function lightNote(light: VarroaLight, band: VarroaBand): VarroaNote {
|
||||
return { kind: light, text: `${LIGHT_WORDS[light]} (${limitText(band)})` };
|
||||
}
|
||||
|
||||
/** The note of a stored measurement; null without a light. */
|
||||
export function ratingNote(rating: VarroaRating | null | undefined): VarroaNote | null {
|
||||
if (!rating?.light) return null;
|
||||
if (rating.light === "POST_TREATMENT") {
|
||||
return rating.postTreatmentUntil
|
||||
? { kind: "effect", text: postTreatmentText(rating.postTreatmentUntil) }
|
||||
: null;
|
||||
}
|
||||
return rating.band ? lightNote(rating.light, rating.band) : null;
|
||||
}
|
||||
|
||||
/** The note for a value being typed, against the bands of the chosen day. */
|
||||
export function liveNote(
|
||||
method: VarroaMethod,
|
||||
value: number | null,
|
||||
bands: VarroaBands | null | undefined,
|
||||
): VarroaNote | null {
|
||||
if (!bands || method === "DRONE_BROOD_CONTROL") return null;
|
||||
if (bands.postTreatmentUntil !== null) {
|
||||
return { kind: "effect", text: postTreatmentText(bands.postTreatmentUntil) };
|
||||
}
|
||||
const band = bands.bands[method];
|
||||
const light = varroaLight(value, band);
|
||||
return light && band ? lightNote(light, band) : null;
|
||||
}
|
||||
|
||||
/** "12,4 Milben/Tag" or "2,4 %"; other methods show the bare number. */
|
||||
export function valueText(
|
||||
method: VarroaMethod,
|
||||
value: number,
|
||||
formatRate: (rate: number) => string,
|
||||
): string {
|
||||
if (method === "NATURAL_MITE_FALL") return `${formatRate(value)} Milben/Tag`;
|
||||
if (usesSample(method)) return `${formatPercent(value)} %`;
|
||||
return formatRate(value);
|
||||
}
|
||||
@@ -1,15 +1,22 @@
|
||||
const ONE_DECIMAL = new Intl.NumberFormat("de-DE", {
|
||||
// The backend stores two decimals; show them when they matter (10,04 is red).
|
||||
const RATE_FORMAT = new Intl.NumberFormat("de-DE", {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
const THRESHOLD_FORMAT = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 1 });
|
||||
const THRESHOLD_FORMAT = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 2 });
|
||||
const PERCENT_FORMAT = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 2 });
|
||||
|
||||
/** "10,0" */
|
||||
/** "10,0" or "10,04" */
|
||||
export function formatMitesPerDay(rate: number): string {
|
||||
return ONE_DECIMAL.format(rate);
|
||||
return RATE_FORMAT.format(rate);
|
||||
}
|
||||
|
||||
/** "10" or "7,5" */
|
||||
/** "10" or "7,5" or "0,25" */
|
||||
export function formatThreshold(threshold: number): string {
|
||||
return THRESHOLD_FORMAT.format(threshold);
|
||||
}
|
||||
|
||||
/** "2,4" (without the percent sign) */
|
||||
export function formatPercent(percent: number): string {
|
||||
return PERCENT_FORMAT.format(percent);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mitesPerDay, varroaLight } from "./rate";
|
||||
import { mitesPerDay } from "./rate";
|
||||
|
||||
describe("varroa rate", () => {
|
||||
it("computes mites per day like the backend", () => {
|
||||
@@ -9,11 +9,7 @@ describe("varroa rate", () => {
|
||||
expect(mitesPerDay(null, 4320)).toBeNull();
|
||||
});
|
||||
|
||||
it("derives the traffic light from the threshold", () => {
|
||||
expect(varroaLight(10, 10)).toBe("red");
|
||||
expect(varroaLight(5, 10)).toBe("yellow");
|
||||
expect(varroaLight(4.9, 10)).toBe("green");
|
||||
expect(varroaLight(10, null)).toBeNull();
|
||||
expect(varroaLight(null, 10)).toBeNull();
|
||||
it("rounds to two decimals as the backend stores it", () => {
|
||||
expect(mitesPerDay(7203, 720 * 1440)).toBe(10);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,6 @@
|
||||
import { MINUTES_PER_DAY } from "./period";
|
||||
|
||||
/** Share of the threshold from which the light turns yellow. */
|
||||
export const VARROA_WARN_FRACTION = 0.5;
|
||||
|
||||
export type VarroaLight = "green" | "yellow" | "red";
|
||||
|
||||
/** Natural mite fall as mites per day; same formula as the backend. */
|
||||
/** Natural mite fall as mites per day, rounded to two decimals like the stored value. */
|
||||
export function mitesPerDay(
|
||||
miteCount: number | null,
|
||||
durationMinutes: number | null,
|
||||
@@ -13,12 +8,5 @@ export function mitesPerDay(
|
||||
if (miteCount === null || durationMinutes === null) return null;
|
||||
if (!Number.isFinite(miteCount) || !Number.isFinite(durationMinutes)) return null;
|
||||
if (durationMinutes <= 0) return null;
|
||||
return (miteCount / durationMinutes) * MINUTES_PER_DAY;
|
||||
}
|
||||
|
||||
export function varroaLight(rate: number | null, threshold: number | null): VarroaLight | null {
|
||||
if (rate === null || threshold === null) return null;
|
||||
if (rate >= threshold) return "red";
|
||||
if (rate >= threshold * VARROA_WARN_FRACTION) return "yellow";
|
||||
return "green";
|
||||
return Math.round((miteCount / durationMinutes) * MINUTES_PER_DAY * 100) / 100;
|
||||
}
|
||||
@@ -4,6 +4,8 @@ export type VarroaMethod =
|
||||
| "WASHING_METHOD"
|
||||
| "DRONE_BROOD_CONTROL";
|
||||
|
||||
import type { VarroaRating } from "./bands";
|
||||
|
||||
export interface VarroaMeasurement {
|
||||
id: string;
|
||||
colonyId: string;
|
||||
@@ -12,10 +14,15 @@ export interface VarroaMeasurement {
|
||||
method: VarroaMethod;
|
||||
durationMinutes: number | null;
|
||||
miteCount: number | null;
|
||||
/** Sample size in bees (powdered sugar, washing). */
|
||||
sampleBees: number | null;
|
||||
/** Mites per day (natural fall) or percent (sample methods). */
|
||||
calculatedValue: number | null;
|
||||
assessment: string | null;
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
/** Only on a colony's list; absent on create and update responses. */
|
||||
rating?: VarroaRating | null;
|
||||
}
|
||||
|
||||
/** Optional fields: `null` or omitted both mean "no value". */
|
||||
@@ -25,6 +32,7 @@ export interface CreateVarroaMeasurementInput {
|
||||
method: VarroaMethod;
|
||||
durationMinutes?: number | null;
|
||||
miteCount?: number | null;
|
||||
sampleBees?: number | null;
|
||||
calculatedValue?: number | null;
|
||||
assessment?: string | null;
|
||||
notes?: string | null;
|
||||
@@ -38,6 +46,7 @@ export interface UpdateVarroaMeasurementInput {
|
||||
method?: VarroaMethod;
|
||||
durationMinutes?: number | null;
|
||||
miteCount?: number | null;
|
||||
sampleBees?: number | null;
|
||||
calculatedValue?: number | null;
|
||||
assessment?: string | null;
|
||||
notes?: string | null;
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import { invalidateVarroaBands, useVarroaBands } from "./use-varroa-bands";
|
||||
import { fetchVarroaThresholds } from "./api";
|
||||
import type { VarroaBands } from "./bands";
|
||||
|
||||
vi.mock("./api", () => ({ fetchVarroaThresholds: vi.fn() }));
|
||||
|
||||
const BANDS: VarroaBands = { day: "2026-08-10", bands: {}, postTreatmentUntil: null, active: true };
|
||||
|
||||
function setup() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
);
|
||||
return { client, wrapper };
|
||||
}
|
||||
|
||||
describe("useVarroaBands", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchVarroaThresholds).mockReset();
|
||||
});
|
||||
|
||||
it("asks nothing without a colony", () => {
|
||||
const { wrapper } = setup();
|
||||
const { result } = renderHook(() => useVarroaBands(null, "2026-08-10"), { wrapper });
|
||||
expect(result.current).toBeNull();
|
||||
expect(fetchVarroaThresholds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("asks nothing for a day that is not complete", () => {
|
||||
const { wrapper } = setup();
|
||||
renderHook(() => useVarroaBands("c-1", "2026-08"), { wrapper });
|
||||
expect(fetchVarroaThresholds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the bands of the colony and day", async () => {
|
||||
vi.mocked(fetchVarroaThresholds).mockResolvedValue(BANDS);
|
||||
const { wrapper } = setup();
|
||||
const { result } = renderHook(() => useVarroaBands("c-1", "2026-08-10"), { wrapper });
|
||||
await waitFor(() => expect(result.current).toEqual(BANDS));
|
||||
expect(fetchVarroaThresholds).toHaveBeenCalledWith("c-1", "2026-08-10");
|
||||
});
|
||||
|
||||
it("stays null without a crash when the request fails", async () => {
|
||||
vi.mocked(fetchVarroaThresholds).mockImplementation(async () => {
|
||||
throw new TypeError("Failed to fetch");
|
||||
});
|
||||
const { client, wrapper } = setup();
|
||||
const { result } = renderHook(() => useVarroaBands("c-1", "2026-08-10"), { wrapper });
|
||||
await waitFor(() =>
|
||||
expect(client.getQueryState(["varroa", "thresholds", "c-1", "2026-08-10"])?.status).toBe("error"),
|
||||
);
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalidateVarroaBands", () => {
|
||||
it("marks single-day and range keys stale and leaves other keys alone", async () => {
|
||||
const { client } = setup();
|
||||
client.setQueryData(["varroa", "thresholds", "c-1", "2026-08-10"], BANDS);
|
||||
client.setQueryData(["varroa", "thresholds", "c-1", "2024-08-01", "2026-07-31"], { segments: [] });
|
||||
client.setQueryData(["colonies", "c-1", "treatments"], []);
|
||||
|
||||
await invalidateVarroaBands(client);
|
||||
|
||||
expect(client.getQueryState(["varroa", "thresholds", "c-1", "2026-08-10"])?.isInvalidated).toBe(true);
|
||||
expect(
|
||||
client.getQueryState(["varroa", "thresholds", "c-1", "2024-08-01", "2026-07-31"])?.isInvalidated,
|
||||
).toBe(true);
|
||||
expect(client.getQueryState(["colonies", "c-1", "treatments"])?.isInvalidated).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { QueryClient, useQuery } from "@tanstack/react-query";
|
||||
import type { VarroaBands } from "./bands";
|
||||
import { fetchVarroaThresholds } from "./api";
|
||||
|
||||
const ONE_HOUR_MS = 60 * 60 * 1000;
|
||||
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/** The bands for a colony and day; null while unknown, offline, without a colony or a valid day. */
|
||||
export function useVarroaBands(colonyId: string | null, date: string): VarroaBands | null {
|
||||
const query = useQuery({
|
||||
queryKey: ["varroa", "thresholds", colonyId, date],
|
||||
queryFn: () => fetchVarroaThresholds(colonyId, date),
|
||||
enabled: colonyId !== null && DAY_PATTERN.test(date),
|
||||
staleTime: ONE_HOUR_MS,
|
||||
});
|
||||
return query.data ?? null;
|
||||
}
|
||||
|
||||
/** The bands depend on treatments and on the rule: after either changes, every cached band is stale. */
|
||||
export function invalidateVarroaBands(queryClient: QueryClient): Promise<void> {
|
||||
return queryClient.invalidateQueries({ queryKey: ["varroa", "thresholds"] });
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchVarroaThreshold } from "./api";
|
||||
|
||||
const ONE_HOUR_MS = 60 * 60 * 1000;
|
||||
|
||||
/** The configured mites-per-day threshold; null while unknown or when the rule is off. */
|
||||
export function useVarroaThreshold(): number | null {
|
||||
const query = useQuery({
|
||||
queryKey: ["varroa", "threshold"],
|
||||
queryFn: fetchVarroaThreshold,
|
||||
staleTime: ONE_HOUR_MS,
|
||||
});
|
||||
return query.data?.thresholdMitesPerDay ?? null;
|
||||
}
|
||||
Reference in new issue
Block a user