feat: T4 wave 5 - colony end cleanup, withdrawal chain, next steps, inspection takeover UI
- Colony end releases the queen in the same transaction; open suggestions become obsolete and a CLEANUP suggestion lists open tasks and the queen; apply-cleanup validates evidence and blocks reactivated colonies - Withdrawal chain: plausibility warnings on harvest and bottling, conflict notifications also when a treatment is entered or corrected after the harvest (naming bottled lots), withdrawal spans in the calendar and M365 sync, Berlin-day end events - Next 3 steps per colony and the "Heute" endpoint with ranking and an explicit merge table by intent - Inspection-triggered rule suggestions follow the colony's latest inspection (resolve when the finding is gone, refresh when it stays) - Frontend: colony card takeover in the inspection form, feed status as condition, occupied frames 0-40; legacy offline entries keep syncing - Notifications and preferences are deleted with their user Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
02eb16924e
commit
ef33e5549b
122 files changed
+10707
-504
No files matched your search
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
FEED_CONDITION_LABELS,
|
||||
INSPECTION_FEED_OPTIONS,
|
||||
feedConditionFromInput,
|
||||
ratingToCondition,
|
||||
} from "./condition";
|
||||
|
||||
describe("FEED_CONDITION_LABELS", () => {
|
||||
it("words the feed levels like the backend", () => {
|
||||
expect(FEED_CONDITION_LABELS).toEqual({
|
||||
EXCELLENT: "Reichlich",
|
||||
GOOD: "Ausreichend",
|
||||
AVERAGE: "Mittel",
|
||||
WEAK: "Niedrig",
|
||||
CRITICAL: "Kritisch",
|
||||
});
|
||||
});
|
||||
|
||||
it("offers three levels in an inspection", () => {
|
||||
expect(INSPECTION_FEED_OPTIONS).toEqual(["GOOD", "WEAK", "CRITICAL"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ratingToCondition", () => {
|
||||
it("maps the brood pattern 5..1 to excellent..critical", () => {
|
||||
expect([5, 4, 3, 2, 1].map(ratingToCondition)).toEqual([
|
||||
"EXCELLENT",
|
||||
"GOOD",
|
||||
"AVERAGE",
|
||||
"WEAK",
|
||||
"CRITICAL",
|
||||
]);
|
||||
});
|
||||
|
||||
it("gives nothing for no or an impossible rating", () => {
|
||||
expect(ratingToCondition(null)).toBeNull();
|
||||
expect(ratingToCondition(0)).toBeNull();
|
||||
expect(ratingToCondition(6)).toBeNull();
|
||||
expect(ratingToCondition(2.5)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("feedConditionFromInput", () => {
|
||||
it("keeps enum values, in any case", () => {
|
||||
expect(feedConditionFromInput("WEAK")).toBe("WEAK");
|
||||
expect(feedConditionFromInput("excellent")).toBe("EXCELLENT");
|
||||
});
|
||||
|
||||
it("maps the legacy words of the previous version, trimmed and case-insensitive", () => {
|
||||
expect(feedConditionFromInput("ausreichend")).toBe("GOOD");
|
||||
expect(feedConditionFromInput(" Niedrig ")).toBe("WEAK");
|
||||
expect(feedConditionFromInput("KRITISCH")).toBe("CRITICAL");
|
||||
});
|
||||
|
||||
it("gives null for empty, unknown or inherited-looking text", () => {
|
||||
expect(feedConditionFromInput(null)).toBeNull();
|
||||
expect(feedConditionFromInput(undefined)).toBeNull();
|
||||
expect(feedConditionFromInput(" ")).toBeNull();
|
||||
expect(feedConditionFromInput("halb voll")).toBeNull();
|
||||
expect(feedConditionFromInput("constructor")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { ColonyCondition } from "./types";
|
||||
|
||||
/** Feed levels worded as the backend words them (colony card and inspection alike). */
|
||||
export const FEED_CONDITION_LABELS: Record<ColonyCondition, string> = {
|
||||
EXCELLENT: "Reichlich",
|
||||
GOOD: "Ausreichend",
|
||||
AVERAGE: "Mittel",
|
||||
WEAK: "Niedrig",
|
||||
CRITICAL: "Kritisch",
|
||||
};
|
||||
|
||||
/** What an inspection offers for feed; „Nicht geprüft“ (null) comes on top. */
|
||||
export const INSPECTION_FEED_OPTIONS = ["GOOD", "WEAK", "CRITICAL"] as const;
|
||||
|
||||
const CONDITION_BY_BROOD_PATTERN: Readonly<Record<number, ColonyCondition>> = {
|
||||
5: "EXCELLENT",
|
||||
4: "GOOD",
|
||||
3: "AVERAGE",
|
||||
2: "WEAK",
|
||||
1: "CRITICAL",
|
||||
};
|
||||
|
||||
/** Brood pattern 1-5 as the colony card's brood status (same mapping as the backend takeover). */
|
||||
export function ratingToCondition(rating: number | null): ColonyCondition | null {
|
||||
if (rating === null) {
|
||||
return null;
|
||||
}
|
||||
return CONDITION_BY_BROOD_PATTERN[rating] ?? null;
|
||||
}
|
||||
|
||||
const CONDITIONS: ReadonlySet<string> = new Set(Object.keys(FEED_CONDITION_LABELS));
|
||||
|
||||
// The words the app used before the feed vocabulary became the colony scale.
|
||||
const LEGACY_FEED_WORDS: ReadonlyMap<string, ColonyCondition> = new Map([
|
||||
["ausreichend", "GOOD"],
|
||||
["niedrig", "WEAK"],
|
||||
["kritisch", "CRITICAL"],
|
||||
]);
|
||||
|
||||
/**
|
||||
* A feed value from anywhere (a draft, an offline pack or a queued entry of
|
||||
* the previous app version) as the enum. The server maps the same legacy
|
||||
* words; anything else is not a feed level and gives null.
|
||||
*/
|
||||
export function feedConditionFromInput(value: string | null | undefined): ColonyCondition | null {
|
||||
const word = value?.trim().toLowerCase() ?? "";
|
||||
if (word === "") {
|
||||
return null;
|
||||
}
|
||||
const upper = word.toUpperCase();
|
||||
if (CONDITIONS.has(upper)) {
|
||||
return upper as ColonyCondition;
|
||||
}
|
||||
return LEGACY_FEED_WORDS.get(word) ?? null;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { colonyStrengthText } from "./labels";
|
||||
|
||||
describe("colonyStrengthText", () => {
|
||||
it("names the occupied frames", () => {
|
||||
expect(colonyStrengthText({ occupiedFrames: 8, strength: 7 })).toBe("8 besetzte Waben");
|
||||
expect(colonyStrengthText({ occupiedFrames: 1, strength: null })).toBe("1 besetzte Wabe");
|
||||
expect(colonyStrengthText({ occupiedFrames: 0, strength: null })).toBe("0 besetzte Waben");
|
||||
});
|
||||
|
||||
it("falls back to the old 1–10 scale, marked as old", () => {
|
||||
expect(colonyStrengthText({ occupiedFrames: null, strength: 7 })).toBe(
|
||||
"Volksstärke (alt): 7 von 10",
|
||||
);
|
||||
});
|
||||
|
||||
it("says nothing when neither is stored", () => {
|
||||
expect(colonyStrengthText({ occupiedFrames: null, strength: null })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Colony, ColonyCondition, ColonyStatus, ColonyType } from "./types";
|
||||
import { Colony, ColonyCondition, ColonyStatus, ColonyType, SplitQueenSource } from "./types";
|
||||
import { LIVING_COLONY_STATUSES } from "./status";
|
||||
|
||||
// Werte und Reihenfolge aus docs/lastenheft.md Abschnitt 4.3 ("Moegliche Volkstypen"/"Moegliche Status").
|
||||
@@ -79,3 +79,24 @@ export const COLONY_CONDITION_TONE_CLASS: Record<ColonyCondition, string> = {
|
||||
export const COLONY_STRENGTH_LABELS: Record<string, string> = Object.fromEntries(
|
||||
Array.from({ length: 10 }, (_, index) => [String(index + 1), String(index + 1)]),
|
||||
);
|
||||
|
||||
/** The most occupied frames the server accepts ("Höchstens 40 besetzte Waben."). */
|
||||
export const MAX_OCCUPIED_FRAMES = 40;
|
||||
|
||||
export const SPLIT_QUEEN_SOURCE_LABELS: Record<SplitQueenSource, string> = {
|
||||
RAISE_OWN: "Eigene Zellen ziehen",
|
||||
QUEEN_CELL: "Weiselzelle",
|
||||
QUEEN: "Königin",
|
||||
};
|
||||
|
||||
/** What the colony page says about the colony's size, or null when nothing is stored. */
|
||||
export function colonyStrengthText(
|
||||
colony: Pick<Colony, "occupiedFrames" | "strength">,
|
||||
): string | null {
|
||||
if (colony.occupiedFrames !== null) {
|
||||
return colony.occupiedFrames === 1
|
||||
? "1 besetzte Wabe"
|
||||
: `${colony.occupiedFrames} besetzte Waben`;
|
||||
}
|
||||
return colony.strength === null ? null : `Volksstärke (alt): ${colony.strength} von 10`;
|
||||
}
|
||||
@@ -26,7 +26,10 @@ export interface Colony {
|
||||
originText: string | null;
|
||||
colonyType: ColonyType;
|
||||
status: ColonyStatus;
|
||||
/** Deprecated 1-10 scale; kept as stored, never converted. */
|
||||
strength: number | null;
|
||||
/** Occupied frames, 0-40. */
|
||||
occupiedFrames: number | null;
|
||||
broodStatus: ColonyCondition | null;
|
||||
feedStatus: ColonyCondition | null;
|
||||
healthStatus: ColonyCondition | null;
|
||||
@@ -43,6 +46,7 @@ export interface CreateColonyInput {
|
||||
colonyType: ColonyType;
|
||||
hiveType?: string | null;
|
||||
originText?: string | null;
|
||||
occupiedFrames?: number | null;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
@@ -55,6 +59,7 @@ export interface UpdateColonyInput {
|
||||
originText?: string | null;
|
||||
colonyType?: ColonyType;
|
||||
strength?: number | null;
|
||||
occupiedFrames?: number | null;
|
||||
broodStatus?: ColonyCondition | null;
|
||||
feedStatus?: ColonyCondition | null;
|
||||
healthStatus?: ColonyCondition | null;
|
||||
@@ -67,10 +72,17 @@ export interface MoveColonyInput {
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
/** Where the split colony gets its queen from - an observation, never prefilled. */
|
||||
export type SplitQueenSource = "RAISE_OWN" | "QUEEN_CELL" | "QUEEN";
|
||||
|
||||
export interface SplitColonyInput {
|
||||
code: string;
|
||||
colonyType: ColonyType;
|
||||
notes?: string;
|
||||
/** Day of the split, "YYYY-MM-DD" (not after today). */
|
||||
date?: string;
|
||||
/** Without it no follow-up plan is made. */
|
||||
queenSource?: SplitQueenSource;
|
||||
}
|
||||
|
||||
export interface MergeColonyInput {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { apiFetch } from "@/lib/auth/api-client";
|
||||
import { CreateOptions, idempotencyHeaders } from "@/lib/offline/idempotency";
|
||||
import {
|
||||
CreateInspectionInput,
|
||||
CreatedInspection,
|
||||
CreateTaskFromInspectionInput,
|
||||
Inspection,
|
||||
InspectionTask,
|
||||
@@ -15,8 +16,8 @@ export function fetchColonyInspections(colonyId: string): Promise<Inspection[]>
|
||||
export function createInspection(
|
||||
input: CreateInspectionInput,
|
||||
options: CreateOptions = {},
|
||||
): Promise<Inspection> {
|
||||
return apiFetch<Inspection>("/inspections", {
|
||||
): Promise<CreatedInspection> {
|
||||
return apiFetch<CreatedInspection>("/inspections", {
|
||||
method: "POST",
|
||||
headers: idempotencyHeaders(options),
|
||||
body: JSON.stringify(input),
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
previousLabel,
|
||||
previousValueOf,
|
||||
takeOverAll,
|
||||
takeoverSkipNotice,
|
||||
undoTakeOver,
|
||||
} from "./takeover";
|
||||
import type { Inspection } from "./types";
|
||||
@@ -30,7 +31,7 @@ function last(overrides: Partial<Inspection> = {}): Inspection {
|
||||
swarmMood: 4,
|
||||
gentleness: 2,
|
||||
combBehavior: null,
|
||||
feedStatus: "ausreichend",
|
||||
feedStatus: "GOOD",
|
||||
honeySuperStatus: null,
|
||||
healthIssues: null,
|
||||
varroaIssues: null,
|
||||
@@ -57,20 +58,20 @@ describe("takeOverAll", () => {
|
||||
colonyStrength: "8",
|
||||
swarmMood: 4,
|
||||
gentleness: 2,
|
||||
feedStatus: "ausreichend",
|
||||
feedStatus: "GOOD",
|
||||
});
|
||||
expect(taken).toContain("queenSeen");
|
||||
expect(taken).toContain("broodFrames");
|
||||
});
|
||||
|
||||
it("never overwrites a typed value", () => {
|
||||
const typed = { ...blank(), queenSeen: false, broodFrames: "2", feedStatus: "kritisch" };
|
||||
const typed = { ...blank(), queenSeen: false, broodFrames: "2", feedStatus: "CRITICAL" as const };
|
||||
|
||||
const { values, taken } = takeOverAll(typed, last());
|
||||
|
||||
expect(values.queenSeen).toBe(false);
|
||||
expect(values.broodFrames).toBe("2");
|
||||
expect(values.feedStatus).toBe("kritisch");
|
||||
expect(values.feedStatus).toBe("CRITICAL");
|
||||
expect(taken).not.toContain("queenSeen");
|
||||
expect(taken).not.toContain("broodFrames");
|
||||
expect(taken).not.toContain("feedStatus");
|
||||
@@ -87,10 +88,20 @@ describe("takeOverAll", () => {
|
||||
expect(taken).not.toContain("honeySuperStatus");
|
||||
});
|
||||
|
||||
it("keeps a legacy free-text feedStatus verbatim", () => {
|
||||
const { values } = takeOverAll(blank(), last({ feedStatus: "noch 2 kg Futterteig" }));
|
||||
it("reads the legacy feed words of an older offline pack as the enum", () => {
|
||||
const legacy = last({ feedStatus: "niedrig" as never });
|
||||
|
||||
expect(values.feedStatus).toBe("noch 2 kg Futterteig");
|
||||
expect(previousValueOf(legacy, "feedStatus")).toBe("WEAK");
|
||||
expect(takeOverAll(blank(), legacy).values.feedStatus).toBe("WEAK");
|
||||
});
|
||||
|
||||
it("offers no feed value for free text - the free-text special case is gone", () => {
|
||||
const freeText = last({ feedStatus: "noch 2 kg Futterteig" as never });
|
||||
|
||||
expect(previousValueOf(freeText, "feedStatus")).toBeNull();
|
||||
const { values, taken } = takeOverAll(blank(), freeText);
|
||||
expect(values.feedStatus).toBeNull();
|
||||
expect(taken).not.toContain("feedStatus");
|
||||
});
|
||||
|
||||
it("does not change the values it was given", () => {
|
||||
@@ -121,8 +132,9 @@ describe("previousValueOf / previousLabel", () => {
|
||||
expect(previousLabel("gentleness", 3)).toBe("3 von 5");
|
||||
expect(previousLabel("colonyStrength", "8")).toBe("8 Waben");
|
||||
expect(previousLabel("broodFrames", "1")).toBe("1 Wabe");
|
||||
expect(previousLabel("feedStatus", "ausreichend")).toBe("Ausreichend");
|
||||
expect(previousLabel("feedStatus", "noch 2 kg Futterteig")).toBe("noch 2 kg Futterteig");
|
||||
expect(previousLabel("feedStatus", "GOOD")).toBe("Ausreichend");
|
||||
expect(previousLabel("feedStatus", "AVERAGE")).toBe("Mittel");
|
||||
expect(previousLabel("feedStatus", "EXCELLENT")).toBe("Reichlich");
|
||||
});
|
||||
|
||||
it("words swarm mood as the buttons show it (mirrored)", () => {
|
||||
@@ -168,3 +180,39 @@ describe("fields absent from the payload", () => {
|
||||
expect(takeOverAll(blank(), sparse).values.broodFrames).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("takeoverSkipNotice", () => {
|
||||
it("explains a skipped takeover caused by a newer inspection", () => {
|
||||
expect(takeoverSkipNotice([{ field: "feedStatus", reason: "NEWER_INSPECTION" }])).toBe(
|
||||
"Volkskarte nicht übernommen: Es gibt eine neuere Durchsicht.",
|
||||
);
|
||||
});
|
||||
|
||||
it("explains an ended colony", () => {
|
||||
expect(takeoverSkipNotice([{ field: "feedStatus", reason: "COLONY_ENDED" }])).toBe(
|
||||
"Volkskarte nicht übernommen: Das Volk ist beendet.",
|
||||
);
|
||||
});
|
||||
|
||||
it("stays silent when nothing was skipped or an answer was only empty", () => {
|
||||
expect(takeoverSkipNotice([])).toBeNull();
|
||||
expect(takeoverSkipNotice([{ field: "broodStatus", reason: "NO_VALUE" }])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("takeoverSkipNotice – fallback", () => {
|
||||
it("gives a generic notice for any other skipped reason", () => {
|
||||
for (const reason of ["NO_PERMISSION", "OUT_OF_RANGE"] as const) {
|
||||
expect(takeoverSkipNotice([{ field: "feedStatus", reason }])).toBe("Volkskarte nicht übernommen.");
|
||||
}
|
||||
});
|
||||
|
||||
it("prefers the specific text and ignores NO_VALUE beside it", () => {
|
||||
expect(
|
||||
takeoverSkipNotice([
|
||||
{ field: "broodStatus", reason: "NO_VALUE" },
|
||||
{ field: "feedStatus", reason: "NEWER_INSPECTION" },
|
||||
]),
|
||||
).toBe("Volkskarte nicht übernommen: Es gibt eine neuere Durchsicht.");
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,8 @@
|
||||
import {
|
||||
FEED_STATUS_OPTIONS,
|
||||
type InspectionFormValues,
|
||||
} from "@/app/(app)/inspections/new/inspection-form-values";
|
||||
import type { InspectionFormValues } from "@/app/(app)/inspections/new/inspection-form-values";
|
||||
import { FEED_CONDITION_LABELS, feedConditionFromInput } from "@/lib/colonies/condition";
|
||||
import { toCalendarDay } from "@/lib/dates";
|
||||
import { toDisplayRating } from "@/lib/forms/rating";
|
||||
import type { Inspection } from "./types";
|
||||
import type { ColonyTakeover, Inspection } from "./types";
|
||||
|
||||
/** The observations "Letztes Mal" offers for one-tap take-over, in form order. */
|
||||
export const TAKEOVER_FIELDS = [
|
||||
@@ -52,8 +50,10 @@ export function previousValueOf(
|
||||
case "colonyStrength":
|
||||
return countText(last.colonyStrength);
|
||||
case "feedStatus":
|
||||
// An offline pack of the previous version may still hold a legacy word.
|
||||
return feedConditionFromInput(last.feedStatus);
|
||||
case "honeySuperStatus":
|
||||
return textOrNull(last[field]);
|
||||
return textOrNull(last.honeySuperStatus);
|
||||
default:
|
||||
return last[field] ?? null;
|
||||
}
|
||||
@@ -86,7 +86,7 @@ export function previousLabel(
|
||||
case "colonyStrength":
|
||||
return wabenLabel(String(value));
|
||||
case "feedStatus":
|
||||
return FEED_STATUS_OPTIONS.find((option) => option.value === value)?.label ?? String(value);
|
||||
return FEED_CONDITION_LABELS[value as keyof typeof FEED_CONDITION_LABELS];
|
||||
case "honeySuperStatus":
|
||||
return String(value);
|
||||
default:
|
||||
@@ -145,3 +145,21 @@ export function undoTakeOver(
|
||||
current,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Why the colony card was left alone, in one sentence for a toast; null when
|
||||
* there is nothing the beekeeper needs to know (nothing skipped, or only
|
||||
* because an answer was empty). Reasons without their own text get a generic one.
|
||||
*/
|
||||
export function takeoverSkipNotice(skipped: ColonyTakeover["skipped"]): string | null {
|
||||
if (skipped.some((entry) => entry.reason === "NEWER_INSPECTION")) {
|
||||
return "Volkskarte nicht übernommen: Es gibt eine neuere Durchsicht.";
|
||||
}
|
||||
if (skipped.some((entry) => entry.reason === "COLONY_ENDED")) {
|
||||
return "Volkskarte nicht übernommen: Das Volk ist beendet.";
|
||||
}
|
||||
if (skipped.some((entry) => entry.reason !== "NO_VALUE")) {
|
||||
return "Volkskarte nicht übernommen.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ColonyCondition } from "@/lib/colonies/types";
|
||||
|
||||
export interface Inspection {
|
||||
id: string;
|
||||
colonyId: string;
|
||||
@@ -17,7 +19,7 @@ export interface Inspection {
|
||||
swarmMood: number | null;
|
||||
gentleness: number | null;
|
||||
combBehavior: number | null;
|
||||
feedStatus: string | null;
|
||||
feedStatus: ColonyCondition | null;
|
||||
honeySuperStatus: string | null;
|
||||
healthIssues: string | null;
|
||||
varroaIssues: string | null;
|
||||
@@ -26,6 +28,28 @@ export interface Inspection {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** Colony-card values an inspection can hand over; same names as the backend. */
|
||||
export const COLONY_TAKEOVER_FIELDS = ["occupiedFrames", "broodStatus", "feedStatus"] as const;
|
||||
export type ColonyTakeoverField = (typeof COLONY_TAKEOVER_FIELDS)[number];
|
||||
|
||||
export type ColonyTakeoverSkipReason =
|
||||
| "NO_VALUE"
|
||||
| "NO_PERMISSION"
|
||||
| "NEWER_INSPECTION"
|
||||
| "COLONY_ENDED"
|
||||
| "OUT_OF_RANGE";
|
||||
|
||||
/** What the server did with the requested applyToColony fields. */
|
||||
export interface ColonyTakeover {
|
||||
applied: ColonyTakeoverField[];
|
||||
skipped: { field: ColonyTakeoverField; reason: ColonyTakeoverSkipReason }[];
|
||||
}
|
||||
|
||||
/** The answer to a new inspection: the inspection plus what reached the colony card. */
|
||||
export interface CreatedInspection extends Inspection {
|
||||
colonyTakeover?: ColonyTakeover;
|
||||
}
|
||||
|
||||
export interface CreateInspectionInput {
|
||||
colonyId: string;
|
||||
date: string;
|
||||
@@ -43,12 +67,18 @@ export interface CreateInspectionInput {
|
||||
swarmMood?: number | null;
|
||||
gentleness?: number | null;
|
||||
combBehavior?: number | null;
|
||||
feedStatus?: string | null;
|
||||
feedStatus?: ColonyCondition | null;
|
||||
honeySuperStatus?: string | null;
|
||||
healthIssues?: string | null;
|
||||
varroaIssues?: string | null;
|
||||
measures?: string | null;
|
||||
notes?: string | null;
|
||||
/**
|
||||
* Colony-card fields to fill from this inspection (creating only; a
|
||||
* correction never takes anything over). Needs colonies:update on the
|
||||
* server, which answers 201 either way.
|
||||
*/
|
||||
applyToColony?: ColonyTakeoverField[];
|
||||
}
|
||||
|
||||
/** `null` clears an optional field, an omitted one stays unchanged. */
|
||||
@@ -70,7 +100,7 @@ export interface UpdateInspectionInput {
|
||||
swarmMood?: number | null;
|
||||
gentleness?: number | null;
|
||||
combBehavior?: number | null;
|
||||
feedStatus?: string | null;
|
||||
feedStatus?: ColonyCondition | null;
|
||||
honeySuperStatus?: string | null;
|
||||
healthIssues?: string | null;
|
||||
varroaIssues?: string | null;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
foreignEntriesWaiting,
|
||||
photosAttached,
|
||||
photosNotTakenOver,
|
||||
rejectionText,
|
||||
sessionExpiredWithEntries,
|
||||
} from "./queue-texts";
|
||||
|
||||
@@ -44,3 +45,15 @@ describe("queue texts", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rejectionText", () => {
|
||||
it("names the field for the too-many-frames rejection of an old entry", () => {
|
||||
expect(rejectionText("Höchstens 40 besetzte Waben.")).toBe(
|
||||
"Besetzte Waben: höchstens 40 – bitte im Eintrag korrigieren.",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes other server reasons through", () => {
|
||||
expect(rejectionText("Datei zu groß")).toBe("Datei zu groß");
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,18 @@ export function photosNotTakenOver(n: number): string {
|
||||
return `${count(n, "Foto wurde", "Fotos wurden")} nicht übernommen und verworfen`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's reason for rejecting a queued entry, in words the beekeeper can
|
||||
* act on. Old entries may hold more than 40 besetzte Waben; they are never
|
||||
* clamped silently.
|
||||
*/
|
||||
export function rejectionText(message: string): string {
|
||||
if (/Höchstens 40 besetzte Waben/.test(message)) {
|
||||
return "Besetzte Waben: höchstens 40 – bitte im Eintrag korrigieren.";
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
/** Sign-out dialog: "3 Einträge sind noch nicht gesendet." */
|
||||
export function entriesNotSent(n: number): string {
|
||||
return n === 1 ? "1 Eintrag ist noch nicht gesendet." : `${n} Einträge sind noch nicht gesendet.`;
|
||||
|
||||
@@ -135,6 +135,34 @@ describe("syncPendingEntries", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("sends an inspection queued by the previous version with its legacy feed word unchanged", async () => {
|
||||
// The server maps ausreichend/niedrig/kritisch itself; the queue must not rewrite or reject them.
|
||||
const legacy = { ...inspection(1), feedStatus: "niedrig" } as never;
|
||||
await db.queueInspection(legacy, "key-legacy-feed-0001", "user-1");
|
||||
createInspection.mockResolvedValue({ id: "x" } as never);
|
||||
|
||||
const result = await sync.syncPendingEntries("user-1");
|
||||
|
||||
expect(result).toEqual({ synced: 1, failed: 0, skippedForeign: 0 });
|
||||
expect(createInspection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ feedStatus: "niedrig" }),
|
||||
{ idempotencyKey: "key-legacy-feed-0001" },
|
||||
);
|
||||
});
|
||||
|
||||
it("sends the Volkskarte fields of a queued inspection along", async () => {
|
||||
const queued = { ...inspection(1), feedStatus: "WEAK", applyToColony: ["feedStatus"] } as never;
|
||||
await db.queueInspection(queued, "key-takeover-0001", "user-1");
|
||||
createInspection.mockResolvedValue({ id: "x" } as never);
|
||||
|
||||
await sync.syncPendingEntries("user-1");
|
||||
|
||||
expect(createInspection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ feedStatus: "WEAK", applyToColony: ["feedStatus"] }),
|
||||
{ idempotencyKey: "key-takeover-0001" },
|
||||
);
|
||||
});
|
||||
|
||||
it("still sends entries queued before keys and owners existed", async () => {
|
||||
await db.summarizeQueue("user-1"); // creates the database at the current version
|
||||
const raw = await openDB("apicore-offline");
|
||||
|
||||
@@ -13,6 +13,7 @@ function colony(overrides: Partial<Colony>): Colony {
|
||||
colonyType: "PRODUCTION_COLONY",
|
||||
status: "ACTIVE",
|
||||
strength: null,
|
||||
occupiedFrames: null,
|
||||
broodStatus: null,
|
||||
feedStatus: null,
|
||||
healthStatus: null,
|
||||
|
||||
Reference in new issue
Block a user