feat: T4 wave 10 – bottling quality, phenology and honey chain UI, Telegram link/receive and ops
- Bottling quality: water content above the legal limit (strict >) answers 409 CONFIRMATION_REQUIRED bound to the measured value; prefill for jar size, best-before (shelfLifeMonths, month-end clamping) and water content; impossible dates give 400; water-content checks in src/common/quality. - Frontend phenology: "Entwicklung melden" sheet, IndexedDB v4 queue, season header on the apiary page, confirmed revoke for all apiaries. - Frontend honey chain: "Vorschlag aus …" hints with explicit take-over, prefill status note, movement ledger with withdraw/correct dialogs. - Telegram: link tokens (hashed, single use), private-chat receiver with lease and offset CAS, bounded poison-update skip, Retry-After, pending deliveries skipped on disconnect; system API for deliveries and retry; worker-only secret wiring and manifest policy tests. - ARCHITECTURE.md §7, §9, §10, §11.11–11.13, Annahmen 51–55. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
16cc557462
commit
fab7f7e12f
91 files changed
+8463
-104
No files matched your search
@@ -1,5 +1,10 @@
|
||||
import { apiFetch } from "@/lib/auth/api-client";
|
||||
import { CreateExtractionInput, Extraction, UpdateExtractionInput } from "./types";
|
||||
import {
|
||||
CreateExtractionInput,
|
||||
Extraction,
|
||||
ExtractionPrefill,
|
||||
UpdateExtractionInput,
|
||||
} from "./types";
|
||||
|
||||
export function fetchExtractions(): Promise<Extraction[]> {
|
||||
return apiFetch<Extraction[]>("/extractions");
|
||||
@@ -9,6 +14,12 @@ export function fetchExtraction(id: string): Promise<Extraction> {
|
||||
return apiFetch<Extraction>(`/extractions/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
/** Proposal from the chosen harvests (query key ["extractions", "prefill", ids]). */
|
||||
export function fetchExtractionPrefill(harvestIds: string[]): Promise<ExtractionPrefill> {
|
||||
const ids = harvestIds.map(encodeURIComponent).join(",");
|
||||
return apiFetch<ExtractionPrefill>(`/extractions/prefill?harvestIds=${ids}`);
|
||||
}
|
||||
|
||||
export function createExtraction(input: CreateExtractionInput): Promise<Extraction> {
|
||||
return apiFetch<Extraction>("/extractions", {
|
||||
method: "POST",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { PlausibilityWarning, WithWarnings } from "@/lib/warnings/types";
|
||||
|
||||
export interface ExtractionHarvestEntry {
|
||||
id: string;
|
||||
harvestId: string;
|
||||
@@ -11,7 +13,7 @@ export interface ExtractionHarvestEntry {
|
||||
};
|
||||
}
|
||||
|
||||
export interface Extraction {
|
||||
export interface Extraction extends WithWarnings {
|
||||
id: string;
|
||||
date: string;
|
||||
userId: string;
|
||||
@@ -46,3 +48,11 @@ export interface UpdateExtractionInput {
|
||||
temperature?: number | null;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
/** A proposal from the chosen harvests: never a measurement, never applied silently. */
|
||||
export interface ExtractionPrefill {
|
||||
amountKg: number | null;
|
||||
honeyType: string | null;
|
||||
sources: { harvestId: string; date: string; netWeight: number | null }[];
|
||||
warnings: PlausibilityWarning[];
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import { apiFetch } from "@/lib/auth/api-client";
|
||||
import { CreateOptions, idempotencyHeaders } from "@/lib/offline/idempotency";
|
||||
import {
|
||||
BookHoneyBatchMovementInput,
|
||||
CreateHoneyBatchInput,
|
||||
HoneyBatchMovement,
|
||||
HoneyBatchMovementPage,
|
||||
HoneyBatchPrefill,
|
||||
CreateHoneyMeasurementInput,
|
||||
UpdateHoneyMeasurementInput,
|
||||
HoneyBatch,
|
||||
@@ -107,3 +112,30 @@ export function removeHoneyBatchContainerAllocation(
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchHoneyBatchPrefill(extractionId: string): Promise<HoneyBatchPrefill> {
|
||||
return apiFetch<HoneyBatchPrefill>(
|
||||
`/honey-batches/prefill?extractionId=${encodeURIComponent(extractionId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** The newest bookings and whether older ones exist (the server sends 200). */
|
||||
export function fetchHoneyBatchMovementPage(id: string): Promise<HoneyBatchMovementPage> {
|
||||
return apiFetch<HoneyBatchMovementPage>(`/honey-batches/${encodeURIComponent(id)}/movements`);
|
||||
}
|
||||
|
||||
export async function fetchHoneyBatchMovements(id: string): Promise<HoneyBatchMovement[]> {
|
||||
return (await fetchHoneyBatchMovementPage(id)).items;
|
||||
}
|
||||
|
||||
export function bookHoneyBatchMovement(
|
||||
id: string,
|
||||
input: BookHoneyBatchMovementInput,
|
||||
options: CreateOptions,
|
||||
): Promise<HoneyBatchMovement> {
|
||||
return apiFetch<HoneyBatchMovement>(`/honey-batches/${encodeURIComponent(id)}/movements`, {
|
||||
method: "POST",
|
||||
headers: idempotencyHeaders(options),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { isNetworkError } from "@/lib/http/fetch-with-timeout";
|
||||
import { issuesByField } from "@/lib/automation/rule-configs";
|
||||
|
||||
const OFFLINE_TEXT = "Keine Verbindung – nichts gespeichert.";
|
||||
const INVALID_TEXT = "Die Eingabe ist ungültig.";
|
||||
|
||||
export interface SaveError {
|
||||
/** A text for the whole form (no field named). */
|
||||
general: string | null;
|
||||
/** Server messages per field (400 `issues[]`). */
|
||||
fields: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ONE place that reads a failed honey save: 400 `issues[]` per field,
|
||||
* Nest's English `message[]` replaced, a service message (409) as it is.
|
||||
* A 409 `CONFIRMATION_REQUIRED` (water content) is to be recognised here
|
||||
* (`status === 409` and `body.code`) once that flow exists.
|
||||
*/
|
||||
export function describeSaveError(error: unknown, fallback: string): SaveError {
|
||||
if (isNetworkError(error)) return { general: OFFLINE_TEXT, fields: {} };
|
||||
if (!(error instanceof ApiError)) return { general: fallback, fields: {} };
|
||||
const fields = issuesByField(error);
|
||||
if (Object.keys(fields).length > 0) return { general: null, fields };
|
||||
if (error.status === 400) {
|
||||
const message = (error.body as { message?: unknown } | null)?.message;
|
||||
return { general: typeof message === "string" && message ? message : INVALID_TEXT, fields: {} };
|
||||
}
|
||||
return { general: error.message || fallback, fields: {} };
|
||||
}
|
||||
|
||||
/** One line for forms without field slots (the booking dialogs). */
|
||||
export function saveErrorText(error: unknown, fallback: string): string {
|
||||
const { general, fields } = describeSaveError(error, fallback);
|
||||
return general ?? Object.values(fields).join(" ");
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { WITHDRAWAL_REASON_LABELS } from "@/lib/stock/labels";
|
||||
import type { HoneyBatchMovement, HoneyBatchMovementType } from "./types";
|
||||
|
||||
const GRAMS_PER_KG = 1000;
|
||||
const MINUS = "−";
|
||||
|
||||
const KG_FORMAT = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 3 });
|
||||
|
||||
export const HONEY_MOVEMENT_TYPE_LABELS: Record<HoneyBatchMovementType, string> = {
|
||||
BOTTLING: "Abfüllung",
|
||||
WITHDRAWAL: "Entnahme",
|
||||
CORRECTION: "Korrektur",
|
||||
SHOP_SALE: "Webshop-Verkauf",
|
||||
SHOP_RETURN: "Webshop-Rückbuchung",
|
||||
CONSUMPTION: "Verbrauch in Herstellung",
|
||||
};
|
||||
|
||||
export function gramsToKg(grams: number): number {
|
||||
return grams / GRAMS_PER_KG;
|
||||
}
|
||||
|
||||
/** "12,5 kg" - German decimal comma, gram precision. */
|
||||
export function formatKg(kg: number): string {
|
||||
return `${KG_FORMAT.format(Math.round(kg * GRAMS_PER_KG) / GRAMS_PER_KG)} kg`;
|
||||
}
|
||||
|
||||
/** "+2 kg" / "−12,5 kg": the sign is always written, a real minus sign. */
|
||||
export function formatSignedKg(kg: number): string {
|
||||
const rounded = Math.round(kg * GRAMS_PER_KG) / GRAMS_PER_KG;
|
||||
const sign = rounded < 0 ? MINUS : "+";
|
||||
return `${sign}${formatKg(Math.abs(rounded))}`;
|
||||
}
|
||||
|
||||
/** Title of one booking, e.g. "Entnahme · Probe / Muster". */
|
||||
export function movementTitle(movement: HoneyBatchMovement): string {
|
||||
const label = HONEY_MOVEMENT_TYPE_LABELS[movement.type];
|
||||
return movement.reason ? `${label} · ${WITHDRAWAL_REASON_LABELS[movement.reason]}` : label;
|
||||
}
|
||||
|
||||
/** Difference between the weighed amount and the booked one, on the gram grid. */
|
||||
export function differenceKg(weighedKg: number, bookedKg: number): number {
|
||||
return Math.round((weighedKg - bookedKg) * GRAMS_PER_KG) / GRAMS_PER_KG;
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { PlausibilityWarning, WithWarnings } from "@/lib/warnings/types";
|
||||
import type { WithdrawalReason } from "@/lib/stock/types";
|
||||
|
||||
export type HoneyBatchStatus =
|
||||
| "RAW"
|
||||
| "EXTRACTED"
|
||||
@@ -23,7 +26,7 @@ export interface StorageContainerRef {
|
||||
location: string | null;
|
||||
}
|
||||
|
||||
export interface HoneyBatch {
|
||||
export interface HoneyBatch extends WithWarnings {
|
||||
id: string;
|
||||
batchNumber: string;
|
||||
extractionId: string;
|
||||
@@ -120,3 +123,45 @@ export interface UpdateHoneyMeasurementInput {
|
||||
temperature?: number | null;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
/** A proposal for the new batch from its extraction: never a measurement. */
|
||||
export interface HoneyBatchPrefill {
|
||||
remainingKg: number | null;
|
||||
honeyType: string | null;
|
||||
waterContent: number | null;
|
||||
warnings: PlausibilityWarning[];
|
||||
}
|
||||
|
||||
export type HoneyBatchMovementType =
|
||||
| "BOTTLING"
|
||||
| "WITHDRAWAL"
|
||||
| "CORRECTION"
|
||||
| "SHOP_SALE"
|
||||
| "SHOP_RETURN"
|
||||
| "CONSUMPTION";
|
||||
|
||||
/** One booking in the movement book; the quantity is signed grams. */
|
||||
export interface HoneyBatchMovement {
|
||||
id: string;
|
||||
type: HoneyBatchMovementType;
|
||||
quantityGrams: number;
|
||||
reason: WithdrawalReason | null;
|
||||
notes: string | null;
|
||||
sourceBottlingBatchId: string | null;
|
||||
createdAt: string;
|
||||
userId: string | null;
|
||||
}
|
||||
|
||||
/** The newest bookings; `hasMore` when older ones are not sent. */
|
||||
export interface HoneyBatchMovementPage {
|
||||
items: HoneyBatchMovement[];
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
/** A manual booking. deltaKg is signed and never 0 (a withdrawal is negative). */
|
||||
export interface BookHoneyBatchMovementInput {
|
||||
type: "WITHDRAWAL" | "CORRECTION";
|
||||
deltaKg: number;
|
||||
reason?: WithdrawalReason;
|
||||
notes?: string;
|
||||
}
|
||||
@@ -63,12 +63,114 @@ async function createVersion2Queue(entry: object): Promise<void> {
|
||||
db.close();
|
||||
}
|
||||
|
||||
/** A device that last opened the app with DB v3 (before plant reports). */
|
||||
async function createVersion3Queue(inspection: object): Promise<void> {
|
||||
const db = await openDB(DB_NAME, 3, {
|
||||
upgrade(database) {
|
||||
for (const store of [...VERSION_2_STORES, "pending-bulk"]) {
|
||||
database.createObjectStore(store, {
|
||||
keyPath: "id",
|
||||
autoIncrement: true,
|
||||
});
|
||||
}
|
||||
const photos = database.createObjectStore("pending-photos", {
|
||||
keyPath: "id",
|
||||
});
|
||||
photos.createIndex("sessionId", "sessionId");
|
||||
photos.createIndex("inspectionIdempotencyKey", "inspectionIdempotencyKey");
|
||||
photos.createIndex("ownerUserId", "ownerUserId");
|
||||
database.createObjectStore("offline-packs", { keyPath: "apiaryId" });
|
||||
database.createObjectStore("meta");
|
||||
},
|
||||
});
|
||||
await db.add("pending-inspections", inspection);
|
||||
db.close();
|
||||
}
|
||||
|
||||
describe("offline queue database", () => {
|
||||
beforeEach(() => {
|
||||
globalThis.indexedDB = new IDBFactory();
|
||||
});
|
||||
|
||||
it("upgrading v2 → v3 keeps already queued inspections", async () => {
|
||||
it.each([
|
||||
["a fresh install (0)", 0],
|
||||
["version 1", 1],
|
||||
])("opening from %s creates the plant report store at version 4", async (_name, from) => {
|
||||
if (from === 1) {
|
||||
const old = await openDB(DB_NAME, 1, {
|
||||
upgrade(database) {
|
||||
database.createObjectStore("pending-inspections", {
|
||||
keyPath: "id",
|
||||
autoIncrement: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
await old.add("pending-inspections", {
|
||||
input: INSPECTION,
|
||||
queuedAt: "2026-10-01T08:00:00.000Z",
|
||||
});
|
||||
old.close();
|
||||
}
|
||||
const db = await loadDb();
|
||||
|
||||
await db.queuePhenologyReport(
|
||||
{ phase: "APFEL", date: "2026-04-20" },
|
||||
"plant-key-0000000003",
|
||||
"user-1",
|
||||
);
|
||||
|
||||
const upgraded = await openDB(DB_NAME);
|
||||
expect(upgraded.version).toBe(4);
|
||||
expect([...upgraded.objectStoreNames]).toContain("pending-phenology-reports");
|
||||
expect(await upgraded.count("pending-phenology-reports")).toBe(1);
|
||||
expect(await upgraded.count("pending-inspections")).toBe(from === 1 ? 1 : 0);
|
||||
upgraded.close();
|
||||
});
|
||||
|
||||
it("db upgrade 3 -> 4 keeps queued inspections and adds the plant report queue", async () => {
|
||||
await createVersion3Queue({
|
||||
input: INSPECTION,
|
||||
idempotencyKey: "key-from-v3",
|
||||
ownerUserId: "user-1",
|
||||
queuedAt: "2026-10-01T08:00:00.000Z",
|
||||
});
|
||||
const db = await loadDb();
|
||||
|
||||
const entries = await db.getPendingEntries("pending-inspections");
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
id: 1,
|
||||
input: INSPECTION,
|
||||
idempotencyKey: "key-from-v3",
|
||||
ownerUserId: "user-1",
|
||||
queuedAt: "2026-10-01T08:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
await db.queuePhenologyReport(
|
||||
{ phase: "SALWEIDE", date: "2026-03-20", apiaryId: null },
|
||||
"key-plant-0000000001",
|
||||
"user-1",
|
||||
);
|
||||
expect(await db.getPendingEntries("pending-phenology-reports")).toEqual([
|
||||
expect.objectContaining({
|
||||
input: { phase: "SALWEIDE", date: "2026-03-20", apiaryId: null },
|
||||
idempotencyKey: "key-plant-0000000001",
|
||||
ownerUserId: "user-1",
|
||||
}),
|
||||
]);
|
||||
expect(await db.summarizeQueue("user-1")).toEqual({
|
||||
own: 2,
|
||||
failed: 0,
|
||||
foreign: 0,
|
||||
});
|
||||
expect((await db.listQueue("user-1")).map((item) => item.label)).toContain("Pflanzenmeldung");
|
||||
|
||||
const upgraded = await openDB(DB_NAME);
|
||||
expect(upgraded.version).toBe(4);
|
||||
upgraded.close();
|
||||
});
|
||||
|
||||
it("upgrading v2 → v4 keeps already queued inspections", async () => {
|
||||
await createVersion2Queue({
|
||||
input: INSPECTION,
|
||||
idempotencyKey: "key-from-v2",
|
||||
@@ -90,7 +192,7 @@ describe("offline queue database", () => {
|
||||
expect(await db.summarizeQueue("user-1")).toEqual({ own: 1, failed: 0, foreign: 0 });
|
||||
|
||||
const upgraded = await openDB(DB_NAME);
|
||||
expect(upgraded.version).toBe(3);
|
||||
expect(upgraded.version).toBe(4);
|
||||
expect([...upgraded.objectStoreNames]).toEqual(
|
||||
expect.arrayContaining([
|
||||
...VERSION_2_STORES,
|
||||
@@ -98,6 +200,7 @@ describe("offline queue database", () => {
|
||||
"pending-photos",
|
||||
"offline-packs",
|
||||
"meta",
|
||||
"pending-phenology-reports",
|
||||
]),
|
||||
);
|
||||
expect([...upgraded.transaction("pending-photos").store.indexNames].sort()).toEqual([
|
||||
|
||||
@@ -7,6 +7,7 @@ import { CreateFeedingInput } from "@/lib/feedings/types";
|
||||
import { CreateVarroaMeasurementInput } from "@/lib/varroa/types";
|
||||
import { CreateSwarmControlCheckInput } from "@/lib/swarm-control/types";
|
||||
import { CreateHarvestInput } from "@/lib/harvests/types";
|
||||
import type { CreatePhenologyReportInput } from "@/lib/phenology/types";
|
||||
import {
|
||||
colonyIdsOfBulkInput,
|
||||
invalidColonyIdsOfBody,
|
||||
@@ -21,6 +22,7 @@ export interface RecordQueueInputs {
|
||||
"pending-varroa-measurements": CreateVarroaMeasurementInput;
|
||||
"pending-swarm-control-checks": CreateSwarmControlCheckInput;
|
||||
"pending-harvests": CreateHarvestInput;
|
||||
"pending-phenology-reports": CreatePhenologyReportInput;
|
||||
}
|
||||
export type RecordStoreName = keyof RecordQueueInputs;
|
||||
/** Every store whose entries wait to be sent and are shown in the queue sheet. */
|
||||
@@ -107,11 +109,12 @@ export interface OfflineDB extends DBSchema, RecordStores {
|
||||
|
||||
const DB_NAME = "apicore-offline";
|
||||
// v1 only had "pending-inspections", v2 added the other five quick-capture
|
||||
// types, v3 adds bulk entries, photos, offline packs and meta. Entries already
|
||||
// types, v3 adds bulk entries, photos, offline packs and meta, v4 adds plant
|
||||
// reports (Pflanzenentwicklung). Entries already
|
||||
// queued on a phone must survive every upgrade, so each store is only created
|
||||
// if it doesn't exist yet (guarded by oldVersion, per idb's upgrade()
|
||||
// convention), never recreated.
|
||||
const DB_VERSION = 3;
|
||||
const DB_VERSION = 4;
|
||||
|
||||
const RECORD_STORES: readonly RecordStoreName[] = [
|
||||
"pending-inspections",
|
||||
@@ -120,6 +123,7 @@ const RECORD_STORES: readonly RecordStoreName[] = [
|
||||
"pending-varroa-measurements",
|
||||
"pending-swarm-control-checks",
|
||||
"pending-harvests",
|
||||
"pending-phenology-reports",
|
||||
];
|
||||
const QUEUE_STORES: readonly QueueStoreName[] = [...RECORD_STORES, "pending-bulk"];
|
||||
|
||||
@@ -130,6 +134,7 @@ const RECORD_LABELS: Record<RecordStoreName, string> = {
|
||||
"pending-varroa-measurements": "Varroamessung",
|
||||
"pending-swarm-control-checks": "Schwarmkontrolle",
|
||||
"pending-harvests": "Ernte",
|
||||
"pending-phenology-reports": "Pflanzenmeldung",
|
||||
};
|
||||
const BULK_LABELS: Record<BulkKind, string> = {
|
||||
treatments: "Behandlung für mehrere Völker",
|
||||
@@ -172,6 +177,12 @@ export function getDb(): Promise<IDBPDatabase<OfflineDB>> {
|
||||
db.createObjectStore("offline-packs", { keyPath: "apiaryId" });
|
||||
db.createObjectStore("meta");
|
||||
}
|
||||
if (oldVersion < 4) {
|
||||
db.createObjectStore("pending-phenology-reports", {
|
||||
keyPath: "id",
|
||||
autoIncrement: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
// A newer app version in another tab wants to upgrade: step aside
|
||||
// instead of blocking it. The next call here opens the new version.
|
||||
@@ -263,6 +274,14 @@ export function queueHarvest(
|
||||
return queue("pending-harvests", input, idempotencyKey, ownerUserId);
|
||||
}
|
||||
|
||||
export function queuePhenologyReport(
|
||||
input: CreatePhenologyReportInput,
|
||||
idempotencyKey: string,
|
||||
ownerUserId: string,
|
||||
): Promise<void> {
|
||||
return queue("pending-phenology-reports", input, idempotencyKey, ownerUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* One entry for several colonies. The key belongs to this exact selection of
|
||||
* colonies: it is stored with the entry and sent again unchanged.
|
||||
|
||||
@@ -7,6 +7,7 @@ import { readHandOvers, rememberHandOver } from "@/lib/photos/handover";
|
||||
vi.mock("@/lib/inspections/api", () => ({ createInspection: vi.fn() }));
|
||||
vi.mock("@/lib/treatments/api", () => ({ createTreatment: vi.fn() }));
|
||||
vi.mock("@/lib/documents/api", () => ({ uploadDocument: vi.fn() }));
|
||||
vi.mock("@/lib/phenology/api", () => ({ createPhenologyReport: vi.fn() }));
|
||||
vi.mock("@/lib/apiaries/offline-pack", () => ({ refreshPacksAfterSync: vi.fn() }));
|
||||
|
||||
// What the inspection drain hands over to the photos, seen at that moment.
|
||||
@@ -45,6 +46,9 @@ let photos: PhotoStoreModule;
|
||||
let ApiError: ApiErrorClass;
|
||||
let createInspection: MockedFunction<typeof import("@/lib/inspections/api").createInspection>;
|
||||
let createTreatment: MockedFunction<typeof import("@/lib/treatments/api").createTreatment>;
|
||||
let createPhenologyReport: MockedFunction<
|
||||
typeof import("@/lib/phenology/api").createPhenologyReport
|
||||
>;
|
||||
let uploadDocument: MockedFunction<typeof import("@/lib/documents/api").uploadDocument>;
|
||||
let refreshPacksAfterSync: MockedFunction<
|
||||
typeof import("@/lib/apiaries/offline-pack").refreshPacksAfterSync
|
||||
@@ -95,6 +99,9 @@ describe("syncPendingEntries", () => {
|
||||
createInspection = vi.mocked((await import("@/lib/inspections/api")).createInspection);
|
||||
createTreatment = vi.mocked((await import("@/lib/treatments/api")).createTreatment);
|
||||
uploadDocument = vi.mocked((await import("@/lib/documents/api")).uploadDocument);
|
||||
createPhenologyReport = vi.mocked(
|
||||
(await import("@/lib/phenology/api")).createPhenologyReport,
|
||||
);
|
||||
refreshPacksAfterSync = vi.mocked(
|
||||
(await import("@/lib/apiaries/offline-pack")).refreshPacksAfterSync,
|
||||
);
|
||||
@@ -102,6 +109,7 @@ describe("syncPendingEntries", () => {
|
||||
createInspection.mockReset();
|
||||
createTreatment.mockReset();
|
||||
uploadDocument.mockReset();
|
||||
createPhenologyReport.mockReset();
|
||||
refreshPacksAfterSync.mockReset().mockResolvedValue(undefined);
|
||||
photoHandOver.calls.length = 0;
|
||||
photoHandOver.failFor = null;
|
||||
@@ -122,6 +130,55 @@ describe("syncPendingEntries", () => {
|
||||
expect(await db.getPendingEntries("pending-inspections")).toEqual([]);
|
||||
});
|
||||
|
||||
it("offline the plant report is queued and synced later with its key", async () => {
|
||||
const report = {
|
||||
phase: "SALWEIDE",
|
||||
date: "2026-03-20",
|
||||
apiaryId: null,
|
||||
} as const;
|
||||
await db.queuePhenologyReport(report, "plant-key-0000000001", "user-1");
|
||||
createPhenologyReport.mockResolvedValue({ id: "r-1" } as never);
|
||||
|
||||
const result = await sync.syncPendingEntries("user-1");
|
||||
|
||||
expect(result).toEqual({ synced: 1, failed: 0, skippedForeign: 0 });
|
||||
expect(createPhenologyReport).toHaveBeenCalledWith(report, {
|
||||
idempotencyKey: "plant-key-0000000001",
|
||||
});
|
||||
expect(await db.getPendingEntries("pending-phenology-reports")).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips a plant report of another account", async () => {
|
||||
await db.queuePhenologyReport(
|
||||
{ phase: "SALWEIDE", date: "2026-03-20" },
|
||||
"plant-key-0000000004",
|
||||
"user-2",
|
||||
);
|
||||
|
||||
const result = await sync.syncPendingEntries("user-1");
|
||||
|
||||
expect(result).toEqual({ synced: 0, failed: 0, skippedForeign: 1 });
|
||||
expect(createPhenologyReport).not.toHaveBeenCalled();
|
||||
expect(await db.getPendingEntries("pending-phenology-reports")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps a plant report the server rejected, with the reason", async () => {
|
||||
await db.queuePhenologyReport(
|
||||
{ phase: "KIRSCHE", date: "2026-04-02" },
|
||||
"plant-key-0000000002",
|
||||
"user-1",
|
||||
);
|
||||
createPhenologyReport.mockRejectedValue(
|
||||
new ApiError(400, "Das Datum liegt in der Zukunft.", {}),
|
||||
);
|
||||
|
||||
const result = await sync.syncPendingEntries("user-1");
|
||||
|
||||
expect(result.failed).toBe(1);
|
||||
const [entry] = await db.getPendingEntries("pending-phenology-reports");
|
||||
expect(entry.lastError?.message).toBe("Das Datum liegt in der Zukunft.");
|
||||
});
|
||||
|
||||
it("resends a queued entry with the idempotency key it was queued with", async () => {
|
||||
// The server may already have saved the first attempt; the same key lets
|
||||
// it answer with that record instead of storing a duplicate.
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createFeeding } from "@/lib/feedings/api";
|
||||
import { createVarroaMeasurement } from "@/lib/varroa/api";
|
||||
import { createSwarmControlCheck } from "@/lib/swarm-control/api";
|
||||
import { createHarvest } from "@/lib/harvests/api";
|
||||
import { createPhenologyReport } from "@/lib/phenology/api";
|
||||
import { uploadDocument } from "@/lib/documents/api";
|
||||
import {
|
||||
createFeedingsBulk,
|
||||
@@ -80,7 +81,7 @@ let generation = 0;
|
||||
let isOpen = true;
|
||||
|
||||
/**
|
||||
* Drains all six offline queues in one pass (not just inspections) - a
|
||||
* Drains all seven offline queues in one pass (not just inspections) - a
|
||||
* beekeeper working a row of hives with no signal may have queued several
|
||||
* different record types before connectivity returns. Only entries of
|
||||
* `currentUserId` (and ownerless ones from before owners existed) are sent.
|
||||
@@ -336,6 +337,7 @@ async function runSync(run: SyncRun): Promise<SyncResult> {
|
||||
drain("pending-varroa-measurements", createVarroaMeasurement, run),
|
||||
drain("pending-swarm-control-checks", createSwarmControlCheck, run),
|
||||
drain("pending-harvests", createHarvest, run),
|
||||
drain("pending-phenology-reports", createPhenologyReport, run),
|
||||
drainBulk(run),
|
||||
]);
|
||||
// Photos last: the drains above have just given them their inspection's
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { apiFetch } from "@/lib/auth/api-client";
|
||||
import { CreateOptions, idempotencyHeaders } from "@/lib/offline/idempotency";
|
||||
import { queuePhenologyReport as queueInOfflineDb } from "@/lib/offline/db";
|
||||
import type { CreatePhenologyReportInput, PhenologyReport, PhenologyState } from "./types";
|
||||
|
||||
export const phenologyStateKey = ["phenology", "state"] as const;
|
||||
|
||||
export function phenologyReportsKey(year: number) {
|
||||
return ["phenology", "reports", year] as const;
|
||||
}
|
||||
|
||||
export function fetchPhenologyState(): Promise<PhenologyState[]> {
|
||||
return apiFetch<PhenologyState[]>("/phenology/state");
|
||||
}
|
||||
|
||||
export function fetchPhenologyReports(year: number): Promise<PhenologyReport[]> {
|
||||
return apiFetch<PhenologyReport[]>(`/phenology/reports?year=${encodeURIComponent(year)}`);
|
||||
}
|
||||
|
||||
export function createPhenologyReport(
|
||||
input: CreatePhenologyReportInput,
|
||||
options: CreateOptions = {},
|
||||
): Promise<PhenologyReport> {
|
||||
return apiFetch<PhenologyReport>("/phenology/reports", {
|
||||
method: "POST",
|
||||
headers: idempotencyHeaders(options),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function revokePhenologyReport(
|
||||
id: string,
|
||||
options: CreateOptions = {},
|
||||
): Promise<PhenologyReport> {
|
||||
return apiFetch<PhenologyReport>(`/phenology/reports/${encodeURIComponent(id)}/revoke`, {
|
||||
method: "POST",
|
||||
headers: idempotencyHeaders(options),
|
||||
});
|
||||
}
|
||||
|
||||
/** Without a connection the report waits in the offline queue until it can be sent. */
|
||||
export function queuePhenologyReport(
|
||||
input: CreatePhenologyReportInput,
|
||||
idempotencyKey: string,
|
||||
ownerUserId: string,
|
||||
): Promise<void> {
|
||||
return queueInOfflineDb(input, idempotencyKey, ownerUserId);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PHASE_REPORT_LABELS, SEASON_NOTE_LABELS, PHASE_SOURCE_LABELS, phaseSourceText } from "./labels";
|
||||
import { PHENOLOGY_PHASES } from "./types";
|
||||
|
||||
describe("phenology labels", () => {
|
||||
it("names every phase the way a beekeeper says it", () => {
|
||||
expect(PHENOLOGY_PHASES.map((phase) => PHASE_REPORT_LABELS[phase])).toEqual([
|
||||
"Salweide blüht",
|
||||
"Kirsche blüht",
|
||||
"Apfel blüht",
|
||||
"Holunder blüht",
|
||||
"Linde blüht",
|
||||
"Tracht vorbei",
|
||||
"Efeu blüht",
|
||||
"Winterruhe",
|
||||
]);
|
||||
});
|
||||
|
||||
it("names the three sources", () => {
|
||||
expect(PHASE_SOURCE_LABELS).toEqual({
|
||||
REPORT: "gemeldet",
|
||||
GTS: "geschätzt",
|
||||
CALENDAR: "Kalender",
|
||||
});
|
||||
});
|
||||
|
||||
it("explains each weak basis in words", () => {
|
||||
expect(SEASON_NOTE_LABELS).toEqual({
|
||||
NO_COORDINATES: "Keine Koordinaten",
|
||||
WEATHER_GAPS: "Wetterdaten lückenhaft",
|
||||
WEATHER_STALE: "Wetterdaten veraltet",
|
||||
});
|
||||
});
|
||||
|
||||
it("adds the report day only for reported phases", () => {
|
||||
expect(phaseSourceText("REPORT", "2026-04-18")).toBe("gemeldet am 18.04.");
|
||||
expect(phaseSourceText("REPORT", null)).toBe("gemeldet");
|
||||
expect(phaseSourceText("GTS", "2026-04-18")).toBe("geschätzt");
|
||||
expect(phaseSourceText("CALENDAR", null)).toBe("Kalender");
|
||||
expect(phaseSourceText(null, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { formatDayMonth } from "@/lib/autopilot/format";
|
||||
import type { PhaseSource, PhenologyPhase, SeasonNote } from "./types";
|
||||
|
||||
export const PHASE_REPORT_LABELS: Record<PhenologyPhase, string> = {
|
||||
SALWEIDE: "Salweide blüht",
|
||||
KIRSCHE: "Kirsche blüht",
|
||||
APFEL: "Apfel blüht",
|
||||
HOLUNDER: "Holunder blüht",
|
||||
LINDE: "Linde blüht",
|
||||
TRACHTENDE: "Tracht vorbei",
|
||||
HERBST: "Efeu blüht",
|
||||
WINTER: "Winterruhe",
|
||||
};
|
||||
|
||||
export const PHASE_SOURCE_LABELS: Record<PhaseSource, string> = {
|
||||
REPORT: "gemeldet",
|
||||
GTS: "geschätzt",
|
||||
CALENDAR: "Kalender",
|
||||
};
|
||||
|
||||
/** Why the estimate is weak, when it is. */
|
||||
export const SEASON_NOTE_LABELS: Record<NonNullable<SeasonNote>, string> = {
|
||||
NO_COORDINATES: "Keine Koordinaten",
|
||||
WEATHER_GAPS: "Wetterdaten lückenhaft",
|
||||
WEATHER_STALE: "Wetterdaten veraltet",
|
||||
};
|
||||
|
||||
/** „gemeldet am 18.04.“, „geschätzt“, „Kalender“; null without a known source. */
|
||||
export function phaseSourceText(
|
||||
source: PhaseSource | null,
|
||||
reportedOn: string | null,
|
||||
): string | null {
|
||||
if (!source) return null;
|
||||
const label = PHASE_SOURCE_LABELS[source];
|
||||
return source === "REPORT" && reportedOn ? `${label} am ${formatDayMonth(reportedOn)}` : label;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { SeasonSegment } from "@/lib/autopilot/types";
|
||||
|
||||
// Mirrors ApiCore-Backend/src/phenology (phenology.service.ts, dto/) and
|
||||
// src/season/season-types.ts - keep both in step.
|
||||
|
||||
export const PHENOLOGY_PHASES = [
|
||||
"SALWEIDE",
|
||||
"KIRSCHE",
|
||||
"APFEL",
|
||||
"HOLUNDER",
|
||||
"LINDE",
|
||||
"TRACHTENDE",
|
||||
"HERBST",
|
||||
"WINTER",
|
||||
] as const;
|
||||
export type PhenologyPhase = (typeof PHENOLOGY_PHASES)[number];
|
||||
|
||||
export type PhaseSource = "REPORT" | "GTS" | "CALENDAR";
|
||||
|
||||
export type SeasonNote = "NO_COORDINATES" | "WEATHER_GAPS" | "WEATHER_STALE" | null;
|
||||
|
||||
export interface CreatePhenologyReportInput {
|
||||
phase: PhenologyPhase;
|
||||
/** YYYY-MM-DD, not after today (Europe/Berlin). */
|
||||
date: string;
|
||||
/** null or omitted = the report counts for all apiaries. */
|
||||
apiaryId?: string | null;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
export interface PhenologyReport {
|
||||
id: string;
|
||||
apiaryId: string | null;
|
||||
/** null = the report counts for all apiaries. */
|
||||
apiaryName: string | null;
|
||||
phase: PhenologyPhase;
|
||||
/** YYYY-MM-DD */
|
||||
date: string;
|
||||
notes: string | null;
|
||||
userId: string;
|
||||
userName: string;
|
||||
revokedAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** The season state of an apiary; the API never sends coordinates. */
|
||||
export interface PhenologyState {
|
||||
apiaryId: string;
|
||||
apiaryName: string;
|
||||
phase: PhenologyPhase | null;
|
||||
segment: SeasonSegment;
|
||||
phaseSource: PhaseSource | null;
|
||||
/** YYYY-MM-DD; only set when the phase comes from a report. */
|
||||
reportedOn: string | null;
|
||||
gts: number | null;
|
||||
gtsThroughDay: string | null;
|
||||
note: SeasonNote;
|
||||
}
|
||||
Reference in new issue
Block a user