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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 22:47:17 +02:00
1 parent 16cc557462
commit fab7f7e12f
91 files changed
+8463 -104

No files matched your search

+12 -1
View File
@@ -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",
+11 -1
View File
@@ -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;
}
+105 -2
View File
@@ -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([
+21 -2
View File
@@ -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.
+3 -1
View File
@@ -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
+48
View File
@@ -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;
}