feat: platform backbone and field capture, first part (checkpoint)

Platform backbone (plan 2026-10-13-plattform-rueckgrat), reviewed tasks:
- run roles api/worker/job/all with shared startup checks (Task 1)
- schema and migration for DomainEvent, deliveries, JobRun, ThrottlerHit (Task 2)
- audit, notifications and domain events written in the same transaction (Task 3)
- dispatcher with SKIP LOCKED claims, retries and stale release (Task 4)
- worker building blocks: tick, LISTEN/NOTIFY wake-up, health (Task 5)
- worker process and entry points (Task 6)
- throttler counters in Postgres (Task 19)
- separate keys per purpose with key ring (Task 20)
- public origin page without exact location (Task 22)
- shop keys split into read and write (Task 23)
- Kustomize base, components, overlay encry, CI validation (Task 26)

Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), reviewed tasks:
- brood frames, read endpoints, apiary overview and offline pack
- network basics, offline queue v3, navigation sheets, form building blocks
- new inspection with last-time hints and weather, varroa per day,
  treatment prefill, apiary page with round
- inspection photo (Task 10) is implemented, review pending

Not rolled out. Remaining tasks follow in later commits.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-13 17:57:54 +02:00
1 parent 10cbe9bf2d
commit c01a38835a
340 files changed
+25695 -1808

No files matched your search

@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { hasCombinedShopScopes } from "./scopes";
describe("hasCombinedShopScopes", () => {
it("is true for a key that may read and book", () => {
expect(hasCombinedShopScopes({ scopes: ["shop-sync:read", "shop-sync:write"] })).toBe(true);
});
it("is false for single-scope keys", () => {
expect(hasCombinedShopScopes({ scopes: ["shop-sync:read"] })).toBe(false);
expect(hasCombinedShopScopes({ scopes: ["shop-sync:write"] })).toBe(false);
});
});
@@ -0,0 +1,16 @@
import type { ApiKey } from "./types";
export type ApiKeyScope = "shop-sync:read" | "shop-sync:write";
export const API_KEY_SCOPE_LABELS: Record<ApiKeyScope, string> = {
"shop-sync:read": "Shop-Daten lesen (Wabenhain)",
"shop-sync:write": "Verkäufe und Rückbuchungen buchen (Wabenhain)",
};
/**
* Keys issued before reading and booking were split may still carry both;
* the server no longer creates such keys, the UI nudges towards two keys.
*/
export function hasCombinedShopScopes(apiKey: Pick<ApiKey, "scopes">): boolean {
return apiKey.scopes.includes("shop-sync:read") && apiKey.scopes.includes("shop-sync:write");
}
+2 -4
View File
@@ -1,8 +1,6 @@
export type ApiKeyScope = "shop-sync:read";
import type { ApiKeyScope } from "./scopes";
export const API_KEY_SCOPE_LABELS: Record<ApiKeyScope, string> = {
"shop-sync:read": "Shop-Daten lesen (Wabenhain)",
};
export type { ApiKeyScope };
export interface ApiKey {
id: string;
@@ -0,0 +1,11 @@
import { apiFetch } from "@/lib/auth/api-client";
import type { ColonyOverview } from "./field-types";
export function fetchColonyOverview(apiaryId: string): Promise<ColonyOverview> {
return apiFetch<ColonyOverview>(`/apiaries/${encodeURIComponent(apiaryId)}/colony-overview`);
}
/** Query key of the apiary page's colony overview. */
export function colonyOverviewKey(apiaryId: string): readonly ["apiaries", string, "colony-overview"] {
return ["apiaries", apiaryId, "colony-overview"] as const;
}
@@ -0,0 +1,83 @@
import type { ApiaryStatus } from "./types";
import type { Colony, ColonyCondition, ColonyStatus } from "@/lib/colonies/types";
import type { Inspection } from "@/lib/inspections/types";
import type { Queen } from "@/lib/queens/types";
import type { Task } from "@/lib/tasks/types";
// Mirrors ApiCore-Backend/src/apiaries/apiary-field.types.ts - keep both in step.
/** One living colony on the apiary page, reduced to what the traffic light needs. */
export interface ColonyOverviewRow {
id: string;
code: string;
status: ColonyStatus;
strength: number | null;
broodStatus: ColonyCondition | null;
feedStatus: ColonyCondition | null;
healthStatus: ColonyCondition | null;
/** ISO timestamp; always null when the caller may not read inspections. */
lastInspectionDate: string | null;
/** Unread WARNUNG notifications visible to the caller. */
openWarnings: number;
/** Unread KRITISCH notifications visible to the caller. */
openCritical: number;
}
export interface ColonyOverview {
apiaryId: string;
inspectionsVisible: boolean;
rows: ColonyOverviewRow[];
}
/**
* Everything the offline apiary mode needs. It deliberately carries no
* coordinates, address or apiary notes (apiary locations are theft-sensitive)
* and no inspection userId. Sections the caller has no read permission for
* are null.
*/
export interface OfflinePackV1 {
version: 1;
generatedAt: string;
apiary: {
id: string;
name: string;
location: string | null;
status: ApiaryStatus;
};
overview: ColonyOverview;
colonies: Pick<
Colony,
| "id"
| "code"
| "apiaryId"
| "status"
| "colonyType"
| "hiveType"
| "strength"
| "broodStatus"
| "feedStatus"
| "healthStatus"
| "currentQueenId"
| "notes"
>[];
queens:
| Pick<
Queen,
"id" | "code" | "hatchYear" | "breed" | "markingColor" | "markingNumber" | "status"
>[]
| null;
inspections: Omit<Inspection, "userId">[] | null;
tasks:
| Pick<
Task,
| "id"
| "title"
| "description"
| "dueDate"
| "priority"
| "status"
| "relatedType"
| "relatedId"
>[]
| null;
}
@@ -10,6 +10,8 @@ export interface Apiary {
address: string | null;
status: ApiaryStatus;
notes: string | null;
/** Shown publicly on the origin page instead of the exact location. */
publicLocationLabel: string | null;
createdAt: string;
updatedAt: string;
deactivatedAt: string | null;
@@ -24,6 +26,7 @@ export interface ApiaryInput {
longitude?: number | null;
address?: string | null;
notes?: string | null;
publicLocationLabel?: string | null;
}
/** `null` clears an optional field, an omitted one stays unchanged. */
@@ -35,4 +38,5 @@ export interface UpdateApiaryInput {
longitude?: number | null;
address?: string | null;
notes?: string | null;
publicLocationLabel?: string | null;
}
@@ -0,0 +1,13 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { colonyOverviewKey, fetchColonyOverview } from "./field-api";
/** The apiary's living colonies with their traffic-light inputs. */
export function useColonyOverview(apiaryId: string, enabled = true) {
return useQuery({
queryKey: colonyOverviewKey(apiaryId),
queryFn: () => fetchColonyOverview(apiaryId),
enabled,
});
}
@@ -0,0 +1,104 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getConnectivity, reportRequestOutcome } from "@/lib/offline/connectivity";
import {
DEFAULT_TIMEOUT_MS,
DOWNLOAD_TIMEOUT_MS,
NON_IDEMPOTENT_WRITE_TIMEOUT_MS,
UPLOAD_TIMEOUT_MS,
} from "@/lib/http/fetch-with-timeout";
import { expectTimeoutAt, stubHangingFetch } from "@/lib/http/deadline-test-helper";
import { setAccessToken } from "./token-store";
import { ApiError, apiFetch, apiFetchBlob, apiFetchFormData } from "./api-client";
vi.mock("../config/runtime-config", () => ({
getApiUrl: vi.fn().mockResolvedValue("http://api.test"),
}));
function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
describe("api-client", () => {
beforeEach(() => {
setAccessToken(null);
reportRequestOutcome("reached-server");
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("ApiError carries the parsed JSON body", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(jsonResponse(400, { message: "Ungültig", invalidColonyIds: ["c1"] })),
);
const error = await apiFetch("/x").catch((e: unknown) => e);
expect(error).toBeInstanceOf(ApiError);
const apiError = error as ApiError;
expect(apiError.status).toBe(400);
expect(apiError.message).toBe("Ungültig");
expect((apiError.body as { invalidColonyIds: string[] }).invalidColonyIds).toEqual(["c1"]);
});
it("reports network-error on a failed fetch and reached-server on any HTTP answer", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch")));
await expect(apiFetch("/x")).rejects.toBeInstanceOf(TypeError);
expect(getConnectivity()).toBe("offline");
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(500, { message: "boom" })));
await expect(apiFetch("/x")).rejects.toBeInstanceOf(ApiError);
expect(getConnectivity()).toBe("online");
});
describe("timeouts", () => {
beforeEach(() => {
vi.useFakeTimers();
stubHangingFetch();
});
afterEach(() => {
vi.useRealTimers();
});
it("apiFetch: GET and keyed writes 4 s, unkeyed writes 15 s", async () => {
await expectTimeoutAt(() => apiFetch("/x"), DEFAULT_TIMEOUT_MS);
await expectTimeoutAt(
() => apiFetch("/x", { method: "POST", headers: { "Idempotency-Key": "k" } }),
DEFAULT_TIMEOUT_MS,
);
await expectTimeoutAt(() => apiFetch("/x", { method: "POST" }), NON_IDEMPOTENT_WRITE_TIMEOUT_MS);
});
it("apiFetchFormData uses 30 s by default and honours timeoutMs", async () => {
await expectTimeoutAt(() => apiFetchFormData("/x", new FormData()), UPLOAD_TIMEOUT_MS);
await expectTimeoutAt(
() => apiFetchFormData("/x", new FormData(), { timeoutMs: 5_000 }),
5_000,
);
});
it("apiFetchFormData passes options.headers through", async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(200, {}));
vi.stubGlobal("fetch", fetchMock);
await apiFetchFormData("/x", new FormData(), { headers: { "Idempotency-Key": "k9" } });
const init = fetchMock.mock.calls[0][1] as RequestInit;
expect(new Headers(init.headers).get("Idempotency-Key")).toBe("k9");
});
it("apiFetchBlob uses 60 s", async () => {
await expectTimeoutAt(() => apiFetchBlob("/x", "f.csv"), DOWNLOAD_TIMEOUT_MS);
});
});
it("apiFetchBlob builds an ApiError with the parsed body", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(403, { message: "Nein", why: 1 })));
const error = (await apiFetchBlob("/x", "f.csv").catch((e: unknown) => e)) as ApiError;
expect(error).toBeInstanceOf(ApiError);
expect(error.status).toBe(403);
expect(error.body).toEqual({ message: "Nein", why: 1 });
});
});
+57 -29
View File
@@ -1,27 +1,50 @@
import { getAccessToken, setAccessToken } from './token-store';
import { refreshRequest } from './api';
import { getApiUrl } from '../config/runtime-config';
import {
DOWNLOAD_TIMEOUT_MS,
UPLOAD_TIMEOUT_MS,
fetchReportingOutcome,
timeoutFor,
} from '../http/fetch-with-timeout';
export class ApiError extends Error {
status: number;
readonly status: number;
readonly body: unknown;
constructor(status: number, message: string) {
constructor(status: number, message: string, body?: unknown) {
super(message);
this.status = status;
this.body = body;
}
}
async function rawFetch(path: string, init: RequestInit): Promise<Response> {
async function apiErrorFrom(response: Response, fallbackMessage = 'Request failed'): Promise<ApiError> {
const body: unknown = await response.json().catch(() => ({}));
const message =
typeof body === 'object' && body !== null && 'message' in body
? String((body as { message: unknown }).message)
: fallbackMessage;
return new ApiError(response.status, message, body);
}
async function rawFetch(
path: string,
init: RequestInit,
timeoutMs?: number,
): Promise<Response> {
const token = getAccessToken();
const apiUrl = await getApiUrl();
return fetch(`${apiUrl}${path}`, {
const headers = {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...init.headers,
};
return fetchReportingOutcome(`${apiUrl}${path}`, {
...init,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...init.headers,
},
headers,
timeoutMs: timeoutMs ?? timeoutFor(init.method ?? 'GET', headers),
});
}
@@ -36,18 +59,18 @@ function filenameFromContentDisposition(header: string | null, fallback: string)
}
export async function apiFetchBlob(path: string, fallbackFilename: string): Promise<BlobDownload> {
let response = await rawFetch(path, {});
let response = await rawFetch(path, {}, DOWNLOAD_TIMEOUT_MS);
if (response.status === 401) {
const refreshed = await refreshRequest();
if (refreshed) {
setAccessToken(refreshed.accessToken);
response = await rawFetch(path, {});
response = await rawFetch(path, {}, DOWNLOAD_TIMEOUT_MS);
}
}
if (!response.ok) {
throw new ApiError(response.status, 'Export fehlgeschlagen');
throw await apiErrorFrom(response, 'Export fehlgeschlagen');
}
const blob = await response.blob();
@@ -58,37 +81,47 @@ export async function apiFetchBlob(path: string, fallbackFilename: string): Prom
return { blob, filename };
}
async function rawFetchFormData(path: string, formData: FormData): Promise<Response> {
export interface FormDataOptions {
headers?: HeadersInit;
timeoutMs?: number;
}
async function rawFetchFormData(
path: string,
formData: FormData,
options: FormDataOptions,
): Promise<Response> {
const token = getAccessToken();
const apiUrl = await getApiUrl();
return fetch(`${apiUrl}${path}`, {
return fetchReportingOutcome(`${apiUrl}${path}`, {
method: 'POST',
body: formData,
credentials: 'include',
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
timeoutMs: options.timeoutMs ?? UPLOAD_TIMEOUT_MS,
});
}
export async function apiFetchFormData<T>(path: string, formData: FormData): Promise<T> {
let response = await rawFetchFormData(path, formData);
export async function apiFetchFormData<T>(
path: string,
formData: FormData,
options: FormDataOptions = {},
): Promise<T> {
let response = await rawFetchFormData(path, formData, options);
if (response.status === 401) {
const refreshed = await refreshRequest();
if (refreshed) {
setAccessToken(refreshed.accessToken);
response = await rawFetchFormData(path, formData);
response = await rawFetchFormData(path, formData, options);
}
}
if (!response.ok) {
const body: unknown = await response.json().catch(() => ({}));
const message =
typeof body === 'object' && body !== null && 'message' in body
? String((body as { message: unknown }).message)
: 'Request failed';
throw new ApiError(response.status, message);
throw await apiErrorFrom(response);
}
return response.json() as Promise<T>;
@@ -106,12 +139,7 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
}
if (!response.ok) {
const body: unknown = await response.json().catch(() => ({}));
const message =
typeof body === 'object' && body !== null && 'message' in body
? String((body as { message: unknown }).message)
: 'Request failed';
throw new ApiError(response.status, message);
throw await apiErrorFrom(response);
}
if (response.status === 204) {
+44
View File
@@ -0,0 +1,44 @@
import { afterEach, beforeEach, describe, it, vi } from "vitest";
import {
DEFAULT_TIMEOUT_MS,
NON_IDEMPOTENT_WRITE_TIMEOUT_MS,
} from "@/lib/http/fetch-with-timeout";
import { expectTimeoutAt, stubHangingFetch } from "@/lib/http/deadline-test-helper";
import {
getCurrentUserRequest,
loginRequest,
logoutRequest,
refreshRequest,
ssoCompleteRequest,
verifyMfaRequest,
} from "./api";
vi.mock("../config/runtime-config", () => ({
getApiUrl: vi.fn().mockResolvedValue("http://api.test"),
}));
describe("auth api timeouts", () => {
beforeEach(() => {
vi.useFakeTimers();
stubHangingFetch();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it.each([
["login", () => loginRequest("[email protected]", "pw")],
["MFA verify", () => verifyMfaRequest("t", "123456")],
["refresh", () => refreshRequest()],
["SSO complete", () => ssoCompleteRequest()],
["logout", () => logoutRequest()],
])("%s (one-shot / rotating) uses the long write timeout", async (_name, call) => {
await expectTimeoutAt(call, NON_IDEMPOTENT_WRITE_TIMEOUT_MS);
});
it("plain reads use the default timeout", async () => {
await expectTimeoutAt(() => getCurrentUserRequest("tok"), DEFAULT_TIMEOUT_MS);
});
});
+25 -7
View File
@@ -1,4 +1,9 @@
import { getApiUrl } from '../config/runtime-config';
import {
DEFAULT_TIMEOUT_MS,
NON_IDEMPOTENT_WRITE_TIMEOUT_MS,
fetchReportingOutcome,
} from '../http/fetch-with-timeout';
export interface AccessTokenResponse {
accessToken: string;
@@ -17,7 +22,8 @@ export function isMfaRequired(response: LoginResponse): response is MfaRequiredR
export async function loginRequest(email: string, password: string): Promise<LoginResponse> {
const apiUrl = await getApiUrl();
const response = await fetch(`${apiUrl}/auth/login`, {
const response = await fetchReportingOutcome(`${apiUrl}/auth/login`, {
timeoutMs: NON_IDEMPOTENT_WRITE_TIMEOUT_MS,
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
@@ -31,7 +37,8 @@ export async function loginRequest(email: string, password: string): Promise<Log
export async function verifyMfaRequest(mfaToken: string, code: string): Promise<AccessTokenResponse> {
const apiUrl = await getApiUrl();
const response = await fetch(`${apiUrl}/auth/mfa/verify`, {
const response = await fetchReportingOutcome(`${apiUrl}/auth/mfa/verify`, {
timeoutMs: NON_IDEMPOTENT_WRITE_TIMEOUT_MS,
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
@@ -43,6 +50,10 @@ export async function verifyMfaRequest(mfaToken: string, code: string): Promise<
return response.json() as Promise<AccessTokenResponse>;
}
// Login, MFA, refresh, SSO complete and logout are one-shot / rotating: a client
// abort after the server processed them would burn a TOTP code or lose the
// rotated refresh cookie, so they get the long write timeout.
// The refresh token is single-use and rotates on every call. If two callers
// (e.g. React StrictMode's dev double-invoke, or two components hitting a 401
// at once) both call refresh at the same time, the second one always fails
@@ -53,7 +64,8 @@ let inFlightRefresh: Promise<AccessTokenResponse | null> | null = null;
async function performRefresh(): Promise<AccessTokenResponse | null> {
const apiUrl = await getApiUrl();
const response = await fetch(`${apiUrl}/auth/refresh`, {
const response = await fetchReportingOutcome(`${apiUrl}/auth/refresh`, {
timeoutMs: NON_IDEMPOTENT_WRITE_TIMEOUT_MS,
method: 'POST',
credentials: 'include',
});
@@ -75,7 +87,7 @@ export function refreshRequest(): Promise<AccessTokenResponse | null> {
export async function ssoStatusRequest(): Promise<boolean> {
const apiUrl = await getApiUrl();
try {
const response = await fetch(`${apiUrl}/auth/sso/status`);
const response = await fetchReportingOutcome(`${apiUrl}/auth/sso/status`, { timeoutMs: DEFAULT_TIMEOUT_MS });
if (!response.ok) {
return false;
}
@@ -98,7 +110,8 @@ export async function ssoLoginUrl(): Promise<string> {
// same challenge as a password login before tokens are issued.
export async function ssoCompleteRequest(): Promise<LoginResponse> {
const apiUrl = await getApiUrl();
const response = await fetch(`${apiUrl}/auth/sso/complete`, {
const response = await fetchReportingOutcome(`${apiUrl}/auth/sso/complete`, {
timeoutMs: NON_IDEMPOTENT_WRITE_TIMEOUT_MS,
method: 'POST',
credentials: 'include',
});
@@ -110,7 +123,11 @@ export async function ssoCompleteRequest(): Promise<LoginResponse> {
export async function logoutRequest(): Promise<void> {
const apiUrl = await getApiUrl();
await fetch(`${apiUrl}/auth/logout`, { method: 'POST', credentials: 'include' });
await fetchReportingOutcome(`${apiUrl}/auth/logout`, {
method: 'POST',
credentials: 'include',
timeoutMs: NON_IDEMPOTENT_WRITE_TIMEOUT_MS,
});
}
export interface CurrentUserProfile {
@@ -126,7 +143,8 @@ export interface CurrentUserProfile {
export async function getCurrentUserRequest(accessToken: string): Promise<CurrentUserProfile> {
const apiUrl = await getApiUrl();
const response = await fetch(`${apiUrl}/auth/me`, {
const response = await fetchReportingOutcome(`${apiUrl}/auth/me`, {
timeoutMs: DEFAULT_TIMEOUT_MS,
credentials: 'include',
headers: { Authorization: `Bearer ${accessToken}` },
});
@@ -0,0 +1,9 @@
import { describe, expect, it } from "vitest";
import { toCalendarDay } from "./dates";
describe("toCalendarDay", () => {
it("cuts the day off an ISO timestamp without shifting it", () => {
expect(toCalendarDay("2026-10-01T23:30:00.000Z")).toBe("2026-10-01");
expect(toCalendarDay("2026-10-01")).toBe("2026-10-01");
});
});
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import type { ColonyOverviewRow } from "@/lib/apiaries/field-types";
import { colonyStatusLight } from "./status-light";
const TODAY = "2026-10-13";
function row(overrides: Partial<ColonyOverviewRow> = {}): ColonyOverviewRow {
return {
id: "c-1",
code: "V-1",
status: "ACTIVE",
strength: 6,
broodStatus: "GOOD",
feedStatus: "GOOD",
healthStatus: "GOOD",
lastInspectionDate: "2026-10-10T00:00:00.000Z",
openWarnings: 0,
openCritical: 0,
...overrides,
};
}
const visible = { today: TODAY, inspectionsVisible: true };
describe("colonyStatusLight", () => {
it.each([
["a healthy, recently inspected colony", row(), "green", []],
["a queenless colony", row({ status: "QUEENLESS" }), "red", ["Weisellos"]],
["critical health", row({ healthStatus: "CRITICAL" }), "red", ["Gesundheit kritisch"]],
["an open critical notification", row({ openCritical: 1 }), "red", ["1 kritische Meldung"]],
["several critical notifications", row({ openCritical: 3 }), "red", ["3 kritische Meldungen"]],
["a weak colony", row({ status: "WEAK" }), "yellow", ["Volk schwach"]],
["a conspicuous colony", row({ status: "CONSPICUOUS" }), "yellow", ["Volk auffällig"]],
["weak feed", row({ feedStatus: "WEAK" }), "yellow", ["Futter schwach"]],
["15 days without inspection", row({ lastInspectionDate: "2026-09-28T00:00:00.000Z" }), "yellow", ["15 Tage ohne Durchsicht"]],
["14 days without inspection", row({ lastInspectionDate: "2026-09-29T00:00:00.000Z" }), "green", []],
["never inspected", row({ lastInspectionDate: null }), "yellow", ["Noch keine Durchsicht"]],
["one warning", row({ openWarnings: 1 }), "yellow", ["1 Warnung"]],
["two warnings", row({ openWarnings: 2 }), "yellow", ["2 Warnungen"]],
] as const)("%s", (_name, input, light, reasons) => {
expect(colonyStatusLight(input, visible)).toEqual({ light, reasons });
});
it("ignores the inspection age when the caller may not read inspections", () => {
const result = colonyStatusLight(row({ lastInspectionDate: null }), {
today: TODAY,
inspectionsVisible: false,
});
expect(result).toEqual({ light: "green", reasons: [] });
});
it("lets the worst light win and lists critical reasons first", () => {
const result = colonyStatusLight(
row({ status: "WEAK", openWarnings: 2, healthStatus: "CRITICAL" }),
visible,
);
expect(result.light).toBe("red");
expect(result.reasons).toEqual(["Gesundheit kritisch", "Volk schwach", "2 Warnungen"]);
});
});
@@ -0,0 +1,86 @@
import type { ColonyOverviewRow } from "@/lib/apiaries/field-types";
import { daysSince } from "@/lib/inspections/takeover";
export type StatusLight = "green" | "yellow" | "red";
export const INSPECTION_STALE_DAYS = 14;
export interface StatusLightResult {
light: StatusLight;
reasons: string[];
}
interface Finding {
light: Exclude<StatusLight, "green">;
reason: string;
}
const CONDITION_REASONS = {
healthStatus: { CRITICAL: "Gesundheit kritisch", WEAK: "Gesundheit schwach" },
feedStatus: { CRITICAL: "Futter kritisch", WEAK: "Futter schwach" },
broodStatus: { CRITICAL: "Brut kritisch", WEAK: "Brut schwach" },
} as const;
function plural(count: number, one: string, many: string): string {
return `${count} ${count === 1 ? one : many}`;
}
function conditionFindings(row: ColonyOverviewRow): Finding[] {
const findings: Finding[] = [];
for (const field of ["healthStatus", "feedStatus", "broodStatus"] as const) {
const condition = row[field];
if (condition === "CRITICAL") {
findings.push({ light: "red", reason: CONDITION_REASONS[field].CRITICAL });
} else if (condition === "WEAK") {
findings.push({ light: "yellow", reason: CONDITION_REASONS[field].WEAK });
}
}
return findings;
}
function inspectionFinding(row: ColonyOverviewRow, today: string): Finding | null {
if (row.lastInspectionDate === null) {
return { light: "yellow", reason: "Noch keine Durchsicht" };
}
const days = daysSince(row.lastInspectionDate, today);
if (days > INSPECTION_STALE_DAYS) {
return { light: "yellow", reason: plural(days, "Tag ohne Durchsicht", "Tage ohne Durchsicht") };
}
return null;
}
/**
* The traffic light of one living colony: the worst finding wins. Reasons are
* plain text (the badge also shows icon and word, never colour alone), critical
* ones first. The inspection age counts only when the caller may read
* inspections - otherwise "never inspected" would be a false alarm.
*/
export function colonyStatusLight(
row: ColonyOverviewRow,
options: { today: string; inspectionsVisible: boolean },
): StatusLightResult {
const findings: Finding[] = [];
if (row.status === "QUEENLESS") findings.push({ light: "red", reason: "Weisellos" });
if (row.status === "WEAK") findings.push({ light: "yellow", reason: "Volk schwach" });
if (row.status === "CONSPICUOUS") findings.push({ light: "yellow", reason: "Volk auffällig" });
findings.push(...conditionFindings(row));
if (options.inspectionsVisible) {
const stale = inspectionFinding(row, options.today);
if (stale) findings.push(stale);
}
if (row.openCritical > 0) {
findings.push({
light: "red",
reason: plural(row.openCritical, "kritische Meldung", "kritische Meldungen"),
});
}
if (row.openWarnings > 0) {
findings.push({ light: "yellow", reason: plural(row.openWarnings, "Warnung", "Warnungen") });
}
const ordered = [
...findings.filter((finding) => finding.light === "red"),
...findings.filter((finding) => finding.light === "yellow"),
];
return { light: ordered[0]?.light ?? "green", reasons: ordered.map((finding) => finding.reason) };
}
@@ -0,0 +1,20 @@
import { afterEach, beforeEach, describe, it, vi } from "vitest";
import { DEFAULT_TIMEOUT_MS } from "@/lib/http/fetch-with-timeout";
import { expectTimeoutAt, stubHangingFetch } from "@/lib/http/deadline-test-helper";
import { getApiUrl } from "./runtime-config";
describe("runtime-config", () => {
beforeEach(() => {
vi.useFakeTimers();
stubHangingFetch();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("loads the runtime config with the default timeout", async () => {
await expectTimeoutAt(() => getApiUrl(), DEFAULT_TIMEOUT_MS);
});
});
@@ -1,9 +1,10 @@
import { DEFAULT_TIMEOUT_MS, fetchReportingOutcome } from '../http/fetch-with-timeout';
import type { RuntimeConfig } from '@/app/api/runtime-config/route';
let cachedApiUrl: Promise<string> | null = null;
async function fetchApiUrl(): Promise<string> {
const response = await fetch('/api/runtime-config');
const response = await fetchReportingOutcome('/api/runtime-config', { timeoutMs: DEFAULT_TIMEOUT_MS });
if (!response.ok) {
throw new Error('Runtime-Konfiguration konnte nicht geladen werden.');
}
+7
View File
@@ -35,3 +35,10 @@ export function toApiDate(calendarDay: string): string {
}
return iso;
}
const CALENDAR_DAY_LENGTH = 10;
/** The "YYYY-MM-DD" part of an ISO timestamp as written, without any time zone shift. */
export function toCalendarDay(isoTimestamp: string): string {
return isoTimestamp.slice(0, CALENDAR_DAY_LENGTH);
}
+21 -3
View File
@@ -1,11 +1,17 @@
import { apiFetch, apiFetchBlob, apiFetchFormData } from "@/lib/auth/api-client";
import { downloadBlob } from "@/lib/export/api";
import { CreateOptions, idempotencyHeaders } from "@/lib/offline/idempotency";
import {
DocumentEntityType,
DocumentSummary,
UpdateDocumentInput,
} from "./types";
export interface UploadDocumentOptions extends CreateOptions {
/** Stored name; a File keeps its own name when this is left out. */
fileName?: string;
}
export function fetchDocuments(
entityType: DocumentEntityType,
entityId: string,
@@ -14,16 +20,28 @@ export function fetchDocuments(
return apiFetch<DocumentSummary[]>(`/documents?${params.toString()}`);
}
/**
* Uploads one file. With an idempotency key (a photo sent from the offline
* queue) a repeated upload after a lost answer returns the first document
* instead of storing the photo twice.
*/
export function uploadDocument(
entityType: DocumentEntityType,
entityId: string,
file: File,
file: Blob,
options: UploadDocumentOptions = {},
): Promise<DocumentSummary> {
const formData = new FormData();
formData.append("file", file);
if (options.fileName === undefined) {
formData.append("file", file);
} else {
formData.append("file", file, options.fileName);
}
formData.append("entityType", entityType);
formData.append("entityId", entityId);
return apiFetchFormData<DocumentSummary>("/documents", formData);
return apiFetchFormData<DocumentSummary>("/documents", formData, {
headers: idempotencyHeaders(options),
});
}
export function updateDocument(
@@ -0,0 +1,5 @@
/**
* The largest file the server accepts for a document - keep equal to
* MAX_FILE_SIZE_BYTES in the backend's documents.controller.ts.
*/
export const MAX_DOCUMENT_UPLOAD_BYTES = 10 * 1024 * 1024;
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { captureBackTarget } from "./back-target";
const FALLBACK = { href: "/dashboard", label: "Zur Übersicht" };
describe("captureBackTarget", () => {
it("goes back to the colony the form was opened from", () => {
expect(captureBackTarget(new URLSearchParams("colonyId=c 1"), FALLBACK)).toEqual({
href: "/colonies/c%201",
label: "Zum Volk",
});
});
it("goes back to the apiary of a round", () => {
expect(captureBackTarget(new URLSearchParams("round=a1"), FALLBACK)).toEqual({
href: "/apiaries/a1",
label: "Zum Stand",
});
});
it("falls back without a known origin", () => {
expect(captureBackTarget(new URLSearchParams(""), FALLBACK)).toEqual(FALLBACK);
});
});
@@ -0,0 +1,25 @@
export interface BackTarget {
href: string;
label: string;
}
/**
* Where „Zurück" leads from a form that can be opened from several places
* (colony page, apiary round): the place named in the URL, else the fallback.
* The id is encoded, so a crafted query value can only ever stay one path
* segment - never an arbitrary path or an external address.
*/
export function captureBackTarget(
params: URLSearchParams,
fallback: BackTarget,
): BackTarget {
const colonyId = params.get("colonyId");
if (colonyId) {
return { href: `/colonies/${encodeURIComponent(colonyId)}`, label: "Zum Volk" };
}
const round = params.get("round");
if (round) {
return { href: `/apiaries/${encodeURIComponent(round)}`, label: "Zum Stand" };
}
return fallback;
}
@@ -0,0 +1,56 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const MIN_CONTRAST = 4.5;
const TONES = ["critical", "weak", "average", "good", "excellent"] as const;
const css = readFileSync(join(process.cwd(), "src/app/globals.css"), "utf8");
function block(selector: string): string {
const start = css.indexOf(`${selector} {`);
return css.slice(start, css.indexOf("\n}", start));
}
function oklch(block: string, token: string): [number, number, number] {
const match = new RegExp(`--${token}:\\s*oklch\\(([\\d.]+) ([\\d.]+) ([\\d.]+)\\)`).exec(block);
if (!match) throw new Error(`token ${token} not found`);
return [Number(match[1]), Number(match[2]), Number(match[3])];
}
/** oklch -> linear sRGB (Ottosson), clipped to gamut. */
function linearRgb([l, c, h]: [number, number, number]): number[] {
const a = c * Math.cos((h * Math.PI) / 180);
const b = c * Math.sin((h * Math.PI) / 180);
const l_ = (l + 0.3963377774 * a + 0.2158037573 * b) ** 3;
const m_ = (l - 0.1055613458 * a - 0.0638541728 * b) ** 3;
const s_ = (l - 0.0894841775 * a - 1.291485548 * b) ** 3;
return [
4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_,
-1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_,
-0.0041960863 * l_ - 0.7034186147 * m_ + 1.707614701 * s_,
].map((channel) => Math.min(1, Math.max(0, channel)));
}
function luminance(color: [number, number, number]): number {
const [r, g, b] = linearRgb(color);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function contrast(a: [number, number, number], b: [number, number, number]): number {
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
}
describe.each([
["light", ":root"],
["dark", ".dark"],
])("condition tones in %s mode", (_mode, selector) => {
it.each(TONES)(`%s: text on tone is at least ${MIN_CONTRAST}:1`, (tone) => {
const scope = block(selector);
const ratio = contrast(
oklch(scope, `condition-${tone}`),
oklch(scope, `condition-${tone}-foreground`),
);
expect(ratio).toBeGreaterThanOrEqual(MIN_CONTRAST);
});
});
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { RATING_TONE_CLASS, toDisplayRating, toStoredRating } from "./rating";
describe("rating direction", () => {
it.each([1, 2, 3, 4, 5])("keeps %i for higher-is-better", (value) => {
expect(toDisplayRating(value, "higher-is-better")).toBe(value);
expect(toStoredRating(value, "higher-is-better")).toBe(value);
});
it.each([
[1, 5],
[2, 4],
[3, 3],
[4, 2],
[5, 1],
])("mirrors stored %i to display %i for lower-is-better and back", (stored, display) => {
expect(toDisplayRating(stored, "lower-is-better")).toBe(display);
expect(toStoredRating(display, "lower-is-better")).toBe(stored);
});
it("passes null through in both directions", () => {
expect(toDisplayRating(null, "lower-is-better")).toBeNull();
expect(toStoredRating(null, "lower-is-better")).toBeNull();
expect(toDisplayRating(null, "higher-is-better")).toBeNull();
});
it("has one tone per display value", () => {
expect(Object.keys(RATING_TONE_CLASS)).toEqual(["1", "2", "3", "4", "5"]);
expect(RATING_TONE_CLASS[1]).toContain("condition-critical");
expect(RATING_TONE_CLASS[5]).toContain("condition-excellent");
});
});
+35
View File
@@ -0,0 +1,35 @@
/**
* Which end of a 1-5 scale is the good one. Stored values never change; only
* the order on screen does, so that left is always the bad end and right the
* good one (e.g. swarm mood: stored 5 = "stark" is shown as 1, on the left).
*/
export type RatingDirection = "higher-is-better" | "lower-is-better";
const SCALE_SUM = 6;
function mirror(value: number | null, direction: RatingDirection): number | null {
if (value === null) return null;
return direction === "lower-is-better" ? SCALE_SUM - value : value;
}
/** Stored value -> position on screen (1 = left = bad end). */
export function toDisplayRating(stored: number | null, direction: RatingDirection): number | null {
return mirror(stored, direction);
}
/** Tapped position on screen -> value to store. The mirror is its own inverse. */
export function toStoredRating(display: number | null, direction: RatingDirection): number | null {
return mirror(display, direction);
}
/**
* Tone of a chosen step by its display value, so the same colour means the
* same thing on every scale. Each carries its own foreground for contrast.
*/
export const RATING_TONE_CLASS: Record<1 | 2 | 3 | 4 | 5, string> = {
1: "border-condition-critical bg-condition-critical text-condition-critical-foreground",
2: "border-condition-weak bg-condition-weak text-condition-weak-foreground",
3: "border-condition-average bg-condition-average text-condition-average-foreground",
4: "border-condition-good bg-condition-good text-condition-good-foreground",
5: "border-condition-excellent bg-condition-excellent text-condition-excellent-foreground",
};
@@ -0,0 +1,41 @@
import { beforeEach, describe, expect, it } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { DRAFT_PREFIX } from "./draft-storage";
import { useFormDraft } from "./use-form-draft";
const KEY = `${DRAFT_PREFIX}anonymous:test-form`;
describe("useFormDraft", () => {
beforeEach(() => {
window.localStorage.clear();
});
it("flush writes immediately", () => {
const { result } = renderHook(() => useFormDraft("test-form", { a: "x" }, true));
expect(window.localStorage.getItem(KEY)).toBeNull();
act(() => result.current.flush());
expect(JSON.parse(window.localStorage.getItem(KEY)!).values).toEqual({ a: "x" });
});
it("flush writes nothing while the form is untouched", () => {
const { result } = renderHook(() => useFormDraft("test-form", { a: "" }, false));
act(() => result.current.flush());
expect(window.localStorage.getItem(KEY)).toBeNull();
});
it("flush keeps an offered earlier draft instead of overwriting it", () => {
window.localStorage.setItem(
KEY,
JSON.stringify({ savedAt: Date.now(), values: { a: "old" } }),
);
const { result } = renderHook(() => useFormDraft("test-form", { a: "new" }, true));
act(() => result.current.flush());
expect(JSON.parse(window.localStorage.getItem(KEY)!).values).toEqual({ a: "old" });
});
});
@@ -20,6 +20,8 @@ export interface FormDraft<T> {
discard: () => void;
/** Call after a successful save. */
clear: () => void;
/** Writes the current entry now (no debounce) - when leaving the form. */
flush: () => void;
}
function read<T>(key: string): StoredDraft<T> | null {
@@ -37,6 +39,15 @@ function read<T>(key: string): StoredDraft<T> | null {
}
}
function write<T>(key: string, values: T): void {
try {
const draft: StoredDraft<T> = { savedAt: Date.now(), values };
window.localStorage.setItem(PREFIX + key, JSON.stringify(draft));
} catch {
// Storage full or blocked - the form still works, just without a draft.
}
}
function remove(key: string): void {
try {
window.localStorage.removeItem(PREFIX + key);
@@ -72,17 +83,23 @@ export function useFormDraft<T>(formKey: string, values: T, dirty: boolean): For
useEffect(() => {
if (!dirty || pending) return;
const timer = window.setTimeout(() => {
try {
const draft: StoredDraft<T> = { savedAt: Date.now(), values };
window.localStorage.setItem(PREFIX + key, JSON.stringify(draft));
} catch {
// Storage full or blocked - the form still works, just without a draft.
}
}, SAVE_DELAY_MS);
const timer = window.setTimeout(() => write(key, values), SAVE_DELAY_MS);
return () => window.clearTimeout(timer);
}, [key, values, dirty, pending]);
// The latest state for `flush`, which must stay a stable callback.
const latest = useRef({ key, values, dirty, pending });
useEffect(() => {
latest.current = { key, values, dirty, pending };
});
// An offered earlier draft is not overwritten: it is still the user's choice.
const flush = useCallback(() => {
const current = latest.current;
if (!current.dirty || current.pending) return;
write(current.key, current.values);
}, []);
const restore = useCallback(() => {
const values = pending?.values ?? null;
setPending(null);
@@ -96,5 +113,5 @@ export function useFormDraft<T>(formKey: string, values: T, dirty: boolean): For
const clear = useCallback(() => remove(key), [key]);
return { pending, restore, discard, clear };
return { pending, restore, discard, clear, flush };
}
@@ -0,0 +1,27 @@
import { expect, vi } from "vitest";
// Test helper (fake timers must be active): a fetch that never answers but
// honours the abort signal, like a real stalled connection.
export function stubHangingFetch(): ReturnType<typeof vi.fn> {
const fetchMock = vi.fn(
(_input: RequestInfo | URL, init?: RequestInit) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => reject(init.signal?.reason));
}),
);
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
// Asserts the request is still pending at ms - 1 and times out at ms.
export async function expectTimeoutAt(start: () => Promise<unknown>, ms: number): Promise<void> {
const pending = start();
let settled = false;
pending.catch(() => {
settled = true;
});
await vi.advanceTimersByTimeAsync(ms - 1);
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await expect(pending).rejects.toMatchObject({ name: "TimeoutError" });
}
@@ -0,0 +1,108 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getConnectivity, reportRequestOutcome } from "@/lib/offline/connectivity";
import { ApiError } from "@/lib/auth/api-client";
import {
DEFAULT_TIMEOUT_MS,
NON_IDEMPOTENT_WRITE_TIMEOUT_MS,
fetchReportingOutcome,
fetchWithTimeout,
isNetworkError,
timeoutFor,
} from "./fetch-with-timeout";
function hangingFetch(_input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
return new Promise((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => reject(init.signal?.reason));
});
}
describe("fetch-with-timeout", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("GET and keyed writes get 4 s, other writes 15 s", () => {
expect(timeoutFor("GET", undefined)).toBe(DEFAULT_TIMEOUT_MS);
expect(timeoutFor("POST", { "Idempotency-Key": "k1" })).toBe(DEFAULT_TIMEOUT_MS);
expect(timeoutFor("POST", new Headers({ "idempotency-key": "k1" }))).toBe(DEFAULT_TIMEOUT_MS);
expect(timeoutFor("POST", undefined)).toBe(NON_IDEMPOTENT_WRITE_TIMEOUT_MS);
expect(timeoutFor("PATCH", { "Content-Type": "application/json" })).toBe(
NON_IDEMPOTENT_WRITE_TIMEOUT_MS,
);
});
it("rejects with TimeoutError after the timeout", async () => {
vi.stubGlobal("fetch", vi.fn(hangingFetch));
const pending = fetchWithTimeout("/x", { timeoutMs: 4_000 });
const assertion = expect(pending).rejects.toMatchObject({ name: "TimeoutError" });
await vi.advanceTimersByTimeAsync(4_000);
await assertion;
});
it("forwards an abort of the caller signal", async () => {
vi.stubGlobal("fetch", vi.fn(hangingFetch));
const caller = new AbortController();
const pending = fetchWithTimeout("/x", { timeoutMs: 4_000, signal: caller.signal });
const assertion = expect(pending).rejects.toMatchObject({ name: "AbortError" });
caller.abort(new DOMException("weg", "AbortError"));
await assertion;
});
it("a timeout is a network error, an ApiError is not", () => {
expect(isNetworkError(new DOMException("t", "TimeoutError"))).toBe(true);
expect(isNetworkError(new DOMException("a", "AbortError"))).toBe(true);
expect(isNetworkError(new TypeError("Failed to fetch"))).toBe(true);
expect(isNetworkError(new ApiError(500, "boom"))).toBe(false);
expect(isNetworkError(new Error("other"))).toBe(false);
});
function stalledBodyResponse(): Response {
return new Response(new ReadableStream({ start() {} }), { status: 200 });
}
it("a stalled body ends in TimeoutError and reports network-error", async () => {
reportRequestOutcome("reached-server");
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(stalledBodyResponse()));
const pending = fetchReportingOutcome("/x", { timeoutMs: 4_000 });
const assertion = expect(pending).rejects.toMatchObject({ name: "TimeoutError" });
await vi.advanceTimersByTimeAsync(4_000);
await assertion;
expect(getConnectivity()).toBe("offline");
reportRequestOutcome("reached-server");
});
it("returns a readable response once headers and body arrived in time", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response('{"a":1}', { status: 400 })));
const response = await fetchWithTimeout("/x", { timeoutMs: 4_000 });
expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({ a: 1 });
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 204 })));
expect((await fetchWithTimeout("/x", { timeoutMs: 4_000 })).status).toBe(204);
});
it("caller cancellation still works while the body is being read", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(stalledBodyResponse()));
const caller = new AbortController();
const pending = fetchWithTimeout("/x", { timeoutMs: 4_000, signal: caller.signal });
const assertion = expect(pending).rejects.toMatchObject({ name: "AbortError" });
await vi.advanceTimersByTimeAsync(10);
caller.abort(new DOMException("weg", "AbortError"));
await assertion;
});
it("a caller-initiated abort does not mark connectivity offline", async () => {
reportRequestOutcome("reached-server");
vi.stubGlobal("fetch", vi.fn(hangingFetch));
const caller = new AbortController();
const pending = fetchReportingOutcome("/x", { timeoutMs: 4_000, signal: caller.signal });
const assertion = expect(pending).rejects.toMatchObject({ name: "AbortError" });
caller.abort(new DOMException("weg", "AbortError"));
await assertion;
expect(getConnectivity()).toBe("online");
});
});
@@ -0,0 +1,99 @@
import { reportRequestOutcome } from "../offline/connectivity";
export const DEFAULT_TIMEOUT_MS = 4_000; // GET and writes with Idempotency-Key
export const NON_IDEMPOTENT_WRITE_TIMEOUT_MS = 15_000;
export const UPLOAD_TIMEOUT_MS = 30_000;
export const DOWNLOAD_TIMEOUT_MS = 60_000;
export function timeoutFor(method: string, headers: HeadersInit | undefined): number {
if (method.toUpperCase() === "GET" || new Headers(headers).has("Idempotency-Key")) {
return DEFAULT_TIMEOUT_MS;
}
return NON_IDEMPOTENT_WRITE_TIMEOUT_MS;
}
// Statuses that must not carry a body when a Response is rebuilt.
const NULL_BODY_STATUSES = new Set([101, 204, 205, 304]);
// Rejects as soon as the signal aborts, so a stalled stream (which ignores the
// abort on its own) still ends in the abort reason.
function rejectOnAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
return new Promise<T>((resolve, reject) => {
const onAbort = () => reject(signal.reason);
if (signal.aborted) {
onAbort();
return;
}
signal.addEventListener("abort", onAbort, { once: true });
promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
});
}
// The timeout is a TOTAL deadline: it also covers receiving the body. The body
// is buffered inside the deadline and handed back as a fresh Response, so every
// caller can keep using json()/text()/blob() without re-arming anything.
export async function fetchWithTimeout(
input: RequestInfo | URL,
init: RequestInit & { timeoutMs: number },
): Promise<Response> {
const { timeoutMs, signal: callerSignal, ...rest } = init;
const controller = new AbortController();
const timer = setTimeout(
() => controller.abort(new DOMException("Zeitüberschreitung der Anfrage", "TimeoutError")),
timeoutMs,
);
const forwardAbort = () => controller.abort(callerSignal?.reason);
if (callerSignal?.aborted) {
forwardAbort();
} else {
callerSignal?.addEventListener("abort", forwardAbort, { once: true });
}
try {
const response = await rejectOnAbort(
fetch(input, { ...rest, signal: controller.signal }),
controller.signal,
);
const body = NULL_BODY_STATUSES.has(response.status)
? null
: await rejectOnAbort(response.arrayBuffer(), controller.signal);
return new Response(body, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
} finally {
clearTimeout(timer);
callerSignal?.removeEventListener("abort", forwardAbort);
}
}
// TypeError = fetch failed at network level; TimeoutError / AbortError = the
// request was cut off. Never an ApiError (that means the server answered).
export function isNetworkError(error: unknown): boolean {
if (error instanceof TypeError) {
return true;
}
return (
error instanceof DOMException && (error.name === "TimeoutError" || error.name === "AbortError")
);
}
// fetchWithTimeout plus the connectivity signal: ANY HTTP answer (including
// 502/503/504 from a proxy) counts as "reached-server"; a timeout or network
// TypeError counts as "network-error". A caller-initiated AbortError
// (unmount/cancel) says nothing about the network and is not reported.
export async function fetchReportingOutcome(
input: RequestInfo | URL,
init: RequestInit & { timeoutMs: number },
): Promise<Response> {
try {
const response = await fetchWithTimeout(input, init);
reportRequestOutcome("reached-server");
return response;
} catch (error) {
if (isNetworkError(error) && !(error instanceof DOMException && error.name === "AbortError")) {
reportRequestOutcome("network-error");
}
throw error;
}
}
@@ -0,0 +1,170 @@
import { describe, expect, it } from "vitest";
import {
TAKEOVER_FIELDS,
daysSince,
previousLabel,
previousValueOf,
takeOverAll,
undoTakeOver,
} from "./takeover";
import type { Inspection } from "./types";
import { initialInspectionValues } from "@/app/(app)/inspections/new/inspection-form-values";
function last(overrides: Partial<Inspection> = {}): Inspection {
return {
id: "i-1",
colonyId: "colony-1",
userId: "u-1",
date: "2026-10-01T00:00:00.000Z",
weather: null,
temperature: null,
colonyStrength: 8,
broodAmount: null,
broodFrames: 5,
broodPattern: 3,
openBroodPresent: true,
cappedBroodPresent: null,
eggsPresent: true,
queenSeen: true,
queenCellsPresent: false,
swarmMood: 4,
gentleness: 2,
combBehavior: null,
feedStatus: "ausreichend",
honeySuperStatus: null,
healthIssues: null,
varroaIssues: null,
measures: null,
notes: null,
createdAt: "2026-10-01T00:00:00.000Z",
...overrides,
};
}
const blank = () => initialInspectionValues("colony-1");
describe("takeOverAll", () => {
it("takes over only empty fields", () => {
const { values, taken } = takeOverAll(blank(), last());
expect(values).toMatchObject({
queenSeen: true,
eggsPresent: true,
openBroodPresent: true,
queenCellsPresent: false,
broodPattern: 3,
broodFrames: "5",
colonyStrength: "8",
swarmMood: 4,
gentleness: 2,
feedStatus: "ausreichend",
});
expect(taken).toContain("queenSeen");
expect(taken).toContain("broodFrames");
});
it("never overwrites a typed value", () => {
const typed = { ...blank(), queenSeen: false, broodFrames: "2", feedStatus: "kritisch" };
const { values, taken } = takeOverAll(typed, last());
expect(values.queenSeen).toBe(false);
expect(values.broodFrames).toBe("2");
expect(values.feedStatus).toBe("kritisch");
expect(taken).not.toContain("queenSeen");
expect(taken).not.toContain("broodFrames");
expect(taken).not.toContain("feedStatus");
});
it("skips fields that were null last time", () => {
const { values, taken } = takeOverAll(blank(), last());
expect(values.cappedBroodPresent).toBeNull();
expect(values.combBehavior).toBeNull();
expect(values.honeySuperStatus).toBe("");
expect(taken).not.toContain("cappedBroodPresent");
expect(taken).not.toContain("combBehavior");
expect(taken).not.toContain("honeySuperStatus");
});
it("keeps a legacy free-text feedStatus verbatim", () => {
const { values } = takeOverAll(blank(), last({ feedStatus: "noch 2 kg Futterteig" }));
expect(values.feedStatus).toBe("noch 2 kg Futterteig");
});
it("does not change the values it was given", () => {
const original = blank();
takeOverAll(original, last());
expect(original.queenSeen).toBeNull();
});
});
describe("previousValueOf / previousLabel", () => {
it("returns null for a field that was empty last time", () => {
expect(previousValueOf(last(), "combBehavior")).toBeNull();
expect(previousValueOf(last({ honeySuperStatus: " " }), "honeySuperStatus")).toBeNull();
});
it("returns counts as strings and answers as they are", () => {
expect(previousValueOf(last(), "broodFrames")).toBe("5");
expect(previousValueOf(last(), "queenSeen")).toBe(true);
expect(previousValueOf(last(), "queenCellsPresent")).toBe(false);
expect(previousValueOf(last(), "gentleness")).toBe(2);
});
it("words the values for the hint", () => {
expect(previousLabel("queenSeen", true)).toBe("Ja");
expect(previousLabel("queenSeen", false)).toBe("Nein");
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");
});
it("words swarm mood as the buttons show it (mirrored)", () => {
expect(previousLabel("swarmMood", 1)).toBe("5 von 5");
expect(previousLabel("swarmMood", 5)).toBe("1 von 5");
});
});
describe("TAKEOVER_FIELDS", () => {
it("lists the thirteen observation fields", () => {
expect(TAKEOVER_FIELDS).toHaveLength(13);
});
});
describe("daysSince", () => {
it("counts whole calendar days", () => {
expect(daysSince("2026-10-01T00:00:00.000Z", "2026-10-13")).toBe(12);
expect(daysSince("2026-10-13T00:00:00.000Z", "2026-10-13")).toBe(0);
});
});
describe("undoTakeOver", () => {
it("restores taken fields but keeps what was changed since", () => {
const before = blank();
const { values: after, taken } = takeOverAll(before, last());
const edited = { ...after, queenSeen: false };
const undone = undoTakeOver(edited, before, after, taken);
expect(undone.queenSeen).toBe(false);
expect(undone.eggsPresent).toBeNull();
expect(undone.broodFrames).toBe("");
});
});
describe("fields absent from the payload", () => {
it("treats omitted values like null", () => {
const sparse = { ...last(), broodFrames: undefined, colonyStrength: undefined, gentleness: undefined } as unknown as Inspection;
expect(previousValueOf(sparse, "broodFrames")).toBeNull();
expect(previousValueOf(sparse, "colonyStrength")).toBeNull();
expect(previousValueOf(sparse, "gentleness")).toBeNull();
expect(takeOverAll(blank(), sparse).values.broodFrames).toBe("");
});
});
@@ -0,0 +1,144 @@
import {
FEED_STATUS_OPTIONS,
type InspectionFormValues,
} from "@/app/(app)/inspections/new/inspection-form-values";
import { toCalendarDay } from "@/lib/dates";
import { toDisplayRating } from "@/lib/forms/rating";
import type { Inspection } from "./types";
/** The observations "Letztes Mal" offers for one-tap take-over, in form order. */
export const TAKEOVER_FIELDS = [
"queenSeen",
"eggsPresent",
"openBroodPresent",
"cappedBroodPresent",
"queenCellsPresent",
"broodPattern",
"broodFrames",
"colonyStrength",
"gentleness",
"combBehavior",
"swarmMood",
"feedStatus",
"honeySuperStatus",
] as const;
export type TakeoverField = (typeof TAKEOVER_FIELDS)[number];
type TakeoverValue = InspectionFormValues[TakeoverField];
const MS_PER_DAY = 86_400_000;
/** Counts are held as text in the form. */
function countText(count: number | null): string | null {
return count == null ? null : String(count);
}
function textOrNull(text: string | null): string | null {
return text == null || text.trim() === "" ? null : text;
}
/**
* What the last inspection recorded for a field, in the form's own shape;
* `null` when it was not recorded then (nothing to offer or take over).
*/
export function previousValueOf(last: Inspection, field: TakeoverField): TakeoverValue | null {
switch (field) {
case "broodFrames":
return countText(last.broodFrames);
case "colonyStrength":
return countText(last.colonyStrength);
case "feedStatus":
case "honeySuperStatus":
return textOrNull(last[field]);
default:
return last[field] ?? null;
}
}
function isEmptyValue(value: TakeoverValue): boolean {
return value === null || value === "";
}
function wabenLabel(count: string): string {
return count === "1" ? "1 Wabe" : `${count} Waben`;
}
/**
* The earlier value in words for the hint. Ratings are worded as the
* buttons show them: the mirrored swarm mood reads "5 von 5" for a stored 1.
*/
export function previousLabel(
field: TakeoverField,
value: NonNullable<TakeoverValue>,
): string {
switch (field) {
case "broodPattern":
case "gentleness":
case "combBehavior":
return `${value} von 5`;
case "swarmMood":
return `${toDisplayRating(Number(value), "lower-is-better")} von 5`;
case "broodFrames":
case "colonyStrength":
return wabenLabel(String(value));
case "feedStatus":
return FEED_STATUS_OPTIONS.find((option) => option.value === value)?.label ?? String(value);
case "honeySuperStatus":
return String(value);
default:
return value ? "Ja" : "Nein";
}
}
function withField(
values: InspectionFormValues,
field: TakeoverField,
value: TakeoverValue,
): InspectionFormValues {
return { ...values, [field]: value };
}
/**
* "Alles wie letztes Mal": fills only fields that are still empty with what
* the last inspection recorded. Typed values stay, and a field that was not
* recorded last time stays empty. `taken` names what changed, for the undo.
*/
export function takeOverAll(
values: InspectionFormValues,
last: Inspection,
): { values: InspectionFormValues; taken: TakeoverField[] } {
let next = values;
const taken: TakeoverField[] = [];
for (const field of TAKEOVER_FIELDS) {
const previous = previousValueOf(last, field);
if (previous === null || !isEmptyValue(values[field])) continue;
next = withField(next, field, previous);
taken.push(field);
}
return { values: next, taken };
}
/** Whole calendar days from an inspection's date to `todayIso` ("YYYY-MM-DD"). */
export function daysSince(inspectionDate: string, todayIso: string): number {
const from = Date.parse(`${toCalendarDay(inspectionDate)}T00:00:00Z`);
const to = Date.parse(`${todayIso}T00:00:00Z`);
return Math.round((to - from) / MS_PER_DAY);
}
/**
* Undo of `takeOverAll`: puts the taken fields back to what they were, but
* only where the beekeeper has not changed them since - later edits stay.
*/
export function undoTakeOver(
current: InspectionFormValues,
before: InspectionFormValues,
after: InspectionFormValues,
taken: readonly TakeoverField[],
): InspectionFormValues {
return taken.reduce(
(values, field) =>
values[field] === after[field] ? withField(values, field, before[field]) : values,
current,
);
}
@@ -7,6 +7,7 @@ export interface Inspection {
temperature: number | null;
colonyStrength: number | null;
broodAmount: string | null;
broodFrames: number | null;
broodPattern: number | null;
openBroodPresent: boolean | null;
cappedBroodPresent: boolean | null;
@@ -32,6 +33,7 @@ export interface CreateInspectionInput {
temperature?: number | null;
colonyStrength?: number | null;
broodAmount?: string | null;
broodFrames?: number | null;
broodPattern?: number | null;
openBroodPresent?: boolean | null;
cappedBroodPresent?: boolean | null;
@@ -58,6 +60,7 @@ export interface UpdateInspectionInput {
temperature?: number | null;
colonyStrength?: number | null;
broodAmount?: string | null;
broodFrames?: number | null;
broodPattern?: number | null;
openBroodPresent?: boolean | null;
cappedBroodPresent?: boolean | null;
@@ -0,0 +1,32 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { fetchColonyInspections } from "./api";
import type { Inspection } from "./types";
function latestOf(inspections: readonly Inspection[]): Inspection | null {
return (
[...inspections].sort(
(a, b) => b.date.localeCompare(a.date) || b.createdAt.localeCompare(a.createdAt),
)[0] ?? null
);
}
/**
* The colony's most recent inspection, for the "Letztes Mal" hints. Shares
* the colony page's inspections query, so it is usually already cached.
*/
export function useLastInspection(colonyId: string): {
inspection: Inspection | null;
isLoading: boolean;
} {
const query = useQuery({
queryKey: ["colonies", colonyId, "inspections"],
queryFn: () => fetchColonyInspections(colonyId),
enabled: colonyId !== "",
});
return {
inspection: query.data ? latestOf(query.data) : null,
isLoading: query.isLoading,
};
}
@@ -0,0 +1,48 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { getConnectivity, reportRequestOutcome, subscribeConnectivity } from "./connectivity";
function setOnLine(value: boolean) {
vi.spyOn(window.navigator, "onLine", "get").mockReturnValue(value);
}
describe("connectivity", () => {
afterEach(() => {
reportRequestOutcome("reached-server");
vi.restoreAllMocks();
});
it("goes offline after a network error and online after the next server answer", () => {
setOnLine(true);
expect(getConnectivity()).toBe("online");
const listener = vi.fn();
const unsubscribe = subscribeConnectivity(listener);
reportRequestOutcome("network-error");
expect(getConnectivity()).toBe("offline");
expect(listener).toHaveBeenCalledTimes(1);
reportRequestOutcome("reached-server");
expect(getConnectivity()).toBe("online");
expect(listener).toHaveBeenCalledTimes(2);
unsubscribe();
});
it("is offline while navigator.onLine is false", () => {
setOnLine(false);
expect(getConnectivity()).toBe("offline");
reportRequestOutcome("reached-server");
expect(getConnectivity()).toBe("offline");
});
it("notifies on browser online/offline events", () => {
setOnLine(true);
const listener = vi.fn();
const unsubscribe = subscribeConnectivity(listener);
window.dispatchEvent(new Event("offline"));
expect(listener).toHaveBeenCalledTimes(1);
unsubscribe();
window.dispatchEvent(new Event("online"));
expect(listener).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,42 @@
import { useSyncExternalStore } from "react";
export type Connectivity = "online" | "offline";
export type RequestOutcome = "reached-server" | "network-error";
// Offline = the browser says so, or the last request failed at network level
// and no request has reached the server since. navigator.onLine alone is
// unreliable (it stays true on a captive portal or a dead uplink).
let lastOutcome: RequestOutcome = "reached-server";
const listeners = new Set<() => void>();
function notify(): void {
listeners.forEach((listener) => listener());
}
export function reportRequestOutcome(outcome: RequestOutcome): void {
if (outcome === lastOutcome) {
return;
}
lastOutcome = outcome;
notify();
}
export function getConnectivity(): Connectivity {
const browserOffline = typeof navigator !== "undefined" && navigator.onLine === false;
return browserOffline || lastOutcome === "network-error" ? "offline" : "online";
}
export function subscribeConnectivity(listener: () => void): () => void {
listeners.add(listener);
window.addEventListener("online", listener);
window.addEventListener("offline", listener);
return () => {
listeners.delete(listener);
window.removeEventListener("online", listener);
window.removeEventListener("offline", listener);
};
}
export function useConnectivity(): Connectivity {
return useSyncExternalStore(subscribeConnectivity, getConnectivity, () => "online");
}
+229
View File
@@ -0,0 +1,229 @@
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import { openDB } from "idb";
import { beforeEach, describe, expect, it, vi } from "vitest";
type OfflineDbModule = typeof import("./db");
const DB_NAME = "apicore-offline";
const VERSION_2_STORES = [
"pending-inspections",
"pending-treatments",
"pending-feedings",
"pending-varroa-measurements",
"pending-swarm-control-checks",
"pending-harvests",
];
const INSPECTION = { colonyId: "colony-1", date: "2026-10-01T00:00:00.000Z" };
const TREATMENT = { colonyId: "colony-1", date: "2026-10-01T00:00:00.000Z", product: "Oxuvar" };
const FEEDING = {
colonyId: "colony-1",
date: "2026-10-01T00:00:00.000Z",
feedType: "FEED_SYRUP" as const,
amount: 2,
unit: "kg",
};
const REJECTION = {
status: 400,
message: "Volk V-03 ist beendet.",
at: "2026-10-13T09:00:00.000Z",
};
function photo(id: string, inspectionIdempotencyKey: string, ownerUserId = "user-1") {
return {
id,
sessionId: "session-1",
ownerUserId,
blob: new Blob(["jpeg"]),
fileName: `${id}.jpg`,
createdAt: "2026-10-13T08:00:00.000Z",
state: "waiting-for-inspection",
inspectionIdempotencyKey,
uploadIdempotencyKey: `upload-${id}`,
};
}
/** A fresh module, so nothing is cached from the previous test's database. */
async function loadDb(): Promise<OfflineDbModule> {
vi.resetModules();
return import("./db");
}
/** The queue exactly as the previous release (DB version 2) left it on a phone. */
async function createVersion2Queue(entry: object): Promise<void> {
const db = await openDB(DB_NAME, 2, {
upgrade(database) {
for (const store of VERSION_2_STORES) {
database.createObjectStore(store, { keyPath: "id", autoIncrement: true });
}
},
});
await db.add("pending-inspections", entry);
db.close();
}
describe("offline queue database", () => {
beforeEach(() => {
globalThis.indexedDB = new IDBFactory();
});
it("upgrading v2 → v3 keeps already queued inspections", async () => {
await createVersion2Queue({
input: INSPECTION,
idempotencyKey: "key-from-v2",
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-v2",
queuedAt: "2026-10-01T08:00:00.000Z",
},
]);
// Entries from before owners existed still count as the user's own.
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.objectStoreNames]).toEqual(
expect.arrayContaining([
...VERSION_2_STORES,
"pending-bulk",
"pending-photos",
"offline-packs",
"meta",
]),
);
expect([...upgraded.transaction("pending-photos").store.indexNames].sort()).toEqual([
"inspectionIdempotencyKey",
"ownerUserId",
"sessionId",
]);
upgraded.close();
});
it("summarizeQueue separates own, failed and foreign entries", async () => {
const db = await loadDb();
await db.queueInspection(INSPECTION, "key-1", "user-1");
await db.queueTreatment(TREATMENT, "key-2", "user-1");
await db.queueFeeding(FEEDING, "key-3", "user-2");
const [treatment] = await db.getPendingEntries("pending-treatments");
await db.markFailed("pending-treatments", treatment.id, REJECTION);
expect(await db.summarizeQueue("user-1")).toEqual({ own: 1, failed: 1, foreign: 1 });
expect(await db.summarizeQueue("user-2")).toEqual({ own: 1, failed: 0, foreign: 2 });
});
it("summarizeQueue counts a photo waiting on its own like an entry, never a draft or one riding with its inspection", async () => {
const db = await loadDb();
await db.queueInspection(INSPECTION, "inspection-key", "user-1");
const raw = await openDB(DB_NAME);
const ready = { ...photo("ready", "inspection-key"), state: "ready", inspectionId: "srv-1" };
await raw.put("pending-photos", ready);
await raw.put("pending-photos", { ...ready, id: "rejected", lastError: REJECTION });
await raw.put("pending-photos", { ...ready, id: "foreign", ownerUserId: "user-2" });
await raw.put("pending-photos", photo("rides-along", "inspection-key"));
await raw.put("pending-photos", { ...photo("draft", "unused"), state: "draft" });
raw.close();
// The inspection itself, plus the ready photo; the rejected one separately.
expect(await db.summarizeQueue("user-1")).toEqual({ own: 2, failed: 1, foreign: 1 });
});
it("toQueueError keeps the server's status, message and body", async () => {
const db = await loadDb();
const { ApiError } = await import("@/lib/auth/api-client");
const body = { statusCode: 413, message: "Datei zu groß" };
const error = db.toQueueError(new ApiError(413, "Datei zu groß", body));
expect(error).toEqual({ status: 413, message: "Datei zu groß", details: body, at: expect.any(String) });
});
it("stores each entry with its key and owner", async () => {
const db = await loadDb();
await db.queueVarroaMeasurement(
{ colonyId: "colony-1", date: "2026-10-01T00:00:00.000Z", method: "WASHING_METHOD" as const },
"key-1",
"user-1",
);
const [entry] = await db.getPendingEntries("pending-varroa-measurements");
expect(entry).toMatchObject({ idempotencyKey: "key-1", ownerUserId: "user-1" });
expect(Date.parse(entry.queuedAt)).not.toBeNaN();
});
it("lists only the current user's entries, oldest first, with the server's message", async () => {
const db = await loadDb();
await db.queueInspection(INSPECTION, "key-1", "user-1");
await db.queueFeeding(FEEDING, "key-2", "user-2");
await db.queueTreatment(TREATMENT, "key-3", "user-1");
const [treatment] = await db.getPendingEntries("pending-treatments");
await db.markFailed("pending-treatments", treatment.id, REJECTION);
const items = await db.listQueue("user-1");
expect(items).toEqual([
expect.objectContaining({ store: "pending-inspections", label: "Durchsicht" }),
expect.objectContaining({
store: "pending-treatments",
id: treatment.id,
label: "Behandlung",
lastError: REJECTION,
}),
]);
});
it("discardQueueItem deletes exactly that entry", async () => {
const db = await loadDb();
await db.queueInspection(INSPECTION, "key-1", "user-1");
await db.queueInspection(INSPECTION, "key-2", "user-1");
const [first] = await db.listQueue("user-1");
await db.discardQueueItem(first);
const remaining = await db.getPendingEntries("pending-inspections");
expect(remaining.map((entry) => entry.idempotencyKey)).toEqual(["key-2"]);
});
it("discarding a queued inspection discards its photos and no others", async () => {
const db = await loadDb();
await db.queueInspection(INSPECTION, "inspection-key", "user-1");
const raw = await openDB(DB_NAME);
await raw.put("pending-photos", photo("p1", "inspection-key"));
await raw.put("pending-photos", photo("p2", "other-inspection-key"));
const [item] = await db.listQueue("user-1");
expect(item.photoCount).toBe(1);
await db.discardQueueItem(item);
expect(await raw.getAllKeys("pending-photos")).toEqual(["p2"]);
expect(await db.getPendingEntries("pending-inspections")).toEqual([]);
raw.close();
});
it("clearQueueOf removes one owner's entries and photos and keeps everyone else's", async () => {
const db = await loadDb();
await db.queueInspection(INSPECTION, "key-1", "user-1");
await db.queueFeeding(FEEDING, "key-2", "user-1");
await db.queueInspection(INSPECTION, "key-3", "user-2");
const raw = await openDB(DB_NAME);
await raw.put("pending-photos", photo("p1", "key-1", "user-1"));
await raw.put("pending-photos", photo("p3", "key-3", "user-2"));
await db.clearQueueOf("user-1");
expect(await db.summarizeQueue("user-1")).toEqual({ own: 0, failed: 0, foreign: 1 });
const [left] = await db.getPendingEntries("pending-inspections");
expect(left.idempotencyKey).toBe("key-3");
expect(await raw.getAllKeys("pending-photos")).toEqual(["p3"]);
raw.close();
});
});
+380 -119
View File
@@ -1,4 +1,6 @@
import { openDB, DBSchema, IDBPDatabase } from "idb";
import type { ApiError } from "@/lib/auth/api-client";
import type { OfflinePackV1 } from "@/lib/apiaries/field-types";
import { CreateInspectionInput } from "@/lib/inspections/types";
import { CreateTreatmentInput } from "@/lib/treatments/types";
import { CreateFeedingInput } from "@/lib/feedings/types";
@@ -6,49 +8,139 @@ import { CreateVarroaMeasurementInput } from "@/lib/varroa/types";
import { CreateSwarmControlCheckInput } from "@/lib/swarm-control/types";
import { CreateHarvestInput } from "@/lib/harvests/types";
type PendingStoreName =
| "pending-inspections"
| "pending-treatments"
| "pending-feedings"
| "pending-varroa-measurements"
| "pending-swarm-control-checks"
| "pending-harvests";
/** What each single-record queue (one per quick-capture form) holds. */
export interface RecordQueueInputs {
"pending-inspections": CreateInspectionInput;
"pending-treatments": CreateTreatmentInput;
"pending-feedings": CreateFeedingInput;
"pending-varroa-measurements": CreateVarroaMeasurementInput;
"pending-swarm-control-checks": CreateSwarmControlCheckInput;
"pending-harvests": CreateHarvestInput;
}
export type RecordStoreName = keyof RecordQueueInputs;
/** Every store whose entries wait to be sent and are shown in the queue sheet. */
export type QueueStoreName = RecordStoreName | "pending-bulk";
interface PendingEntry<T> {
/** The server's answer when it rejected a queued entry. */
export interface QueueError {
status: number;
message: string;
details?: unknown;
at: string;
}
export function toQueueError(error: ApiError): QueueError {
return {
status: error.status,
message: error.message,
details: error.body,
at: new Date().toISOString(),
};
}
export interface PendingEntry<T> {
id: number;
input: T;
queuedAt: string;
// Missing on entries queued before keys existed - those sync as before.
idempotencyKey?: string;
// Missing on entries queued before owners existed (DB v2) - those sync as
// before, under whoever is signed in.
ownerUserId?: string;
// Set when the server rejected the entry; it stays queued until it is sent
// or the user discards it.
lastError?: QueueError;
}
interface OfflineDB extends DBSchema {
"pending-inspections": { key: number; value: PendingEntry<CreateInspectionInput> };
"pending-treatments": { key: number; value: PendingEntry<CreateTreatmentInput> };
"pending-feedings": { key: number; value: PendingEntry<CreateFeedingInput> };
"pending-varroa-measurements": {
key: number;
value: PendingEntry<CreateVarroaMeasurementInput>;
export type BulkKind = "treatments" | "feedings" | "varroa";
export interface PendingBulkEntry {
id: number;
kind: BulkKind;
input: unknown;
idempotencyKey: string;
queuedAt: string;
ownerUserId: string;
lastError?: QueueError;
}
export interface StoredPhoto {
id: string;
sessionId: string;
ownerUserId: string;
blob: Blob;
fileName: string;
createdAt: string;
state: "draft" | "waiting-for-inspection" | "ready";
inspectionIdempotencyKey?: string;
inspectionId?: string;
uploadIdempotencyKey: string;
lastError?: QueueError;
}
export interface StoredPack {
apiaryId: string;
ownerUserId: string;
savedAt: string;
pack: OfflinePackV1;
}
type RecordStores = {
[Store in RecordStoreName]: { key: number; value: PendingEntry<RecordQueueInputs[Store]> };
};
export interface OfflineDB extends DBSchema, RecordStores {
"pending-bulk": { key: number; value: PendingBulkEntry };
"pending-photos": {
key: string;
value: StoredPhoto;
indexes: { sessionId: string; inspectionIdempotencyKey: string; ownerUserId: string };
};
"pending-swarm-control-checks": {
key: number;
value: PendingEntry<CreateSwarmControlCheckInput>;
};
"pending-harvests": { key: number; value: PendingEntry<CreateHarvestInput> };
"offline-packs": { key: string; value: StoredPack };
meta: { key: string; value: unknown };
}
const DB_NAME = "apicore-offline";
// v1 only had "pending-inspections". v2 adds the other five quick-capture
// types - existing users' already-queued inspections must survive the
// 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 = 2;
// v1 only had "pending-inspections", v2 added the other five quick-capture
// types, v3 adds bulk entries, photos, offline packs and meta. 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 RECORD_STORES: readonly RecordStoreName[] = [
"pending-inspections",
"pending-treatments",
"pending-feedings",
"pending-varroa-measurements",
"pending-swarm-control-checks",
"pending-harvests",
];
const QUEUE_STORES: readonly QueueStoreName[] = [...RECORD_STORES, "pending-bulk"];
const RECORD_LABELS: Record<RecordStoreName, string> = {
"pending-inspections": "Durchsicht",
"pending-treatments": "Behandlung",
"pending-feedings": "Fütterung",
"pending-varroa-measurements": "Varroamessung",
"pending-swarm-control-checks": "Schwarmkontrolle",
"pending-harvests": "Ernte",
};
const BULK_LABELS: Record<BulkKind, string> = {
treatments: "Behandlung für mehrere Völker",
feedings: "Fütterung für mehrere Völker",
varroa: "Varroamessung für mehrere Völker",
};
let dbPromise: Promise<IDBPDatabase<OfflineDB>> | null = null;
function getDb(): Promise<IDBPDatabase<OfflineDB>> {
/**
* The one shared connection to this device's offline database. Exported for
* the photo store (lib/photos/store.ts), which keeps its own records here.
*/
export function getDb(): Promise<IDBPDatabase<OfflineDB>> {
if (!dbPromise) {
dbPromise = openDB<OfflineDB>(DB_NAME, DB_VERSION, {
const opening = openDB<OfflineDB>(DB_NAME, DB_VERSION, {
upgrade(db, oldVersion) {
if (oldVersion < 1) {
db.createObjectStore("pending-inspections", { keyPath: "id", autoIncrement: true });
@@ -66,128 +158,297 @@ function getDb(): Promise<IDBPDatabase<OfflineDB>> {
});
db.createObjectStore("pending-harvests", { keyPath: "id", autoIncrement: true });
}
if (oldVersion < 3) {
db.createObjectStore("pending-bulk", { keyPath: "id", autoIncrement: true });
const photos = db.createObjectStore("pending-photos", { keyPath: "id" });
photos.createIndex("sessionId", "sessionId");
photos.createIndex("inspectionIdempotencyKey", "inspectionIdempotencyKey");
photos.createIndex("ownerUserId", "ownerUserId");
db.createObjectStore("offline-packs", { keyPath: "apiaryId" });
db.createObjectStore("meta");
}
},
// A newer app version in another tab wants to upgrade: step aside
// instead of blocking it. The next call here opens the new version.
blocking() {
void opening.then((db) => db.close());
forget(opening);
},
terminated() {
forget(opening);
},
});
// A failed open (storage blocked, private mode) is retried next time.
opening.catch(() => forget(opening));
dbPromise = opening;
}
return dbPromise;
}
async function queue<T>(
store: PendingStoreName,
input: T,
idempotencyKey?: string,
function forget(connection: Promise<IDBPDatabase<OfflineDB>>): void {
if (dbPromise === connection) {
dbPromise = null;
}
}
async function queue<Store extends RecordStoreName>(
store: Store,
input: RecordQueueInputs[Store],
idempotencyKey: string,
ownerUserId: string,
): Promise<void> {
const db = await getDb();
await db.add(store, { input, idempotencyKey, queuedAt: new Date().toISOString() } as never);
const entry: Omit<PendingEntry<RecordQueueInputs[Store]>, "id"> = {
input,
idempotencyKey,
ownerUserId,
queuedAt: new Date().toISOString(),
};
await db.add(store, entry as never);
}
async function getPending<T>(store: PendingStoreName): Promise<PendingEntry<T>[]> {
export function queueInspection(
input: CreateInspectionInput,
idempotencyKey: string,
ownerUserId: string,
): Promise<void> {
return queue("pending-inspections", input, idempotencyKey, ownerUserId);
}
export function queueTreatment(
input: CreateTreatmentInput,
idempotencyKey: string,
ownerUserId: string,
): Promise<void> {
return queue("pending-treatments", input, idempotencyKey, ownerUserId);
}
export function queueFeeding(
input: CreateFeedingInput,
idempotencyKey: string,
ownerUserId: string,
): Promise<void> {
return queue("pending-feedings", input, idempotencyKey, ownerUserId);
}
export function queueVarroaMeasurement(
input: CreateVarroaMeasurementInput,
idempotencyKey: string,
ownerUserId: string,
): Promise<void> {
return queue("pending-varroa-measurements", input, idempotencyKey, ownerUserId);
}
export function queueSwarmControlCheck(
input: CreateSwarmControlCheckInput,
idempotencyKey: string,
ownerUserId: string,
): Promise<void> {
return queue("pending-swarm-control-checks", input, idempotencyKey, ownerUserId);
}
export function queueHarvest(
input: CreateHarvestInput,
idempotencyKey: string,
ownerUserId: string,
): Promise<void> {
return queue("pending-harvests", input, idempotencyKey, ownerUserId);
}
/** Every entry of one record queue, oldest first, of every owner. */
export async function getPendingEntries<Store extends RecordStoreName>(
store: Store,
): Promise<PendingEntry<RecordQueueInputs[Store]>[]> {
const db = await getDb();
return db.getAll(store) as Promise<PendingEntry<T>[]>;
return (await db.getAll(store)) as PendingEntry<RecordQueueInputs[Store]>[];
}
async function removePending(store: PendingStoreName, id: number): Promise<void> {
/** One entry as it is stored right now, or undefined once it is gone. */
export async function getPendingEntry<Store extends RecordStoreName>(
store: Store,
id: number,
): Promise<PendingEntry<RecordQueueInputs[Store]> | undefined> {
const db = await getDb();
return (await db.get(store, id)) as PendingEntry<RecordQueueInputs[Store]> | undefined;
}
export async function removeQueueEntry(store: QueueStoreName, id: number): Promise<void> {
const db = await getDb();
await db.delete(store, id);
}
async function countPending(store: PendingStoreName): Promise<number> {
/** Keeps a rejected entry queued and remembers why the server refused it. */
export async function markFailed(
store: QueueStoreName,
id: number,
error: QueueError,
): Promise<void> {
const db = await getDb();
return db.count(store);
const tx = db.transaction(store, "readwrite");
const entry = await tx.store.get(id);
if (entry) {
await tx.store.put({ ...entry, lastError: error });
}
await tx.done;
}
export type PendingInspection = PendingEntry<CreateInspectionInput>;
export function queueInspection(input: CreateInspectionInput, idempotencyKey?: string): Promise<void> {
return queue("pending-inspections", input, idempotencyKey);
}
export function getPendingInspections(): Promise<PendingInspection[]> {
return getPending("pending-inspections");
}
export function removePendingInspection(id: number): Promise<void> {
return removePending("pending-inspections", id);
}
export function countPendingInspections(): Promise<number> {
return countPending("pending-inspections");
/**
* Entries from before owners existed (DB v2) count as the signed-in user's
* own. Anything queued under another account is never sent by this one.
*/
export function isForeignEntry(entry: { ownerUserId?: string }, currentUserId: string): boolean {
return entry.ownerUserId !== undefined && entry.ownerUserId !== currentUserId;
}
export type PendingTreatment = PendingEntry<CreateTreatmentInput>;
export function queueTreatment(input: CreateTreatmentInput, idempotencyKey?: string): Promise<void> {
return queue("pending-treatments", input, idempotencyKey);
}
export function getPendingTreatments(): Promise<PendingTreatment[]> {
return getPending("pending-treatments");
}
export function removePendingTreatment(id: number): Promise<void> {
return removePending("pending-treatments", id);
}
export function countPendingTreatments(): Promise<number> {
return countPending("pending-treatments");
type QueuedValue = PendingEntry<unknown> | PendingBulkEntry;
interface QueuedRecord {
store: QueueStoreName;
entry: QueuedValue;
}
export type PendingFeeding = PendingEntry<CreateFeedingInput>;
export function queueFeeding(input: CreateFeedingInput, idempotencyKey?: string): Promise<void> {
return queue("pending-feedings", input, idempotencyKey);
}
export function getPendingFeedings(): Promise<PendingFeeding[]> {
return getPending("pending-feedings");
}
export function removePendingFeeding(id: number): Promise<void> {
return removePending("pending-feedings", id);
}
export function countPendingFeedings(): Promise<number> {
return countPending("pending-feedings");
async function readQueue(): Promise<QueuedRecord[]> {
const db = await getDb();
const tx = db.transaction([...QUEUE_STORES], "readonly");
const perStore = await Promise.all(
QUEUE_STORES.map(async (store) => {
const entries: QueuedValue[] = await tx.objectStore(store).getAll();
return entries.map((entry) => ({ store, entry }));
}),
);
await tx.done;
return perStore.flat();
}
export type PendingVarroaMeasurement = PendingEntry<CreateVarroaMeasurementInput>;
export function queueVarroaMeasurement(input: CreateVarroaMeasurementInput, idempotencyKey?: string): Promise<void> {
return queue("pending-varroa-measurements", input, idempotencyKey);
}
export function getPendingVarroaMeasurements(): Promise<PendingVarroaMeasurement[]> {
return getPending("pending-varroa-measurements");
}
export function removePendingVarroaMeasurement(id: number): Promise<void> {
return removePending("pending-varroa-measurements", id);
}
export function countPendingVarroaMeasurements(): Promise<number> {
return countPending("pending-varroa-measurements");
export interface QueueSummary {
/** The user's entries still waiting to be sent. */
own: number;
/** The user's entries the server rejected. */
failed: number;
/** Entries of another account on this device - never sent from here. */
foreign: number;
}
export type PendingSwarmControlCheck = PendingEntry<CreateSwarmControlCheckInput>;
export function queueSwarmControlCheck(input: CreateSwarmControlCheckInput, idempotencyKey?: string): Promise<void> {
return queue("pending-swarm-control-checks", input, idempotencyKey);
}
export function getPendingSwarmControlChecks(): Promise<PendingSwarmControlCheck[]> {
return getPending("pending-swarm-control-checks");
}
export function removePendingSwarmControlCheck(id: number): Promise<void> {
return removePending("pending-swarm-control-checks", id);
}
export function countPendingSwarmControlChecks(): Promise<number> {
return countPending("pending-swarm-control-checks");
/**
* A photo whose inspection is on the server waits on its own and counts like
* an entry. Drafts (form not saved yet) and photos still waiting for their
* queued inspection are not counted: they ride along with that inspection.
*/
async function readPhotosWaitingOnTheirOwn(): Promise<StoredPhoto[]> {
const db = await getDb();
const photos = await db.getAll("pending-photos");
return photos.filter((photo) => photo.state === "ready");
}
export type PendingHarvest = PendingEntry<CreateHarvestInput>;
export function queueHarvest(input: CreateHarvestInput, idempotencyKey?: string): Promise<void> {
return queue("pending-harvests", input, idempotencyKey);
}
export function getPendingHarvests(): Promise<PendingHarvest[]> {
return getPending("pending-harvests");
}
export function removePendingHarvest(id: number): Promise<void> {
return removePending("pending-harvests", id);
}
export function countPendingHarvests(): Promise<number> {
return countPending("pending-harvests");
export async function summarizeQueue(currentUserId: string): Promise<QueueSummary> {
const waiting: { ownerUserId?: string; lastError?: QueueError }[] = [
...(await readQueue()).map(({ entry }) => entry),
...(await readPhotosWaitingOnTheirOwn()),
];
return waiting.reduce<QueueSummary>(
(summary, entry) => {
if (isForeignEntry(entry, currentUserId)) {
return { ...summary, foreign: summary.foreign + 1 };
}
if (entry.lastError) {
return { ...summary, failed: summary.failed + 1 };
}
return { ...summary, own: summary.own + 1 };
},
{ own: 0, failed: 0, foreign: 0 },
);
}
export async function countAllPending(): Promise<number> {
const counts = await Promise.all([
countPendingInspections(),
countPendingTreatments(),
countPendingFeedings(),
countPendingVarroaMeasurements(),
countPendingSwarmControlChecks(),
countPendingHarvests(),
]);
return counts.reduce((sum, count) => sum + count, 0);
export interface QueueItem {
store: string;
id: number | string;
label: string;
queuedAt: string;
lastError?: QueueError;
photoCount?: number;
}
function labelOf({ store, entry }: QueuedRecord): string {
if (store === "pending-bulk") {
return BULK_LABELS[(entry as PendingBulkEntry).kind];
}
return RECORD_LABELS[store];
}
/** The signed-in user's waiting and rejected entries, oldest first. */
export async function listQueue(currentUserId: string): Promise<QueueItem[]> {
const db = await getDb();
const own = (await readQueue()).filter(({ entry }) => !isForeignEntry(entry, currentUserId));
const items = await Promise.all(
own.map(async (record): Promise<QueueItem> => {
const { store, entry } = record;
const item: QueueItem = {
store,
id: entry.id,
label: labelOf(record),
queuedAt: entry.queuedAt,
...(entry.lastError ? { lastError: entry.lastError } : {}),
};
if (store !== "pending-inspections" || !entry.idempotencyKey) {
return item;
}
const photoCount = await db.countFromIndex(
"pending-photos",
"inspectionIdempotencyKey",
entry.idempotencyKey,
);
return photoCount > 0 ? { ...item, photoCount } : item;
}),
);
return items.sort((a, b) => a.queuedAt.localeCompare(b.queuedAt));
}
function asQueueStore(store: string): QueueStoreName {
const known = QUEUE_STORES.find((name) => name === store);
if (!known) {
throw new Error(`Unknown offline queue: ${store}`);
}
return known;
}
/**
* Deletes one queued entry without sending it. A queued inspection takes its
* photos with it - they must never be uploaded to another record.
*/
export async function discardQueueItem(item: QueueItem): Promise<void> {
const store = asQueueStore(item.store);
if (typeof item.id !== "number") {
throw new Error(`Invalid queue entry id: ${String(item.id)}`);
}
const db = await getDb();
const tx = db.transaction([store, "pending-photos"], "readwrite");
const entry = await tx.objectStore(store).get(item.id);
if (store === "pending-inspections" && entry?.idempotencyKey) {
const photos = tx.objectStore("pending-photos");
const photoIds = await photos
.index("inspectionIdempotencyKey")
.getAllKeys(entry.idempotencyKey);
await Promise.all(photoIds.map((photoId) => photos.delete(photoId)));
}
await tx.objectStore(store).delete(item.id);
await tx.done;
}
/** Deletes everything one account queued on this device, photos included. */
export async function clearQueueOf(ownerUserId: string): Promise<void> {
const db = await getDb();
const tx = db.transaction([...QUEUE_STORES, "pending-photos"], "readwrite");
const queues = QUEUE_STORES.map(async (store) => {
const objectStore = tx.objectStore(store);
const entries: QueuedValue[] = await objectStore.getAll();
await Promise.all(
entries
.filter((entry) => entry.ownerUserId === ownerUserId)
.map((entry) => objectStore.delete(entry.id)),
);
});
const photos = tx.objectStore("pending-photos");
const photoIds = await photos.index("ownerUserId").getAllKeys(ownerUserId);
await Promise.all([...queues, ...photoIds.map((photoId) => photos.delete(photoId))]);
await tx.done;
}
@@ -14,7 +14,7 @@ import {
toTreatmentInput,
} from "@/app/(app)/treatments/new/treatment-form-values";
import { queueFeeding, queueInspection, queueTreatment, queueVarroaMeasurement } from "./db";
import { syncPendingInspections } from "./sync";
import { syncPendingEntries } from "./sync";
/**
* Stand-in for IndexedDB that stores entries the way the browser does - as a
@@ -31,6 +31,7 @@ vi.mock("idb", () => {
stores.set(store, entries);
return id;
},
get: async (store: string, id: number) => structuredClone(stores.get(store)?.get(id)),
getAll: async (store: string) =>
[...(stores.get(store)?.values() ?? [])].map((entry) => structuredClone(entry)),
delete: async (store: string, id: number) => {
@@ -41,6 +42,12 @@ vi.mock("idb", () => {
return { openDB: vi.fn(async () => db) };
});
// The photos that follow a queued inspection are covered in lib/photos/store.spec.ts.
vi.mock("@/lib/photos/store", () => ({
resolveQueuedInspection: vi.fn(async () => undefined),
uploadReadyPhotos: vi.fn(async () => ({ synced: 0, failed: 0, skippedForeign: 0 })),
}));
vi.mock("@/lib/auth/api-client", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/auth/api-client")>()),
apiFetch: vi.fn(async () => ({ id: "saved" })),
@@ -72,13 +79,13 @@ describe("offline queue keeps 'not filled in' as null from form to server", () =
product: "Oxuvar",
});
await queueInspection(inspection, "key-inspection");
await queueVarroaMeasurement(varroa, "key-varroa");
await queueFeeding(feeding, "key-feeding");
await queueTreatment(treatment, "key-treatment");
const result = await syncPendingInspections();
await queueInspection(inspection, "key-inspection", "user-1");
await queueVarroaMeasurement(varroa, "key-varroa", "user-1");
await queueFeeding(feeding, "key-feeding", "user-1");
await queueTreatment(treatment, "key-treatment", "user-1");
const result = await syncPendingEntries("user-1");
expect(result).toEqual({ synced: 4, failed: 0 });
expect(result).toEqual({ synced: 4, failed: 0, skippedForeign: 0 });
expect(sentBody("/inspections")).toEqual(asSentOnline(inspection));
expect(sentBody("/varroa-measurements")).toEqual(asSentOnline(varroa));
expect(sentBody("/feedings")).toEqual(asSentOnline(feeding));
@@ -86,8 +93,8 @@ describe("offline queue keeps 'not filled in' as null from form to server", () =
});
it("sends an untouched inspection observation as null, never as a finding", async () => {
await queueInspection(toInspectionInput(initialInspectionValues("colony-1")), "key-2");
await syncPendingInspections();
await queueInspection(toInspectionInput(initialInspectionValues("colony-1")), "key-2", "user-1");
await syncPendingEntries("user-1");
expect(sentBody("/inspections")).toMatchObject({
queenSeen: null,
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import {
entriesRejected,
entriesSynced,
entriesWaiting,
foreignEntriesWaiting,
photosAttached,
} from "./queue-texts";
describe("queue texts", () => {
it("uses the singular for exactly one entry", () => {
expect(entriesWaiting(1)).toBe("1 Eintrag wartet");
expect(entriesRejected(1)).toBe("1 Eintrag abgelehnt");
expect(foreignEntriesWaiting(1)).toBe("1 Eintrag eines anderen Kontos wartet");
expect(entriesSynced(1)).toBe("1 Eintrag synchronisiert");
expect(photosAttached(1)).toBe("+ 1 Foto");
});
it("uses the plural for two entries", () => {
expect(entriesWaiting(2)).toBe("2 Einträge warten");
expect(entriesRejected(2)).toBe("2 Einträge abgelehnt");
expect(foreignEntriesWaiting(2)).toBe("2 Einträge eines anderen Kontos warten");
expect(entriesSynced(2)).toBe("2 Einträge synchronisiert");
expect(photosAttached(2)).toBe("+ 2 Fotos");
});
});
@@ -0,0 +1,26 @@
// German counts for the offline queue: "1 Eintrag wartet", "2 Einträge warten".
function count(n: number, one: string, many: string): string {
return n === 1 ? `1 ${one}` : `${n} ${many}`;
}
export function entriesWaiting(n: number): string {
return count(n, "Eintrag wartet", "Einträge warten");
}
export function entriesRejected(n: number): string {
return count(n, "Eintrag abgelehnt", "Einträge abgelehnt");
}
export function foreignEntriesWaiting(n: number): string {
return count(n, "Eintrag eines anderen Kontos wartet", "Einträge eines anderen Kontos warten");
}
export function entriesSynced(n: number): string {
return count(n, "Eintrag synchronisiert", "Einträge synchronisiert");
}
/** Photos that wait together with a queued inspection: "+ 2 Fotos". */
export function photosAttached(n: number): string {
return `+ ${count(n, "Foto", "Fotos")}`;
}
+389 -73
View File
@@ -1,111 +1,427 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { ApiError } from "@/lib/auth/api-client";
import { createInspection } from "@/lib/inspections/api";
import { getPendingInspections, removePendingInspection } from "./db";
import { syncPendingInspections } from "./sync";
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import { openDB } from "idb";
import { beforeEach, describe, expect, it, vi, type MockedFunction } from "vitest";
vi.mock("@/lib/inspections/api", () => ({
createInspection: vi.fn(),
vi.mock("@/lib/inspections/api", () => ({ createInspection: vi.fn() }));
vi.mock("@/lib/treatments/api", () => ({ createTreatment: vi.fn() }));
vi.mock("@/lib/documents/api", () => ({ uploadDocument: vi.fn() }));
// What the inspection drain hands over to the photos, seen at that moment.
const photoHandOver = vi.hoisted(() => ({
calls: [] as { key: string; inspectionId: string; entryStillQueued: boolean }[],
failFor: null as string | null,
// Set per test: reads the queue of the test's fresh database.
isQueued: (() => Promise.resolve(false)) as (key: string) => Promise<boolean>,
}));
vi.mock("./db", () => ({
getPendingInspections: vi.fn(),
removePendingInspection: vi.fn(),
// The other five queues are exercised by their own dedicated tests
// elsewhere; here they just need to resolve empty so runSync()'s
// Promise.all doesn't touch their (unmocked, real) create functions.
getPendingTreatments: vi.fn().mockResolvedValue([]),
removePendingTreatment: vi.fn(),
getPendingFeedings: vi.fn().mockResolvedValue([]),
removePendingFeeding: vi.fn(),
getPendingVarroaMeasurements: vi.fn().mockResolvedValue([]),
removePendingVarroaMeasurement: vi.fn(),
getPendingSwarmControlChecks: vi.fn().mockResolvedValue([]),
removePendingSwarmControlCheck: vi.fn(),
getPendingHarvests: vi.fn().mockResolvedValue([]),
removePendingHarvest: vi.fn(),
}));
const mockedCreateInspection = vi.mocked(createInspection);
const mockedGetPending = vi.mocked(getPendingInspections);
const mockedRemovePending = vi.mocked(removePendingInspection);
function pendingEntry(id: number) {
vi.mock("@/lib/photos/store", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/photos/store")>();
return {
id,
input: { colonyId: `colony-${id}`, date: "2026-01-01T00:00:00.000Z" },
queuedAt: "2026-01-01T00:00:00.000Z",
...actual,
resolveQueuedInspection: async (key: string, inspectionId: string) => {
photoHandOver.calls.push({
key,
inspectionId,
entryStillQueued: await photoHandOver.isQueued(key),
});
if (photoHandOver.failFor === key) {
throw new Error("Speicher voll");
}
return actual.resolveQueuedInspection(key, inspectionId);
},
};
});
type OfflineDbModule = typeof import("./db");
type SyncModule = typeof import("./sync");
type PhotoStoreModule = typeof import("@/lib/photos/store");
type ApiErrorClass = typeof import("@/lib/auth/api-client").ApiError;
let db: OfflineDbModule;
let sync: SyncModule;
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 uploadDocument: MockedFunction<typeof import("@/lib/documents/api").uploadDocument>;
function inspection(colony: number) {
return { colonyId: `colony-${colony}`, date: "2026-10-01T00:00:00.000Z" };
}
describe("syncPendingInspections", () => {
beforeEach(() => {
vi.clearAllMocks();
/** A request that stays open until the test answers it. */
function openRequest() {
let answer: (saved: { id: string }) => void = () => undefined;
const promise = new Promise<{ id: string }>((resolve) => {
answer = resolve;
});
return { promise: promise as never, answer };
}
/** Lets every already-runnable promise callback run. */
function settle(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
function sentColonies(): string[] {
return createInspection.mock.calls.map(([input]) => input.colonyId);
}
function takePhoto(sessionId: string) {
return photos.addDraftPhoto({
sessionId,
ownerUserId: "user-1",
blob: new Blob(["jpeg"], { type: "image/jpeg" }),
date: "2026-10-01",
});
}
describe("syncPendingEntries", () => {
beforeEach(async () => {
// A fresh database and fresh modules: nothing (no open connection, no
// in-flight run) carries over from the previous test.
globalThis.indexedDB = new IDBFactory();
vi.resetModules();
db = await import("./db");
sync = await import("./sync");
photos = await import("@/lib/photos/store");
ApiError = (await import("@/lib/auth/api-client")).ApiError;
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);
// The mocks themselves outlive resetModules - start them from scratch too.
createInspection.mockReset();
createTreatment.mockReset();
uploadDocument.mockReset();
photoHandOver.calls.length = 0;
photoHandOver.failFor = null;
photoHandOver.isQueued = async (key) =>
(await db.getPendingEntries("pending-inspections")).some(
(entry) => entry.idempotencyKey === key,
);
});
it("syncs every queued entry and removes it from the queue", async () => {
mockedGetPending.mockResolvedValue([pendingEntry(1), pendingEntry(2)]);
mockedCreateInspection.mockResolvedValue({ id: "x" } as never);
await db.queueInspection(inspection(1), "key-1", "user-1");
await db.queueInspection(inspection(2), "key-2", "user-1");
createInspection.mockResolvedValue({ id: "x" } as never);
const result = await syncPendingInspections();
const result = await sync.syncPendingEntries("user-1");
expect(result).toEqual({ synced: 2, failed: 0 });
expect(mockedRemovePending).toHaveBeenCalledWith(1);
expect(mockedRemovePending).toHaveBeenCalledWith(2);
expect(result).toEqual({ synced: 2, failed: 0, skippedForeign: 0 });
expect(await db.getPendingEntries("pending-inspections")).toEqual([]);
});
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.
mockedGetPending.mockResolvedValue([
{ ...pendingEntry(1), idempotencyKey: "key-from-the-form-0001" },
pendingEntry(2),
]);
mockedCreateInspection.mockResolvedValue({ id: "x" } as never);
await db.queueInspection(inspection(1), "key-from-the-form-0001", "user-1");
createInspection.mockResolvedValue({ id: "x" } as never);
await syncPendingInspections();
await sync.syncPendingEntries("user-1");
expect(mockedCreateInspection).toHaveBeenNthCalledWith(1, pendingEntry(1).input, {
expect(createInspection).toHaveBeenCalledWith(inspection(1), {
idempotencyKey: "key-from-the-form-0001",
});
// Entries queued before keys existed still sync, just without one.
expect(mockedCreateInspection).toHaveBeenNthCalledWith(2, pendingEntry(2).input, {
idempotencyKey: undefined,
});
});
it("keeps a rejected entry queued and continues with the rest on a server-side ApiError", async () => {
mockedGetPending.mockResolvedValue([pendingEntry(1), pendingEntry(2)]);
mockedCreateInspection
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");
await raw.add("pending-inspections", {
input: inspection(1),
queuedAt: "2026-10-01T08:00:00.000Z",
});
raw.close();
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(inspection(1), { idempotencyKey: undefined });
});
it("stores the server rejection on the entry and keeps it", async () => {
await db.queueInspection(inspection(1), "key-1", "user-1");
const body = { statusCode: 400, message: "Volk V-03 ist beendet." };
createInspection.mockRejectedValue(new ApiError(400, "Volk V-03 ist beendet.", body));
const result = await sync.syncPendingEntries("user-1");
expect(result).toEqual({ synced: 0, failed: 1, skippedForeign: 0 });
const [kept] = await db.getPendingEntries("pending-inspections");
expect(kept.idempotencyKey).toBe("key-1");
expect(kept.lastError).toEqual({
status: 400,
message: "Volk V-03 ist beendet.",
details: body,
at: expect.any(String),
});
expect(kept.lastError?.status).toBe(400);
});
it("continues with the rest of the queue after a server rejection", async () => {
await db.queueInspection(inspection(1), "key-1", "user-1");
await db.queueInspection(inspection(2), "key-2", "user-1");
createInspection
.mockRejectedValueOnce(new ApiError(400, "invalid colony"))
.mockResolvedValueOnce({ id: "x" } as never);
const result = await syncPendingInspections();
const result = await sync.syncPendingEntries("user-1");
expect(result).toEqual({ synced: 1, failed: 1 });
expect(mockedRemovePending).not.toHaveBeenCalledWith(1);
expect(mockedRemovePending).toHaveBeenCalledWith(2);
expect(result).toEqual({ synced: 1, failed: 1, skippedForeign: 0 });
const remaining = await db.getPendingEntries("pending-inspections");
expect(remaining.map((entry) => entry.idempotencyKey)).toEqual(["key-1"]);
});
it("stops immediately on a network failure, leaving the rest queued", async () => {
mockedGetPending.mockResolvedValue([pendingEntry(1), pendingEntry(2)]);
mockedCreateInspection.mockRejectedValueOnce(new TypeError("Failed to fetch"));
it("stops immediately on a network failure, leaving the rest queued untouched", async () => {
await db.queueInspection(inspection(1), "key-1", "user-1");
await db.queueInspection(inspection(2), "key-2", "user-1");
createInspection.mockRejectedValueOnce(new TypeError("Failed to fetch"));
const result = await syncPendingInspections();
const result = await sync.syncPendingEntries("user-1");
expect(result).toEqual({ synced: 0, failed: 1 });
expect(mockedCreateInspection).toHaveBeenCalledTimes(1);
expect(mockedRemovePending).not.toHaveBeenCalled();
expect(result).toEqual({ synced: 0, failed: 1, skippedForeign: 0 });
expect(createInspection).toHaveBeenCalledTimes(1);
const remaining = await db.getPendingEntries("pending-inspections");
expect(remaining).toHaveLength(2);
expect(remaining.every((entry) => entry.lastError === undefined)).toBe(true);
});
it("never sends entries queued by another user", async () => {
await db.queueInspection(inspection(1), "key-1", "user-2");
createInspection.mockResolvedValue({ id: "x" } as never);
const result = await sync.syncPendingEntries("user-1");
expect(result).toEqual({ synced: 0, failed: 0, skippedForeign: 1 });
expect(createInspection).not.toHaveBeenCalled();
expect(await db.getPendingEntries("pending-inspections")).toHaveLength(1);
});
it("shares a single in-flight run across concurrent callers instead of double-processing the queue", async () => {
mockedGetPending.mockResolvedValue([pendingEntry(1)]);
mockedCreateInspection.mockResolvedValue({ id: "x" } as never);
await db.queueInspection(inspection(1), "key-1", "user-1");
createInspection.mockResolvedValue({ id: "x" } as never);
const [first, second] = await Promise.all([syncPendingInspections(), syncPendingInspections()]);
const [first, second] = await Promise.all([
sync.syncPendingEntries("user-1"),
sync.syncPendingEntries("user-1"),
]);
expect(first).toBe(second);
expect(mockedGetPending).toHaveBeenCalledTimes(1);
expect(mockedCreateInspection).toHaveBeenCalledTimes(1);
expect(createInspection).toHaveBeenCalledTimes(1);
});
it("never sends a user's entries after cancelSync() during a running sync", async () => {
await db.queueInspection(inspection(1), "key-1", "user-1");
await db.queueInspection(inspection(2), "key-2", "user-1");
const firstRequest = openRequest();
createInspection.mockReturnValueOnce(firstRequest.promise).mockResolvedValue({ id: "x" } as never);
const run = sync.syncPendingEntries("user-1");
await vi.waitFor(() => expect(createInspection).toHaveBeenCalledTimes(1));
// Logout: stop the run before the access token changes.
const cancelled = sync.cancelSync();
firstRequest.answer({ id: "saved-1" });
await cancelled;
// The request already on its way is finished properly, nothing after it is sent.
expect(await run).toEqual({ synced: 1, failed: 0, skippedForeign: 0 });
expect(sentColonies()).toEqual(["colony-1"]);
const remaining = await db.getPendingEntries("pending-inspections");
expect(remaining.map((entry) => entry.idempotencyKey)).toEqual(["key-2"]);
});
it("starts another user's run only after the current one finished", async () => {
await db.queueInspection(inspection(1), "key-a1", "user-a");
await db.queueInspection(inspection(2), "key-b1", "user-b");
const firstRequest = openRequest();
createInspection.mockReturnValueOnce(firstRequest.promise).mockResolvedValue({ id: "x" } as never);
const runA = sync.syncPendingEntries("user-a");
await vi.waitFor(() => expect(createInspection).toHaveBeenCalledTimes(1));
const runB = sync.syncPendingEntries("user-b");
await settle();
expect(createInspection).toHaveBeenCalledTimes(1);
firstRequest.answer({ id: "saved-a1" });
expect(await runA).toEqual({ synced: 1, failed: 0, skippedForeign: 1 });
expect(await runB).toEqual({ synced: 1, failed: 0, skippedForeign: 0 });
expect(sentColonies()).toEqual(["colony-1", "colony-2"]);
});
it("after a logout and the next login, never sends the first user's entries", async () => {
await db.queueInspection(inspection(1), "key-a1", "user-a");
await db.queueInspection(inspection(2), "key-a2", "user-a");
await db.queueInspection(inspection(3), "key-b1", "user-b");
const firstRequest = openRequest();
createInspection.mockReturnValueOnce(firstRequest.promise).mockResolvedValue({ id: "x" } as never);
const runA = sync.syncPendingEntries("user-a");
await vi.waitFor(() => expect(createInspection).toHaveBeenCalledTimes(1));
// A signs out (the cancel is awaited before the token changes) ...
const cancelled = sync.cancelSync();
firstRequest.answer({ id: "saved-a1" });
await cancelled;
// ... and B signs in on the same device: B's token is set, sync opens again.
sync.resumeSync();
const runB = await sync.syncPendingEntries("user-b");
expect(await runA).toEqual({ synced: 1, failed: 0, skippedForeign: 1 });
expect(runB).toEqual({ synced: 1, failed: 0, skippedForeign: 1 });
expect(sentColonies()).toEqual(["colony-1", "colony-3"]);
const remaining = await db.getPendingEntries("pending-inspections");
expect(remaining.map((entry) => entry.idempotencyKey)).toEqual(["key-a2"]);
});
it("sends nothing between cancelSync() and resumeSync(), not even for the same user", async () => {
await db.queueInspection(inspection(1), "key-1", "user-1");
await db.queueInspection(inspection(2), "key-2", "user-1");
const firstRequest = openRequest();
createInspection.mockReturnValueOnce(firstRequest.promise).mockResolvedValue({ id: "x" } as never);
void sync.syncPendingEntries("user-1");
await vi.waitFor(() => expect(createInspection).toHaveBeenCalledTimes(1));
const cancelled = sync.cancelSync();
// E.g. an `online` event while the logout still waits for the cancel.
const late = sync.syncPendingEntries("user-1");
firstRequest.answer({ id: "saved-1" });
await cancelled;
expect(await late).toEqual({ synced: 0, failed: 0, skippedForeign: 0 });
expect(await sync.syncPendingEntries("user-1")).toEqual({
synced: 0,
failed: 0,
skippedForeign: 0,
});
expect(sentColonies()).toEqual(["colony-1"]);
sync.resumeSync();
expect(await sync.syncPendingEntries("user-1")).toEqual({
synced: 1,
failed: 0,
skippedForeign: 0,
});
expect(sentColonies()).toEqual(["colony-1", "colony-2"]);
});
it("re-checks each entry right before sending it", async () => {
await db.queueInspection(inspection(1), "key-1", "user-1");
await db.queueInspection(inspection(2), "key-2", "user-1");
await db.queueInspection(inspection(3), "key-3", "user-1");
const firstRequest = openRequest();
createInspection.mockReturnValueOnce(firstRequest.promise).mockResolvedValue({ id: "x" } as never);
const run = sync.syncPendingEntries("user-1");
await vi.waitFor(() => expect(createInspection).toHaveBeenCalledTimes(1));
// While the first request is open: the second entry is discarded in the
// queue sheet, and the third no longer belongs to this user.
const [, second, third] = await db.getPendingEntries("pending-inspections");
await db.discardQueueItem({
store: "pending-inspections",
id: second.id,
label: "Durchsicht",
queuedAt: second.queuedAt,
});
const raw = await openDB("apicore-offline");
await raw.put("pending-inspections", { ...third, ownerUserId: "user-2" });
raw.close();
firstRequest.answer({ id: "saved-1" });
expect(await run).toEqual({ synced: 1, failed: 0, skippedForeign: 1 });
expect(sentColonies()).toEqual(["colony-1"]);
});
it("uploads photos only after their inspection synced in the same run", async () => {
await db.queueInspection(inspection(1), "inspection-key", "user-1");
await db.queueTreatment(
{ colonyId: "colony-1", date: "2026-10-01T00:00:00.000Z", product: "Oxuvar" },
"treatment-key",
"user-1",
);
const photo = await takePhoto("session-1");
await photos.attachSessionToQueuedInspection("session-1", "inspection-key");
createInspection.mockResolvedValue({ id: "srv-1" } as never);
const treatmentRequest = openRequest();
createTreatment.mockReturnValueOnce(treatmentRequest.promise);
uploadDocument.mockResolvedValue({ id: "doc-1" } as never);
const run = sync.syncPendingEntries("user-1");
await vi.waitFor(() => expect(createTreatment).toHaveBeenCalledTimes(1));
await vi.waitFor(async () =>
expect(await db.getPendingEntries("pending-inspections")).toEqual([]),
);
// The inspection is on the server, another record drain still runs: no photo yet.
await settle();
expect(uploadDocument).not.toHaveBeenCalled();
treatmentRequest.answer({ id: "srv-t1" });
expect(await run).toEqual({ synced: 3, failed: 0, skippedForeign: 0 });
// The photos got the server id while the inspection was still queued.
expect(photoHandOver.calls).toEqual([
{ key: "inspection-key", inspectionId: "srv-1", entryStillQueued: true },
]);
expect(uploadDocument).toHaveBeenCalledTimes(1);
expect(uploadDocument).toHaveBeenCalledWith("INSPECTION", "srv-1", expect.anything(), {
idempotencyKey: photo.uploadIdempotencyKey,
fileName: photo.fileName,
});
expect(await photos.listSessionPhotos("session-1")).toEqual([]);
});
it("keeps an inspection whose photo hand-over failed and still finishes the run", async () => {
await db.queueInspection(inspection(1), "key-1", "user-1");
await db.queueInspection(inspection(2), "key-2", "user-1");
const photo = await takePhoto("session-1");
await photos.attachSessionToQueuedInspection("session-1", "key-1");
createInspection
.mockResolvedValueOnce({ id: "srv-1" } as never)
.mockResolvedValueOnce({ id: "srv-2" } as never);
photoHandOver.failFor = "key-1";
const first = await sync.syncPendingEntries("user-1");
expect(first).toEqual({ synced: 1, failed: 1, skippedForeign: 0 });
const [kept] = await db.getPendingEntries("pending-inspections");
expect(kept.idempotencyKey).toBe("key-1");
expect(kept.lastError).toBeUndefined();
expect(uploadDocument).not.toHaveBeenCalled();
// Next run: the same key brings back the same server record, the photo follows it.
photoHandOver.failFor = null;
createInspection.mockResolvedValue({ id: "srv-1" } as never);
uploadDocument.mockResolvedValue({ id: "doc-1" } as never);
const second = await sync.syncPendingEntries("user-1");
expect(second).toEqual({ synced: 2, failed: 0, skippedForeign: 0 });
expect(createInspection).toHaveBeenLastCalledWith(inspection(1), { idempotencyKey: "key-1" });
expect(uploadDocument).toHaveBeenCalledWith(
"INSPECTION",
"srv-1",
expect.anything(),
expect.objectContaining({ idempotencyKey: photo.uploadIdempotencyKey }),
);
expect(await db.getPendingEntries("pending-inspections")).toEqual([]);
});
it("never uploads photos after cancelSync()", async () => {
await db.queueInspection(inspection(1), "inspection-key", "user-1");
await takePhoto("session-1");
await photos.attachSessionToQueuedInspection("session-1", "inspection-key");
const request = openRequest();
createInspection.mockReturnValueOnce(request.promise);
uploadDocument.mockResolvedValue({ id: "doc-1" } as never);
const run = sync.syncPendingEntries("user-1");
await vi.waitFor(() => expect(createInspection).toHaveBeenCalledTimes(1));
const cancelled = sync.cancelSync();
request.answer({ id: "srv-1" });
await cancelled;
expect(await run).toEqual({ synced: 1, failed: 0, skippedForeign: 0 });
expect(uploadDocument).not.toHaveBeenCalled();
const [photo] = await photos.listSessionPhotos("session-1");
expect(photo).toMatchObject({ state: "ready", inspectionId: "srv-1" });
});
});
+168 -62
View File
@@ -6,94 +6,200 @@ 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 { uploadDocument } from "@/lib/documents/api";
import type { CreateInspectionInput, Inspection } from "@/lib/inspections/types";
import { resolveQueuedInspection, uploadReadyPhotos } from "@/lib/photos/store";
import {
getPendingInspections,
removePendingInspection,
getPendingTreatments,
removePendingTreatment,
getPendingFeedings,
removePendingFeeding,
getPendingVarroaMeasurements,
removePendingVarroaMeasurement,
getPendingSwarmControlChecks,
removePendingSwarmControlCheck,
getPendingHarvests,
removePendingHarvest,
PendingEntry,
RecordQueueInputs,
RecordStoreName,
getPendingEntries,
getPendingEntry,
isForeignEntry,
markFailed,
removeQueueEntry,
toQueueError,
} from "./db";
export interface SyncResult {
synced: number;
failed: number;
/** Entries of another account on this device - left alone, never sent. */
skippedForeign: number;
}
let inFlightSync: Promise<SyncResult> | null = null;
/** One sync run: whose entries it sends, and whether it was cancelled since. */
interface SyncRun {
userId: string;
isCancelled: () => boolean;
}
// Drains all six 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.
export function syncPendingInspections(): Promise<SyncResult> {
if (!inFlightSync) {
inFlightSync = runSync().finally(() => {
inFlightSync = null;
});
const NOTHING_SYNCED: SyncResult = { synced: 0, failed: 0, skippedForeign: 0 };
let inFlight: { userId: string; generation: number; run: Promise<SyncResult> } | null = null;
// Raised by cancelSync(): every run requested before stops sending.
let generation = 0;
// Closed by cancelSync() until resumeSync(): between a logout and the next
// signed-in user's token nothing is sent, whoever asks.
let isOpen = true;
/**
* Drains all six 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.
*
* Callers for the same user share one run; a run for another user starts
* once the current one is done, so two accounts never send at the same time.
* Every drain runs inside such a run, so cancelSync() always reaches it.
*/
export function syncPendingEntries(currentUserId: string): Promise<SyncResult> {
if (!isOpen) {
return Promise.resolve(NOTHING_SYNCED);
}
return inFlightSync;
if (inFlight?.userId === currentUserId && inFlight.generation === generation) {
return inFlight.run;
}
const requestedIn = generation;
const syncRun: SyncRun = {
userId: currentUserId,
isCancelled: () => generation !== requestedIn,
};
const previous = inFlight?.run ?? Promise.resolve(NOTHING_SYNCED);
const run = previous
.catch(() => NOTHING_SYNCED)
.then(() => runSync(syncRun))
.finally(() => {
if (inFlight?.run === run) {
inFlight = null;
}
});
inFlight = { userId: currentUserId, generation: requestedIn, run };
return run;
}
// Generic drain: tries each queued entry in order, removes it on success,
// stops at the first non-ApiError (real network failure, still offline) so
// the rest of the queue is left untouched for the next sync attempt. An
// ApiError (the server actively rejected it, e.g. validation) is counted as
// failed and left in the queue too, rather than silently dropping data the
// user entered - the sync badge stays visible so it's not forgotten.
async function drain<T>(
getPending: () => Promise<{ id: number; input: T; idempotencyKey?: string }[]>,
removePending: (id: number) => Promise<void>,
create: (input: T, options: CreateOptions) => Promise<unknown>,
/**
* Stops every sync run requested so far - the request already on its way is
* finished and booked, nothing after it is sent - and keeps sync closed until
* resumeSync(). Resolves once the runs have stopped: await it before the
* access token changes (logout).
*/
export async function cancelSync(): Promise<void> {
isOpen = false;
generation += 1;
await inFlight?.run.catch(() => undefined);
}
/**
* Opens sync again after cancelSync(). Call it only once the signed-in user's
* access token is set - the offline band does so when it mounts for a user.
*/
export function resumeSync(): void {
isOpen = true;
}
/**
* Tries each of the run user's queued entries in order and removes it on
* success, after `onCreated` has seen the server's record. Right before each
* request the entry is read again: one discarded meanwhile is not sent, one
* that belongs to another account is not sent, and a cancelled run stops.
*
* A real network failure (still offline) stops the drain so the rest stays
* untouched for the next attempt. An ApiError (the server actively rejected
* it, e.g. validation) is stored on the entry, which stays queued rather than
* silently dropping data the user entered - the band and the queue sheet
* show it with the server's message until it is sent or discarded.
*
* If `onCreated` fails, only that entry is affected: it stays queued and
* counts as failed, the drain goes on. The next run sends it again with the
* same idempotency key, gets the same server record back and retries
* `onCreated` - nothing that depends on it (the photos) is lost.
*
* Internal on purpose: new drains are added in runSync(), never called from
* outside, so cancelSync() always tracks and stops them.
*/
async function drain<Store extends RecordStoreName, Result>(
store: Store,
create: (input: RecordQueueInputs[Store], options: CreateOptions) => Promise<Result>,
run: SyncRun,
onCreated?: (entry: PendingEntry<RecordQueueInputs[Store]>, result: Result) => Promise<void>,
): Promise<SyncResult> {
const pending = await getPending();
const queued = await getPendingEntries(store);
const ownIds = queued.filter((entry) => !isForeignEntry(entry, run.userId)).map(({ id }) => id);
let synced = 0;
let failed = 0;
let skippedForeign = queued.length - ownIds.length;
for (const entry of pending) {
for (const id of ownIds) {
const entry = await getPendingEntry(store, id);
if (run.isCancelled()) {
break;
}
if (!entry) {
continue;
}
if (isForeignEntry(entry, run.userId)) {
skippedForeign += 1;
continue;
}
let result: Result;
try {
await create(entry.input, { idempotencyKey: entry.idempotencyKey });
await removePending(entry.id);
synced += 1;
result = await create(entry.input, { idempotencyKey: entry.idempotencyKey });
} catch (error) {
failed += 1;
if (!(error instanceof ApiError)) {
break;
}
await markFailed(store, entry.id, toQueueError(error));
continue;
}
try {
await onCreated?.(entry, result);
} catch {
failed += 1;
continue;
}
await removeQueueEntry(store, entry.id);
synced += 1;
}
return { synced, failed };
return { synced, failed, skippedForeign };
}
async function runSync(): Promise<SyncResult> {
const results = await Promise.all([
drain(getPendingInspections, removePendingInspection, createInspection),
drain(getPendingTreatments, removePendingTreatment, createTreatment),
drain(getPendingFeedings, removePendingFeeding, createFeeding),
drain(
getPendingVarroaMeasurements,
removePendingVarroaMeasurement,
createVarroaMeasurement,
),
drain(
getPendingSwarmControlChecks,
removePendingSwarmControlCheck,
createSwarmControlCheck,
),
drain(getPendingHarvests, removePendingHarvest, createHarvest),
/**
* A queued inspection is on the server now: its photos learn the server id,
* while the entry - the only place that ties them to it - is still queued.
*/
function handOverPhotos(
entry: PendingEntry<CreateInspectionInput>,
created: Inspection,
): Promise<void> {
// Entries from before keys existed were queued before photos existed too.
if (!entry.idempotencyKey) {
return Promise.resolve();
}
return resolveQueuedInspection(entry.idempotencyKey, created.id);
}
function addResults(total: SyncResult, result: SyncResult): SyncResult {
return {
synced: total.synced + result.synced,
failed: total.failed + result.failed,
skippedForeign: total.skippedForeign + result.skippedForeign,
};
}
async function runSync(run: SyncRun): Promise<SyncResult> {
const records = await Promise.all([
drain("pending-inspections", createInspection, run, handOverPhotos),
drain("pending-treatments", createTreatment, run),
drain("pending-feedings", createFeeding, run),
drain("pending-varroa-measurements", createVarroaMeasurement, run),
drain("pending-swarm-control-checks", createSwarmControlCheck, run),
drain("pending-harvests", createHarvest, run),
]);
return results.reduce(
(total, result) => ({
synced: total.synced + result.synced,
failed: total.failed + result.failed,
}),
{ synced: 0, failed: 0 },
);
// Photos last: the drains above have just given them their inspection's
// server id, and records come first on a weak connection.
const photos = await uploadReadyPhotos(run.userId, uploadDocument, run.isCancelled);
return [...records, photos].reduce(addResults, NOTHING_SYNCED);
}
@@ -0,0 +1,154 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { ApiError } from "@/lib/auth/api-client";
import { useCaptureMutation } from "./use-capture-mutation";
const signedIn = vi.hoisted(() => ({ userId: "user-1" as string | null }));
vi.mock("@/lib/auth/auth-context", () => ({
useCurrentUserId: () => signedIn.userId,
}));
interface Input {
colonyId: string;
}
interface Saved {
id: string;
colonyId: string;
}
const INPUT: Input = { colonyId: "colony-1" };
const FAILURE = "Durchsicht konnte nicht gespeichert werden.";
function setup(create: (input: Input) => Promise<Saved>) {
const queue = vi.fn().mockResolvedValue(undefined);
const onSaved = vi.fn();
// A plain client on purpose: the hook must not rely on the app's defaults.
const client = new QueryClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
const createMock = vi.fn(create);
const { result } = renderHook(
() =>
useCaptureMutation<Input, Saved>({
create: createMock,
queue,
failureMessage: FAILURE,
onSaved,
}),
{ wrapper },
);
return { result, create: createMock, queue, onSaved };
}
describe("useCaptureMutation", () => {
beforeEach(() => {
signedIn.userId = "user-1";
});
afterEach(() => {
vi.restoreAllMocks();
// TanStack's onlineManager tracks the browser events, so restore it too.
act(() => {
window.dispatchEvent(new Event("online"));
});
});
it("saves online with the form's key and reports the server's record", async () => {
const saved = { id: "i-1", colonyId: "colony-1" };
const { result, create, queue, onSaved } = setup(async () => saved);
act(() => result.current.submit(INPUT));
await waitFor(() => expect(onSaved).toHaveBeenCalledWith({ via: "online", result: saved }, INPUT));
expect(create).toHaveBeenCalledWith(INPUT, { idempotencyKey: result.current.idempotencyKey });
expect(queue).not.toHaveBeenCalled();
expect(result.current.submitError).toBeNull();
});
it("queues on a network error with the form's key and the current user", async () => {
const { result, queue, onSaved } = setup(async () => {
throw new TypeError("Failed to fetch");
});
const key = result.current.idempotencyKey;
act(() => result.current.submit(INPUT));
await waitFor(() => expect(queue).toHaveBeenCalledWith(INPUT, key, "user-1"));
await waitFor(() =>
expect(onSaved).toHaveBeenCalledWith({ via: "queued", idempotencyKey: key }, INPUT),
);
expect(result.current.submitError).toBeNull();
});
it("shows the ApiError message and does not queue", async () => {
const { result, queue, onSaved } = setup(async () => {
throw new ApiError(400, "Volk V-03 ist beendet.");
});
act(() => result.current.submit(INPUT));
await waitFor(() => expect(result.current.submitError).toBe("Volk V-03 ist beendet."));
expect(queue).not.toHaveBeenCalled();
expect(onSaved).not.toHaveBeenCalled();
});
it("queues even while navigator.onLine is false", async () => {
vi.spyOn(window.navigator, "onLine", "get").mockReturnValue(false);
act(() => {
window.dispatchEvent(new Event("offline"));
});
const { result, queue } = setup(async () => {
throw new TypeError("Failed to fetch");
});
act(() => result.current.submit(INPUT));
await waitFor(() =>
expect(queue).toHaveBeenCalledWith(INPUT, result.current.idempotencyKey, "user-1"),
);
});
it("never queues an entry without an owner and shows the failure message instead", async () => {
signedIn.userId = null;
const { result, queue, onSaved } = setup(async () => {
throw new TypeError("Failed to fetch");
});
act(() => result.current.submit(INPUT));
await waitFor(() => expect(result.current.submitError).toBe(FAILURE));
expect(queue).not.toHaveBeenCalled();
expect(onSaved).not.toHaveBeenCalled();
});
it("shows the failure message when the device cannot store the entry", async () => {
const { result, queue, onSaved } = setup(async () => {
throw new TypeError("Failed to fetch");
});
queue.mockRejectedValue(new DOMException("Quota exceeded", "QuotaExceededError"));
act(() => result.current.submit(INPUT));
await waitFor(() => expect(result.current.submitError).toBe(FAILURE));
expect(onSaved).not.toHaveBeenCalled();
});
it("clears an earlier error on the next submit", async () => {
let attempt = 0;
const { result, onSaved } = setup(async () => {
attempt += 1;
if (attempt === 1) throw new ApiError(400, "Volk V-03 ist beendet.");
return { id: "i-1", colonyId: "colony-1" };
});
act(() => result.current.submit(INPUT));
await waitFor(() => expect(result.current.submitError).toBe("Volk V-03 ist beendet."));
act(() => result.current.submit(INPUT));
await waitFor(() => expect(onSaved).toHaveBeenCalled());
expect(result.current.submitError).toBeNull();
});
});
@@ -0,0 +1,79 @@
"use client";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { ApiError } from "@/lib/auth/api-client";
import { useCurrentUserId } from "@/lib/auth/auth-context";
import { errorMessage } from "@/lib/forms/error-message";
import { CreateOptions, newIdempotencyKey } from "./idempotency";
/** Prefix of every query that reads the offline queue (band, queue sheet). */
export const OFFLINE_QUEUE_QUERY_KEY = ["offline-queue"] as const;
export type CaptureOutcome<R> =
| { via: "online"; result: R }
| { via: "queued"; idempotencyKey: string };
export interface CaptureMutationOptions<I, R> {
create: (input: I, options: CreateOptions) => Promise<R>;
queue: (input: I, idempotencyKey: string, ownerUserId: string) => Promise<void>;
/** Shown when nothing could be saved and the server gave no reason. */
failureMessage: string;
onSaved: (outcome: CaptureOutcome<R>, input: I) => void | Promise<void>;
}
export interface CaptureMutation<I> {
submit: (input: I) => void;
isPending: boolean;
submitError: string | null;
idempotencyKey: string;
}
/**
* Saves a quick-capture form: online first; without a network the entry goes
* into the offline queue under the signed-in user. A server rejection
* (ApiError) is shown at the form and never queued.
*/
export function useCaptureMutation<I, R>({
create,
queue,
failureMessage,
onSaved,
}: CaptureMutationOptions<I, R>): CaptureMutation<I> {
const ownerUserId = useCurrentUserId();
const queryClient = useQueryClient();
// One key per opened form: a double tap or an offline retry of the
// same entry is recognised by the server instead of saved twice.
const [idempotencyKey] = useState(newIdempotencyKey);
const [submitError, setSubmitError] = useState<string | null>(null);
const mutation = useMutation({
// Runs while the browser reports offline too: the request fails fast and
// the entry is queued, instead of the form waiting for a network.
networkMode: "always",
retry: false,
mutationFn: async (input: I): Promise<CaptureOutcome<R>> => {
try {
return { via: "online", result: await create(input, { idempotencyKey }) };
} catch (error) {
// Never queue without an owner: such an entry would be sent under
// whoever signs in next on this device.
if (error instanceof ApiError || !ownerUserId) {
throw error;
}
await queue(input, idempotencyKey, ownerUserId);
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
return { via: "queued", idempotencyKey };
}
},
onSuccess: (outcome, input) => onSaved(outcome, input),
onError: (error) => setSubmitError(errorMessage(error, failureMessage)),
});
function submit(input: I): void {
setSubmitError(null);
mutation.mutate(input);
}
return { submit, isPending: mutation.isPending, submitError, idempotencyKey };
}
@@ -0,0 +1,121 @@
// @vitest-environment node
// Node's Blob: its bytes can be read back (jsdom's Blob cannot be cloned).
import { describe, expect, it, vi } from "vitest";
import {
PHOTO_FALLBACK_QUALITY,
PHOTO_JPEG_QUALITY,
PHOTO_MAX_EDGE_PX,
PhotoMetadataError,
PhotoTooLargeError,
containsExifOrGps,
preparePhoto,
type ImageEncoder,
} from "./reencode";
const MB = 1024 * 1024;
function ascii(text: string): number[] {
return Array.from(text, (char) => char.charCodeAt(0));
}
/** A JPEG segment: marker, two length bytes (length counts itself), payload. */
function segment(marker: number, payload: number[]): number[] {
const length = payload.length + 2;
return [0xff, marker, length >> 8, length & 0xff, ...payload];
}
const SOI = [0xff, 0xd8];
const EOI = [0xff, 0xd9];
const JFIF = segment(0xe0, [...ascii("JFIF\0"), 1, 1, 0, 0, 1, 0, 1, 0, 0]);
const QUANTIZATION = segment(0xdb, [0, ...new Array<number>(64).fill(1)]);
const SCAN = [...segment(0xda, [1, 1, 0, 0, 0x3f, 0]), 0x12, 0x34, 0xff, 0x00, 0x56];
// What a phone camera writes: TIFF header, then (in a real photo) the GPS IFD.
const EXIF_APP1 = segment(0xe1, [...ascii("Exif\0\0"), ...ascii("MM\0*"), 0, 0, 0, 8]);
const XMP_APP1 = segment(0xe1, [
...ascii("http://ns.adobe.com/xap/1.0/\0"),
...ascii('<x:xmpmeta><rdf:Description exif:GPSLatitude="52,31.0N"/></x:xmpmeta>'),
]);
function jpeg(...segments: number[][]): Uint8Array<ArrayBuffer> {
return Uint8Array.from([...SOI, ...segments.flat(), ...EOI]);
}
const CLEAN_JPEG = jpeg(JFIF, QUANTIZATION, SCAN);
/** Answers each encode call with the next of the given results. */
function fakeEncoder(...results: Blob[]): ImageEncoder & { encode: ReturnType<typeof vi.fn> } {
const encode = vi.fn();
for (const result of results) {
encode.mockResolvedValueOnce(result);
}
return { encode };
}
function blobOf(size: number): Blob {
// A clean JPEG start, padded to the wanted size with zeros.
const bytes = new Uint8Array(size);
bytes.set(CLEAN_JPEG.subarray(0, CLEAN_JPEG.length - 2));
return new Blob([bytes], { type: "image/jpeg" });
}
describe("containsExifOrGps", () => {
it("detects an EXIF APP1 segment", () => {
expect(containsExifOrGps(jpeg(EXIF_APP1, QUANTIZATION, SCAN))).toBe(true);
});
it("detects XMP", () => {
expect(containsExifOrGps(jpeg(JFIF, XMP_APP1, QUANTIZATION, SCAN))).toBe(true);
});
it("a clean JPEG passes", () => {
expect(containsExifOrGps(CLEAN_JPEG)).toBe(false);
});
});
describe("preparePhoto", () => {
const original = new Blob([jpeg(EXIF_APP1, QUANTIZATION, SCAN)], { type: "image/jpeg" });
it("re-encodes at the photo size and quality and returns the clean result", async () => {
const clean = new Blob([CLEAN_JPEG], { type: "image/jpeg" });
const encoder = fakeEncoder(clean);
const prepared = await preparePhoto(original, encoder);
expect(encoder.encode).toHaveBeenCalledWith(original, PHOTO_MAX_EDGE_PX, PHOTO_JPEG_QUALITY);
expect(new Uint8Array(await prepared.arrayBuffer())).toEqual(CLEAN_JPEG);
});
it("falls back to quality 0.7 when the first result is over 10 MB", async () => {
const encoder = fakeEncoder(blobOf(11 * MB), blobOf(2 * MB));
const prepared = await preparePhoto(original, encoder);
expect(prepared.size).toBe(2 * MB);
expect(encoder.encode).toHaveBeenNthCalledWith(1, original, PHOTO_MAX_EDGE_PX, 0.85);
expect(encoder.encode).toHaveBeenNthCalledWith(2, original, PHOTO_MAX_EDGE_PX, 0.7);
expect(PHOTO_FALLBACK_QUALITY).toBe(0.7);
});
it("throws PhotoTooLargeError when still too large", async () => {
const encoder = fakeEncoder(blobOf(11 * MB), blobOf(10 * MB + 1));
await expect(preparePhoto(original, encoder)).rejects.toBeInstanceOf(PhotoTooLargeError);
});
it("throws PhotoMetadataError and never returns a blob that still has EXIF", async () => {
// An encoder that copied the camera's metadata into its output.
const encoder = fakeEncoder(new Blob([jpeg(EXIF_APP1, QUANTIZATION, SCAN)]));
await expect(preparePhoto(original, encoder)).rejects.toBeInstanceOf(PhotoMetadataError);
});
it("checks the fallback result for metadata too", async () => {
const withXmp = jpeg(JFIF, XMP_APP1, QUANTIZATION, SCAN);
const padded = new Uint8Array(2 * MB);
padded.set(withXmp);
const encoder = fakeEncoder(blobOf(11 * MB), new Blob([padded]));
await expect(preparePhoto(original, encoder)).rejects.toBeInstanceOf(PhotoMetadataError);
});
});
+118
View File
@@ -0,0 +1,118 @@
import { MAX_DOCUMENT_UPLOAD_BYTES } from "@/lib/documents/limits";
/** Longest edge of a stored photo: enough to see eggs on a frame. */
export const PHOTO_MAX_EDGE_PX = 2560;
export const PHOTO_JPEG_QUALITY = 0.85;
/** Second try when the first result is still above the upload limit. */
export const PHOTO_FALLBACK_QUALITY = 0.7;
export interface ImageEncoder {
encode(file: Blob, maxEdgePx: number, quality: number): Promise<Blob>;
}
/** The photo stays above the upload limit even at the fallback quality. */
export class PhotoTooLargeError extends Error {
constructor() {
super("Foto zu groß (max. 10 MB)");
this.name = "PhotoTooLargeError";
}
}
/** The re-encoded photo still carries EXIF or XMP - it is never kept. */
export class PhotoMetadataError extends Error {
constructor() {
super("Foto enthält noch Metadaten");
this.name = "PhotoMetadataError";
}
}
/**
* Draws the image onto a canvas and writes a new JPEG. A canvas holds pixels
* only, so nothing of the camera's EXIF (GPS position of the apiary, device,
* time) or XMP can reach the output. The camera's rotation is applied to the
* pixels first (`imageOrientation: "from-image"`), so the photo still stands
* upright once the orientation tag is gone.
*/
export const canvasImageEncoder: ImageEncoder = {
async encode(file, maxEdgePx, quality) {
const bitmap = await createImageBitmap(file, { imageOrientation: "from-image" });
try {
const scale = Math.min(1, maxEdgePx / Math.max(bitmap.width, bitmap.height));
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
const context = canvas.getContext("2d");
if (!context) {
throw new Error("Canvas 2D is not available");
}
// JPEG has no transparency: a transparent PNG gets white, not black.
context.fillStyle = "#ffffff";
context.fillRect(0, 0, canvas.width, canvas.height);
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
return await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error("Canvas could not encode a JPEG"))),
"image/jpeg",
quality,
);
});
} finally {
bitmap.close();
}
},
};
function asciiBytes(text: string): Uint8Array {
return Uint8Array.from(text, (char) => char.charCodeAt(0));
}
// The EXIF APP1 header (holds the GPS IFD) and the XMP namespace URI.
const EXIF_SIGNATURE = asciiBytes("Exif\0\0");
const XMP_SIGNATURE = asciiBytes("http://ns.adobe.com/xap/");
function includesBytes(haystack: Uint8Array, needle: Uint8Array): boolean {
const first = needle[0];
const last = haystack.length - needle.length;
for (let start = 0; start <= last; start++) {
if (haystack[start] !== first) continue;
let offset = 1;
while (offset < needle.length && haystack[start + offset] === needle[offset]) {
offset++;
}
if (offset === needle.length) return true;
}
return false;
}
/**
* True if the bytes contain an EXIF header ("Exif\0\0", as in a JPEG APP1
* segment) or XMP. Searches the whole file, not only the APP segments: a
* leftover anywhere is reason enough to refuse the photo.
*/
export function containsExifOrGps(bytes: Uint8Array): boolean {
return includesBytes(bytes, EXIF_SIGNATURE) || includesBytes(bytes, XMP_SIGNATURE);
}
/**
* Turns a camera photo into what may be stored on the device and uploaded:
* re-encoded without metadata, at most PHOTO_MAX_EDGE_PX on its long edge
* and within the upload limit. Throws PhotoTooLargeError if even the
* fallback quality is too large, and PhotoMetadataError if the result still
* holds EXIF or XMP - such a photo is never returned, so never uploaded.
*/
export async function preparePhoto(
file: Blob,
encoder: ImageEncoder = canvasImageEncoder,
): Promise<Blob> {
let encoded = await encoder.encode(file, PHOTO_MAX_EDGE_PX, PHOTO_JPEG_QUALITY);
if (encoded.size > MAX_DOCUMENT_UPLOAD_BYTES) {
encoded = await encoder.encode(file, PHOTO_MAX_EDGE_PX, PHOTO_FALLBACK_QUALITY);
}
if (encoded.size > MAX_DOCUMENT_UPLOAD_BYTES) {
throw new PhotoTooLargeError();
}
if (containsExifOrGps(new Uint8Array(await encoded.arrayBuffer()))) {
throw new PhotoMetadataError();
}
return encoded;
}
@@ -0,0 +1,327 @@
// @vitest-environment node
// Node's Blob survives IndexedDB (fake-indexeddb clones it); jsdom's does not.
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import { afterEach, beforeEach, describe, expect, it, vi, type MockedFunction } from "vitest";
vi.mock("@/lib/inspections/api", () => ({ createInspection: vi.fn() }));
vi.mock("@/lib/documents/api", () => ({ uploadDocument: vi.fn() }));
type OfflineDbModule = typeof import("@/lib/offline/db");
type StoreModule = typeof import("./store");
type SyncModule = typeof import("@/lib/offline/sync");
type ApiErrorClass = typeof import("@/lib/auth/api-client").ApiError;
type Upload = MockedFunction<typeof import("@/lib/documents/api").uploadDocument>;
let db: OfflineDbModule;
let store: StoreModule;
let sync: SyncModule;
let ApiError: ApiErrorClass;
let createInspection: MockedFunction<typeof import("@/lib/inspections/api").createInspection>;
let syncUpload: Upload;
const INSPECTION = { colonyId: "colony-1", date: "2026-10-13T00:00:00.000Z" };
const JPEG_BYTES = Uint8Array.from([0xff, 0xd8, 0xff, 0xdb, 0x00, 0x03, 0x01, 0xff, 0xd9]);
const SAVED_DOCUMENT = {
id: "doc-1",
entityType: "INSPECTION" as const,
entityId: "srv-1",
fileName: "durchsicht-2026-10-13-1.jpg",
mimeType: "image/jpeg",
sizeBytes: JPEG_BYTES.length,
uploadedByUserId: "user-1",
createdAt: "2026-10-13T10:00:00.000Z",
};
function photoBlob(): Blob {
return new Blob([JPEG_BYTES], { type: "image/jpeg" });
}
function takePhoto(sessionId = "session-1", ownerUserId = "user-1") {
return store.addDraftPhoto({ sessionId, ownerUserId, blob: photoBlob(), date: "2026-10-13" });
}
function uploadMock(): Upload {
return vi.fn() as Upload;
}
describe("photo store", () => {
beforeEach(async () => {
// Fresh database and modules: no open connection survives from the last test.
globalThis.indexedDB = new IDBFactory();
vi.resetModules();
db = await import("@/lib/offline/db");
store = await import("./store");
sync = await import("@/lib/offline/sync");
ApiError = (await import("@/lib/auth/api-client")).ApiError;
createInspection = vi.mocked((await import("@/lib/inspections/api")).createInspection);
createInspection.mockReset();
syncUpload = vi.mocked((await import("@/lib/documents/api")).uploadDocument);
syncUpload.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
it("draft → waiting → ready after the queued inspection is created", async () => {
const taken = await takePhoto();
expect(taken).toMatchObject({
sessionId: "session-1",
ownerUserId: "user-1",
state: "draft",
fileName: "durchsicht-2026-10-13-1.jpg",
});
expect(taken.uploadIdempotencyKey).toMatch(/^[A-Za-z0-9_-]{16,128}$/);
await store.attachSessionToQueuedInspection("session-1", "inspection-key");
const [waiting] = await store.listSessionPhotos("session-1");
expect(waiting).toMatchObject({
state: "waiting-for-inspection",
inspectionIdempotencyKey: "inspection-key",
});
expect(waiting.inspectionId).toBeUndefined();
await store.resolveQueuedInspection("inspection-key", "srv-1");
const [ready] = await store.listSessionPhotos("session-1");
expect(ready).toMatchObject({ state: "ready", inspectionId: "srv-1" });
});
it("photos of an inspection the server rejected are never uploaded", async () => {
await db.queueInspection(INSPECTION, "inspection-key", "user-1");
await takePhoto();
await store.attachSessionToQueuedInspection("session-1", "inspection-key");
const rejection = new ApiError(400, "Volk V-03 ist beendet.", { statusCode: 400 });
createInspection.mockRejectedValue(rejection);
const run = await sync.syncPendingEntries("user-1");
const upload = uploadMock();
const direct = await store.uploadReadyPhotos("user-1", upload);
expect(run).toEqual({ synced: 0, failed: 1, skippedForeign: 0 });
const [inspection] = await db.getPendingEntries("pending-inspections");
expect(inspection.lastError).toMatchObject({ status: 400, message: "Volk V-03 ist beendet." });
expect(direct).toEqual({ synced: 0, failed: 0, skippedForeign: 0 });
expect(upload).not.toHaveBeenCalled();
expect(syncUpload).not.toHaveBeenCalled();
const [photo] = await store.listSessionPhotos("session-1");
expect(photo).toMatchObject({ state: "waiting-for-inspection" });
expect(photo.inspectionId).toBeUndefined();
});
it("discarding the inspection discards its photos", async () => {
await db.queueInspection(INSPECTION, "key-a", "user-1");
await db.queueInspection(INSPECTION, "key-b", "user-1");
await takePhoto("session-a");
await takePhoto("session-a");
await takePhoto("session-b");
await store.attachSessionToQueuedInspection("session-a", "key-a");
await store.attachSessionToQueuedInspection("session-b", "key-b");
await store.discardPhotosForInspection("key-a");
expect(await store.listSessionPhotos("session-a")).toEqual([]);
expect(await store.listSessionPhotos("session-b")).toHaveLength(1);
// The queue sheet discards an inspection together with its photos.
const [, inspectionB] = await db.listQueue("user-1");
await db.discardQueueItem(inspectionB);
expect(await store.listSessionPhotos("session-b")).toEqual([]);
expect(await store.uploadReadyPhotos("user-1", uploadMock())).toEqual({
synced: 0,
failed: 0,
skippedForeign: 0,
});
});
it("uploads with the photo's own Idempotency-Key to INSPECTION/<server id> and deletes it afterwards", async () => {
const taken = await takePhoto();
await store.attachSessionToInspection("session-1", "srv-1");
const upload = uploadMock().mockResolvedValue(SAVED_DOCUMENT);
const result = await store.uploadReadyPhotos("user-1", upload);
expect(result).toEqual({ synced: 1, failed: 0, skippedForeign: 0 });
expect(upload).toHaveBeenCalledWith("INSPECTION", "srv-1", expect.any(Blob), {
idempotencyKey: taken.uploadIdempotencyKey,
fileName: "durchsicht-2026-10-13-1.jpg",
});
const sent = upload.mock.calls[0][2];
expect(new Uint8Array(await sent.arrayBuffer())).toEqual(JPEG_BYTES);
expect(await store.listSessionPhotos("session-1")).toEqual([]);
});
it("keeps a photo with lastError on 413", async () => {
await takePhoto();
await store.attachSessionToInspection("session-1", "srv-1");
const upload = uploadMock().mockRejectedValue(new ApiError(413, "Datei zu groß"));
const result = await store.uploadReadyPhotos("user-1", upload);
expect(result).toEqual({ synced: 0, failed: 1, skippedForeign: 0 });
const [kept] = await store.listSessionPhotos("session-1");
expect(kept).toMatchObject({ state: "ready", inspectionId: "srv-1" });
expect(kept.lastError).toMatchObject({ status: 413, message: "Datei zu groß" });
expect(await store.listPendingPhotos("user-1")).toEqual([
expect.objectContaining({ id: kept.id, lastError: expect.objectContaining({ status: 413 }) }),
]);
});
it("waits for „Erneut senden“ after a permanent rejection, retries a passing one by itself", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-10-13T08:00:00.000Z"));
const tooLarge = await takePhoto("session-1");
vi.setSystemTime(new Date("2026-10-13T08:01:00.000Z"));
await takePhoto("session-2");
await store.attachSessionToInspection("session-1", "srv-1");
await store.attachSessionToInspection("session-2", "srv-2");
const upload = uploadMock()
.mockRejectedValueOnce(new ApiError(413, "Datei zu groß"))
.mockRejectedValueOnce(new ApiError(503, "Wartung"))
.mockResolvedValue(SAVED_DOCUMENT);
await store.uploadReadyPhotos("user-1", upload);
await store.uploadReadyPhotos("user-1", upload);
// The server outage heals by itself; the too large photo is not sent again.
expect(upload.mock.calls.map(([, entityId]) => entityId)).toEqual(["srv-1", "srv-2", "srv-2"]);
expect((await store.listPendingPhotos("user-1")).map((photo) => photo.id)).toEqual([
tooLarge.id,
]);
// „Erneut senden“: the user decides to try it once more.
await store.resetPhotoError(tooLarge.id);
await store.uploadReadyPhotos("user-1", upload);
expect(upload).toHaveBeenCalledTimes(4);
expect(await store.listPendingPhotos("user-1")).toEqual([]);
});
it("never uploads another user's photo", async () => {
await takePhoto("session-1", "user-2");
await store.attachSessionToInspection("session-1", "srv-1");
const upload = uploadMock().mockResolvedValue(SAVED_DOCUMENT);
const result = await store.uploadReadyPhotos("user-1", upload);
expect(result).toEqual({ synced: 0, failed: 0, skippedForeign: 1 });
expect(upload).not.toHaveBeenCalled();
expect(await store.listPendingPhotos("user-1")).toEqual([]);
expect(await store.listPendingPhotos("user-2")).toHaveLength(1);
});
it("stops at a network failure and leaves the photos untouched", async () => {
await takePhoto();
await takePhoto();
await store.attachSessionToInspection("session-1", "srv-1");
const upload = uploadMock().mockRejectedValue(new TypeError("Failed to fetch"));
const result = await store.uploadReadyPhotos("user-1", upload);
expect(result).toEqual({ synced: 0, failed: 1, skippedForeign: 0 });
expect(upload).toHaveBeenCalledTimes(1);
const photos = await store.listSessionPhotos("session-1");
expect(photos).toHaveLength(2);
expect(photos.every((photo) => photo.lastError === undefined)).toBe(true);
});
it("stops uploading once the run is cancelled", async () => {
await takePhoto();
await takePhoto();
await store.attachSessionToInspection("session-1", "srv-1");
let cancelled = false;
const upload = uploadMock().mockImplementation(async () => {
cancelled = true; // e.g. logout while the first upload runs
return SAVED_DOCUMENT;
});
const result = await store.uploadReadyPhotos("user-1", upload, () => cancelled);
expect(result).toEqual({ synced: 1, failed: 0, skippedForeign: 0 });
expect(upload).toHaveBeenCalledTimes(1);
expect(await store.listSessionPhotos("session-1")).toHaveLength(1);
});
it("never moves a photo that already belongs to an inspection", async () => {
await takePhoto();
await store.attachSessionToQueuedInspection("session-1", "key-a");
await store.attachSessionToInspection("session-1", "srv-other");
await store.attachSessionToQueuedInspection("session-1", "key-other");
await store.resolveQueuedInspection("key-other", "srv-other");
const [photo] = await store.listSessionPhotos("session-1");
expect(photo).toMatchObject({ state: "waiting-for-inspection", inspectionIdempotencyKey: "key-a" });
expect(photo.inspectionId).toBeUndefined();
});
it("gives the photos back to the form when the inspection could not be queued", async () => {
await takePhoto();
await store.attachSessionToQueuedInspection("session-1", "key-a");
await store.releaseSessionPhotos("session-1");
const [photo] = await store.listSessionPhotos("session-1");
expect(photo.state).toBe("draft");
expect(photo.inspectionIdempotencyKey).toBeUndefined();
expect((await db.listQueue("user-1")).length).toBe(0);
});
it("discarding a form draft removes its unsaved photos only", async () => {
await takePhoto("session-1");
await takePhoto("session-2");
await store.attachSessionToQueuedInspection("session-2", "key-b");
await takePhoto("session-2"); // taken after the save: still a draft
await store.discardSessionPhotos("session-1");
await store.discardSessionPhotos("session-2");
expect(await store.listSessionPhotos("session-1")).toEqual([]);
const kept = await store.listSessionPhotos("session-2");
expect(kept.map((photo) => photo.state)).toEqual(["waiting-for-inspection"]);
});
it("purges drafts older than 3 days and keeps everything else", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-10-01T08:00:00.000Z"));
await takePhoto("old-draft");
await takePhoto("old-queued");
await store.attachSessionToQueuedInspection("old-queued", "key-q");
vi.setSystemTime(new Date("2026-10-03T08:00:00.000Z"));
await takePhoto("recent-draft");
const purged = await store.purgeStaleDraftPhotos(Date.parse("2026-10-04T08:00:01.000Z"));
expect(purged).toBe(1);
expect(await store.listSessionPhotos("old-draft")).toEqual([]);
expect(await store.listSessionPhotos("old-queued")).toHaveLength(1);
expect(await store.listSessionPhotos("recent-draft")).toHaveLength(1);
});
it("never stores a photo that still carries EXIF", async () => {
const { PhotoMetadataError } = await import("./reencode");
const exifHeader = Array.from("Exif\0\0", (char) => char.charCodeAt(0));
const exif = Uint8Array.from([0xff, 0xd8, 0xff, 0xe1, 0x00, 0x08, ...exifHeader, 0xff, 0xd9]);
await expect(
store.addDraftPhoto({
sessionId: "session-1",
ownerUserId: "user-1",
blob: new Blob([exif], { type: "image/jpeg" }),
date: "2026-10-13",
}),
).rejects.toBeInstanceOf(PhotoMetadataError);
expect(await store.listSessionPhotos("session-1")).toEqual([]);
});
it("numbers the photos of a session without reusing a number", async () => {
const first = await takePhoto();
await takePhoto();
await store.removePhoto(first.id);
const third = await takePhoto();
expect(third.fileName).toBe("durchsicht-2026-10-13-3.jpg");
const names = (await store.listSessionPhotos("session-1")).map((photo) => photo.fileName);
expect(names).toEqual(["durchsicht-2026-10-13-2.jpg", "durchsicht-2026-10-13-3.jpg"]);
});
});
+297
View File
@@ -0,0 +1,297 @@
import { ApiError } from "@/lib/auth/api-client";
import { uploadDocument } from "@/lib/documents/api";
import { getDb, toQueueError, type QueueError, type StoredPhoto } from "@/lib/offline/db";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
import type { SyncResult } from "@/lib/offline/sync";
import { PhotoMetadataError, containsExifOrGps } from "./reencode";
/*
* Inspection photos on this device, each one already re-encoded without
* metadata (preparePhoto). A photo moves through three states:
*
* - draft: taken in an inspection form that is not saved yet. Belongs to the
* form's photo session (`photoSessionId` in the form draft).
* - waiting-for-inspection: the inspection went into the offline queue; the
* photo knows only that entry's idempotency key.
* - ready: the inspection exists on the server; the photo knows its id and is
* uploaded by the next sync, then deleted here.
*
* A photo only ever moves forward from draft, so it can never be attached to
* a second record. Discarding a queued inspection discards its photos with
* it (db.discardQueueItem), so they are never uploaded anywhere.
*/
export const PHOTOS_PER_INSPECTION_MAX = 5;
// As long as an unsaved form draft lives (use-form-draft.ts): once the draft
// is gone, nobody can reach its photos any more.
const DRAFT_PHOTO_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1000;
// Rejections that may pass on the next try: session refresh, timeout, the
// same key still in progress, rate limit, server trouble.
const PASSING_STATUSES = new Set([401, 408, 409, 429]);
const FILE_NUMBER = /-(\d+)\.jpg$/;
type PhotoState = StoredPhoto["state"];
export interface NewDraftPhoto {
sessionId: string;
ownerUserId: string;
/** Already re-encoded by preparePhoto - never the camera's original. */
blob: Blob;
/** The inspection date (yyyy-mm-dd), for the file name. */
date: string;
}
function fileNumber(photo: StoredPhoto): number {
const match = FILE_NUMBER.exec(photo.fileName);
return match ? Number(match[1]) : 0;
}
function byCaptureOrder(a: StoredPhoto, b: StoredPhoto): number {
return a.createdAt.localeCompare(b.createdAt) || fileNumber(a) - fileNumber(b);
}
/**
* Stores a photo of an unsaved inspection form right away, so it survives a
* closed tab or a dead battery like the rest of the form draft. A blob that
* still carries EXIF or XMP is refused: only clean photos reach the device's
* storage, so only clean photos can ever be uploaded from it.
*/
export async function addDraftPhoto({
sessionId,
ownerUserId,
blob,
date,
}: NewDraftPhoto): Promise<StoredPhoto> {
// Checked before the transaction opens: awaiting the bytes inside it would
// let IndexedDB commit it early.
if (containsExifOrGps(new Uint8Array(await blob.arrayBuffer()))) {
throw new PhotoMetadataError();
}
const db = await getDb();
const tx = db.transaction("pending-photos", "readwrite");
const session = await tx.store.index("sessionId").getAll(sessionId);
// Never reuse the number of a removed photo: names stay unique per session.
const number = Math.max(0, ...session.map(fileNumber)) + 1;
const photo: StoredPhoto = {
id: newIdempotencyKey(),
sessionId,
ownerUserId,
blob,
fileName: `durchsicht-${date}-${number}.jpg`,
createdAt: new Date().toISOString(),
state: "draft",
uploadIdempotencyKey: newIdempotencyKey(),
};
await tx.store.add(photo);
await tx.done;
return photo;
}
/** Every photo of one form's session, in the order they were taken. */
export async function listSessionPhotos(sessionId: string): Promise<StoredPhoto[]> {
const db = await getDb();
const photos = await db.getAllFromIndex("pending-photos", "sessionId", sessionId);
return photos.sort(byCaptureOrder);
}
export async function removePhoto(id: string): Promise<void> {
const db = await getDb();
await db.delete("pending-photos", id);
}
/** Rewrites the session's photos that are in state `from`; others stay as they are. */
async function moveSessionPhotos(
sessionId: string,
from: PhotoState,
change: (photo: StoredPhoto) => StoredPhoto,
): Promise<void> {
const db = await getDb();
const tx = db.transaction("pending-photos", "readwrite");
const photos = await tx.store.index("sessionId").getAll(sessionId);
await Promise.all(
photos.filter((photo) => photo.state === from).map((photo) => tx.store.put(change(photo))),
);
await tx.done;
}
/** "Entwurf verwerfen": deletes the session's unsaved photos, never saved ones. */
export async function discardSessionPhotos(sessionId: string): Promise<void> {
const db = await getDb();
const tx = db.transaction("pending-photos", "readwrite");
const photos = await tx.store.index("sessionId").getAll(sessionId);
await Promise.all(
photos.filter((photo) => photo.state === "draft").map((photo) => tx.store.delete(photo.id)),
);
await tx.done;
}
/** The inspection was saved online: its photos are ready to upload. */
export function attachSessionToInspection(sessionId: string, inspectionId: string): Promise<void> {
return moveSessionPhotos(sessionId, "draft", (photo) => ({
...photo,
state: "ready",
inspectionId,
}));
}
/** The inspection went into the offline queue: its photos wait for it. */
export function attachSessionToQueuedInspection(
sessionId: string,
inspectionIdempotencyKey: string,
): Promise<void> {
return moveSessionPhotos(sessionId, "draft", (photo) => ({
...photo,
state: "waiting-for-inspection",
inspectionIdempotencyKey,
}));
}
/**
* Undoes attachSessionToQueuedInspection when the inspection could not be
* queued after all: the photos belong to the still open form again.
*/
export function releaseSessionPhotos(sessionId: string): Promise<void> {
return moveSessionPhotos(sessionId, "waiting-for-inspection", (photo) => ({
...photo,
state: "draft",
inspectionIdempotencyKey: undefined,
}));
}
/**
* The queued inspection was created on the server (sync): its photos get the
* server id and are uploaded once all records of the run are sent.
*/
export async function resolveQueuedInspection(
inspectionIdempotencyKey: string,
inspectionId: string,
): Promise<void> {
const db = await getDb();
const tx = db.transaction("pending-photos", "readwrite");
const photos = await tx.store
.index("inspectionIdempotencyKey")
.getAll(inspectionIdempotencyKey);
await Promise.all(
photos
.filter((photo) => photo.state === "waiting-for-inspection")
.map((photo) =>
tx.store.put({ ...photo, state: "ready", inspectionId, inspectionIdempotencyKey: undefined }),
),
);
await tx.done;
}
/** Deletes the photos still waiting for this queued inspection. */
export async function discardPhotosForInspection(inspectionIdempotencyKey: string): Promise<void> {
const db = await getDb();
const tx = db.transaction("pending-photos", "readwrite");
const ids = await tx.store.index("inspectionIdempotencyKey").getAllKeys(inspectionIdempotencyKey);
await Promise.all(ids.map((id) => tx.store.delete(id)));
await tx.done;
}
/** Deletes photos of form drafts older than 3 days; returns how many. */
export async function purgeStaleDraftPhotos(now: number): Promise<number> {
const db = await getDb();
const tx = db.transaction("pending-photos", "readwrite");
const stale = (await tx.store.getAll()).filter(
(photo) =>
photo.state === "draft" && now - Date.parse(photo.createdAt) > DRAFT_PHOTO_MAX_AGE_MS,
);
await Promise.all(stale.map((photo) => tx.store.delete(photo.id)));
await tx.done;
return stale.length;
}
/**
* The user's photos waiting to be uploaded on their own (their inspection is
* on the server), rejected ones included - the queue sheet lists them.
*/
export async function listPendingPhotos(currentUserId: string): Promise<StoredPhoto[]> {
const db = await getDb();
const own = await db.getAllFromIndex("pending-photos", "ownerUserId", currentUserId);
return own.filter((photo) => photo.state === "ready").sort(byCaptureOrder);
}
async function setPhotoError(id: string, lastError: QueueError | undefined): Promise<void> {
const db = await getDb();
const tx = db.transaction("pending-photos", "readwrite");
const photo = await tx.store.get(id);
if (photo) {
await tx.store.put({ ...photo, lastError });
}
await tx.done;
}
/** „Erneut senden": the next sync tries the rejected photo again. */
export function resetPhotoError(id: string): Promise<void> {
return setPhotoError(id, undefined);
}
/**
* A rejection that would fail the same way again (too large, wrong type,
* inspection gone) is not re-sent by every sync - that would burn megabytes
* of mobile data each time. Such a photo waits for „Erneut senden".
*/
function isSentBySync(photo: StoredPhoto): boolean {
const status = photo.lastError?.status;
return status === undefined || status >= 500 || PASSING_STATUSES.has(status);
}
/**
* Uploads the run user's ready photos with each photo's own idempotency key
* to INSPECTION/<server id> and deletes each one once the server has it.
* Called by the sync run (runSync) after all record drains, which passes its
* cancellation, so a logout stops it like any other drain.
*
* Each photo is read again right before sending: one removed, still waiting
* or taken over by another account meanwhile is not sent. A network failure
* stops the run and leaves the rest untouched; a server rejection is stored
* on the photo, which stays for the queue sheet.
*/
export async function uploadReadyPhotos(
currentUserId: string,
upload: typeof uploadDocument = uploadDocument,
isCancelled: () => boolean = () => false,
): Promise<SyncResult> {
const db = await getDb();
const ready = (await db.getAll("pending-photos")).filter((photo) => photo.state === "ready");
const own = ready.filter((photo) => photo.ownerUserId === currentUserId).sort(byCaptureOrder);
let synced = 0;
let failed = 0;
let skippedForeign = ready.length - own.length;
for (const { id } of own) {
const photo = await db.get("pending-photos", id);
if (isCancelled()) {
break;
}
if (!photo || photo.state !== "ready" || !photo.inspectionId || !isSentBySync(photo)) {
continue;
}
if (photo.ownerUserId !== currentUserId) {
skippedForeign += 1;
continue;
}
try {
await upload("INSPECTION", photo.inspectionId, photo.blob, {
idempotencyKey: photo.uploadIdempotencyKey,
fileName: photo.fileName,
});
} catch (error) {
failed += 1;
if (!(error instanceof ApiError)) {
break;
}
await setPhotoError(id, toQueueError(error));
continue;
}
await db.delete("pending-photos", id);
synced += 1;
}
return { synced, failed, skippedForeign };
}
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { originText } from "./origin-text";
describe("originText", () => {
it("shows only the country when no region is set", () => {
expect(originText({ regions: [], country: "Deutschland" })).toBe("Deutschland");
});
it("puts the regions in front of the country", () => {
expect(originText({ regions: ["Nordhessen"], country: "Deutschland" })).toBe(
"Nordhessen, Deutschland",
);
});
it("lists several regions before the country", () => {
expect(
originText({ regions: ["Nordhessen", "Südhessen"], country: "Deutschland" }),
).toBe("Nordhessen, Südhessen, Deutschland");
});
});
@@ -0,0 +1,6 @@
import type { PublicOrigin } from "./types";
/** "Nordhessen, Deutschland" - or just "Deutschland" when no region is public. */
export function originText(origin: PublicOrigin): string {
return [...origin.regions, origin.country].join(", ");
}
@@ -1,6 +1,7 @@
export interface PublicApiaryOrigin {
name: string;
location: string | null;
/** Region labels and country only - never a stand's name, address or coordinates. */
export interface PublicOrigin {
regions: string[];
country: string;
}
export interface PublicSocialLinks {
@@ -21,6 +22,6 @@ export interface PublicBatch {
waterContent: number | null;
extractedDate: string | null;
harvestDates: string[];
apiaries: PublicApiaryOrigin[];
origin: PublicOrigin;
socialLinks: PublicSocialLinks;
}
@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
clearAllRounds,
endRound,
markColonyDone,
nextRoundColony,
readRound,
roundStorageKey,
startRound,
} from "./round-state";
const APIARY = { id: "a-1", name: "Obstwiese" };
const COLONIES = [
{ id: "c-1", code: "V-1" },
{ id: "c-2", code: "V-2" },
{ id: "c-3", code: "V-3" },
];
describe("round state", () => {
beforeEach(() => window.sessionStorage.clear());
it("next skips done colonies in code order", () => {
startRound("u-1", APIARY, COLONIES);
const round = markColonyDone("u-1", "c-1");
expect(nextRoundColony(round!)).toEqual({ id: "c-2", code: "V-2" });
});
it("next skips a colony moved away or ended mid-round", () => {
const round = startRound("u-1", APIARY, COLONIES);
const done = markColonyDone("u-1", "c-1")!;
expect(nextRoundColony(done, new Set(["c-1", "c-3"]))).toEqual({ id: "c-3", code: "V-3" });
expect(round.colonies).toHaveLength(3);
});
it("null when all done", () => {
startRound("u-1", APIARY, COLONIES);
markColonyDone("u-1", "c-1");
markColonyDone("u-1", "c-2");
const round = markColonyDone("u-1", "c-3")!;
expect(nextRoundColony(round)).toBeNull();
});
it("null when everything left was moved away", () => {
startRound("u-1", APIARY, COLONIES);
const round = markColonyDone("u-1", "c-1")!;
expect(nextRoundColony(round, new Set(["c-1"]))).toBeNull();
});
it("per user and survives a reload", () => {
startRound("u-1", APIARY, COLONIES);
markColonyDone("u-1", "c-1");
expect(readRound("u-2")).toBeNull();
expect(readRound("u-1")).toMatchObject({ apiaryId: "a-1", doneColonyIds: ["c-1"] });
expect(window.sessionStorage.getItem(roundStorageKey("u-1"))).not.toBeNull();
});
it("markColonyDone ignores colonies outside the round", () => {
startRound("u-1", APIARY, COLONIES);
const round = markColonyDone("u-1", "x-9")!;
expect(round.doneColonyIds).toEqual([]);
});
it("markColonyDone counts a colony only once", () => {
startRound("u-1", APIARY, COLONIES);
markColonyDone("u-1", "c-1");
expect(markColonyDone("u-1", "c-1")!.doneColonyIds).toEqual(["c-1"]);
});
it("markColonyDone without a round returns null", () => {
expect(markColonyDone("u-1", "c-1")).toBeNull();
});
it("starting a round replaces a running one", () => {
startRound("u-1", APIARY, COLONIES);
markColonyDone("u-1", "c-1");
startRound("u-1", { id: "a-2", name: "Wald" }, [{ id: "d-1", code: "W-1" }]);
expect(readRound("u-1")).toMatchObject({ apiaryId: "a-2", doneColonyIds: [] });
});
it("endRound and clearAllRounds remove the round", () => {
startRound("u-1", APIARY, COLONIES);
startRound("u-2", APIARY, COLONIES);
endRound("u-1");
expect(readRound("u-1")).toBeNull();
clearAllRounds();
expect(readRound("u-2")).toBeNull();
});
it("treats corrupt storage as no round", () => {
window.sessionStorage.setItem(roundStorageKey("u-1"), "{nope");
expect(readRound("u-1")).toBeNull();
window.sessionStorage.setItem(roundStorageKey("u-1"), JSON.stringify({ version: 1 }));
expect(readRound("u-1")).toBeNull();
});
});
@@ -0,0 +1,139 @@
interface RoundColony {
id: string;
code: string;
}
export interface StandRound {
version: 1;
apiaryId: string;
apiaryName: string;
colonies: RoundColony[];
doneColonyIds: string[];
startedAt: string;
}
const KEY_PREFIX = "apicore:round:";
export function roundStorageKey(userId: string): string {
return `${KEY_PREFIX}${userId}`;
}
function storage(): Storage | null {
try {
return typeof window === "undefined" ? null : window.sessionStorage;
} catch {
return null;
}
}
function isRoundColony(value: unknown): value is RoundColony {
if (typeof value !== "object" || value === null) return false;
const candidate = value as Record<string, unknown>;
return typeof candidate.id === "string" && typeof candidate.code === "string";
}
function isStandRound(value: unknown): value is StandRound {
if (typeof value !== "object" || value === null) return false;
const round = value as Record<string, unknown>;
return (
round.version === 1 &&
typeof round.apiaryId === "string" &&
typeof round.apiaryName === "string" &&
typeof round.startedAt === "string" &&
Array.isArray(round.colonies) &&
round.colonies.every(isRoundColony) &&
Array.isArray(round.doneColonyIds) &&
round.doneColonyIds.every((id) => typeof id === "string")
);
}
function write(userId: string, round: StandRound): void {
try {
storage()?.setItem(roundStorageKey(userId), JSON.stringify(round));
} catch {
// Storage full or blocked: the round just is not remembered.
}
}
/** Begins a round at an apiary; a running round of this user is replaced. */
export function startRound(
userId: string,
apiary: { id: string; name: string },
colonies: RoundColony[],
): StandRound {
const round: StandRound = {
version: 1,
apiaryId: apiary.id,
apiaryName: apiary.name,
colonies: colonies.map(({ id, code }) => ({ id, code })),
doneColonyIds: [],
startedAt: new Date().toISOString(),
};
write(userId, round);
return round;
}
/** The user's running round; anything unreadable counts as no round. */
export function readRound(userId: string): StandRound | null {
try {
const raw = storage()?.getItem(roundStorageKey(userId));
if (!raw) return null;
const parsed: unknown = JSON.parse(raw);
return isStandRound(parsed) ? parsed : null;
} catch {
return null;
}
}
/** Marks a colony of the round as inspected; colonies outside it are ignored. */
export function markColonyDone(userId: string, colonyId: string): StandRound | null {
const round = readRound(userId);
if (!round) return null;
const inRound = round.colonies.some((colony) => colony.id === colonyId);
if (!inRound || round.doneColonyIds.includes(colonyId)) return round;
const next: StandRound = { ...round, doneColonyIds: [...round.doneColonyIds, colonyId] };
write(userId, next);
return next;
}
/**
* The next colony still to do, in round order. `stillInApiary` (the colonies
* currently living at the apiary) drops colonies moved away or ended since
* the round began - the round carries on without them instead of aborting.
*/
export function nextRoundColony(
round: StandRound,
stillInApiary?: ReadonlySet<string>,
): RoundColony | null {
return (
round.colonies.find(
(colony) =>
!round.doneColonyIds.includes(colony.id) &&
(stillInApiary === undefined || stillInApiary.has(colony.id)),
) ?? null
);
}
export function endRound(userId: string): void {
try {
storage()?.removeItem(roundStorageKey(userId));
} catch {
// Nothing to remove.
}
}
/** Sign-out: no round may outlive its user's session. */
export function clearAllRounds(): void {
try {
const store = storage();
if (!store) return;
const keys: string[] = [];
for (let index = 0; index < store.length; index += 1) {
const key = store.key(index);
if (key?.startsWith(KEY_PREFIX)) keys.push(key);
}
keys.forEach((key) => store.removeItem(key));
} catch {
// Nothing to clear.
}
}
@@ -0,0 +1,68 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useColonyOverview } from "@/lib/apiaries/use-colony-overview";
import { useCurrentUserId } from "@/lib/auth/auth-context";
import {
endRound,
markColonyDone,
nextRoundColony,
readRound,
type StandRound,
} from "./round-state";
export interface StandRoundHandle {
/** The running round, only when it belongs to this apiary and contains the colony. */
round: StandRound | null;
/** Records the colony as done; returns the round as it stands afterwards. */
markDone: (colonyId: string) => StandRound | null;
/** Next colony still to do, skipping ones that moved away or ended. */
next: (round: StandRound) => { id: string; code: string } | null;
end: () => void;
}
/**
* The stand round a form was opened from (`?round=<apiaryId>`). Read after
* mount - sessionStorage does not exist on the server. Which colonies still
* live at the apiary comes from the overview query; without it (offline, not
* loaded yet) nothing is skipped and the round just offers the next one.
*/
export function useStandRound(roundApiaryId: string | null, colonyId: string): StandRoundHandle {
const userId = useCurrentUserId();
const [round, setRound] = useState<StandRound | null>(null);
useEffect(() => {
const stored = userId && roundApiaryId ? readRound(userId) : null;
const matches =
stored !== null &&
stored.apiaryId === roundApiaryId &&
stored.colonies.some((colony) => colony.id === colonyId);
// eslint-disable-next-line react-hooks/set-state-in-effect -- sessionStorage is only readable after mount
setRound(matches ? stored : null);
}, [userId, roundApiaryId, colonyId]);
const { data: overview } = useColonyOverview(roundApiaryId ?? "", round !== null);
const stillInApiary = useMemo(
() => (overview ? new Set(overview.rows.map((row) => row.id)) : undefined),
[overview],
);
const markDone = useCallback(
(doneColonyId: string) => {
const updated = userId ? markColonyDone(userId, doneColonyId) : null;
if (updated) setRound(updated);
return updated;
},
[userId],
);
const next = useCallback(
(current: StandRound) => nextRoundColony(current, stillInApiary),
[stillInApiary],
);
const end = useCallback(() => {
if (userId) endRound(userId);
setRound(null);
}, [userId]);
return { round, markDone, next, end };
}
@@ -6,6 +6,7 @@ export interface SiteSettings {
websiteUrl: string | null;
holderName: string | null;
holderAddress: string | null;
originCountry: string;
updatedAt: string;
}
@@ -16,4 +17,5 @@ export interface UpdateSiteSettingsInput {
websiteUrl?: string;
holderName?: string;
holderAddress?: string;
originCountry?: string;
}
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { apiFetch } from "@/lib/auth/api-client";
import { fetchWithdrawalStatus } from "./api";
import { fetchTreatmentPrefill, fetchWithdrawalStatus } from "./api";
vi.mock("@/lib/auth/api-client", () => ({ apiFetch: vi.fn(async () => []) }));
@@ -35,3 +35,22 @@ describe("fetchWithdrawalStatus", () => {
expect(apiFetch).not.toHaveBeenCalled();
});
});
describe("fetchTreatmentPrefill", () => {
beforeEach(() => vi.mocked(apiFetch).mockReset());
it("unwraps the treatment and encodes the product", async () => {
vi.mocked(apiFetch).mockResolvedValue({ treatment: { product: "A&B" } } as never);
const result = await fetchTreatmentPrefill("A&B c");
expect(apiFetch).toHaveBeenCalledWith("/treatments/last-by-product?product=A%26B+c");
expect(result).toEqual({ product: "A&B" });
});
it("returns null when there is no earlier treatment", async () => {
vi.mocked(apiFetch).mockResolvedValue({ treatment: null } as never);
expect(await fetchTreatmentPrefill("Neu")).toBeNull();
});
});
@@ -3,6 +3,7 @@ import { CreateOptions, idempotencyHeaders } from "@/lib/offline/idempotency";
import {
CreateTreatmentInput,
Treatment,
TreatmentPrefill,
UpdateTreatmentInput,
WithdrawalStatus,
} from "./types";
@@ -48,3 +49,12 @@ export function updateTreatment(
body: JSON.stringify(input),
});
}
/** The caller's own last treatment of `product`, or null when there is none. */
export async function fetchTreatmentPrefill(product: string): Promise<TreatmentPrefill | null> {
const params = new URLSearchParams({ product });
const response = await apiFetch<{ treatment: TreatmentPrefill | null }>(
`/treatments/last-by-product?${params.toString()}`,
);
return response.treatment;
}
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { initialTreatmentValues } from "@/app/(app)/treatments/new/treatment-form-values";
import { applyPrefill, formatPrefillDate } from "./prefill";
import type { TreatmentPrefill } from "./types";
const prefill: TreatmentPrefill = {
sourceTreatmentId: "t-1",
sourceDate: "2026-08-12T00:00:00.000Z",
product: "Oxuvar",
activeIngredient: "Oxalsäure",
withdrawalPeriodDays: 0,
supplier: "Imkereibedarf Muster",
purchaseProof: "Rechnung 4711",
prescribingVet: "Dr. Biene",
};
describe("applyPrefill", () => {
it("fills empty fields only", () => {
const values = { ...initialTreatmentValues("c-1"), activeIngredient: "Ameisensäure" };
const result = applyPrefill(values, prefill);
expect(result.values.activeIngredient).toBe("Ameisensäure");
expect(result.values.supplier).toBe("Imkereibedarf Muster");
expect(result.filled).not.toContain("activeIngredient");
expect(result.filled).toEqual([
"withdrawalPeriodDays",
"supplier",
"purchaseProof",
"prescribingVet",
]);
expect(values.supplier).toBe("");
});
it('fills withdrawalPeriodDays 0 as "0"', () => {
const result = applyPrefill(initialTreatmentValues("c-1"), prefill);
expect(result.values.withdrawalPeriodDays).toBe("0");
expect(result.filled).toContain("withdrawalPeriodDays");
});
it("does not fill null values", () => {
const result = applyPrefill(initialTreatmentValues("c-1"), {
...prefill,
activeIngredient: null,
withdrawalPeriodDays: null,
supplier: null,
});
expect(result.values.activeIngredient).toBe("");
expect(result.values.withdrawalPeriodDays).toBe("");
expect(result.filled).toEqual(["purchaseProof", "prescribingVet"]);
});
it("treats whitespace-only values as empty", () => {
const values = { ...initialTreatmentValues("c-1"), supplier: " " };
expect(applyPrefill(values, prefill).filled).toContain("supplier");
});
});
describe("formatPrefillDate", () => {
it("writes the calendar day as TT.MM.JJJJ without a time zone shift", () => {
expect(formatPrefillDate("2026-08-12T00:00:00.000Z")).toBe("12.08.2026");
});
});
@@ -0,0 +1,44 @@
import { toCalendarDay } from "@/lib/dates";
import type { TreatmentPrefill } from "./types";
/** The fields copied from the beekeeper's own last treatment of a product. */
export const PREFILL_FIELDS = [
"activeIngredient",
"withdrawalPeriodDays",
"supplier",
"purchaseProof",
"prescribingVet",
] as const;
export type PrefillField = (typeof PREFILL_FIELDS)[number];
/** What a form must hold for the prefill: its fields as text, plus the typed product. */
export type PrefillValues = Record<PrefillField | "product", string>;
/**
* Copies the last values into the fields that are still empty. Nothing the
* user typed is overwritten, null stays empty, and a withdrawal period of 0
* becomes "0". Fields in `skip` (touched by the user, even if cleared) are left
* alone. The lot number is never part of it: it belongs to the pack.
*/
export function applyPrefill<T extends PrefillValues>(
values: T,
prefill: TreatmentPrefill,
skip: readonly PrefillField[] = [],
): { values: T; filled: PrefillField[] } {
const next = { ...values };
const filled: PrefillField[] = [];
for (const field of PREFILL_FIELDS) {
const source = prefill[field];
if (skip.includes(field) || source === null || values[field].trim() !== "") continue;
next[field] = String(source) as T[PrefillField];
filled.push(field);
}
return { values: next, filled };
}
/** "2026-08-12T…" as "12.08.2026", read from the stored day as written. */
export function formatPrefillDate(isoTimestamp: string): string {
const [year, month, day] = toCalendarDay(isoTimestamp).split("-");
return `${day}.${month}.${year}`;
}
@@ -63,3 +63,15 @@ export interface WithdrawalStatus {
activeUntil: string;
product: string;
}
/** The beekeeper's own last values for a product, offered as an editable prefill. */
export interface TreatmentPrefill {
sourceTreatmentId: string;
sourceDate: string;
product: string;
activeIngredient: string | null;
withdrawalPeriodDays: number | null;
supplier: string | null;
purchaseProof: string | null;
prescribingVet: string | null;
}
@@ -0,0 +1,84 @@
"use client";
import { Dispatch, SetStateAction, useState } from "react";
import {
PREFILL_FIELDS,
PrefillField,
PrefillValues,
applyPrefill,
formatPrefillDate,
} from "./prefill";
import type { TreatmentPrefill } from "./types";
import { useTreatmentPrefill } from "./use-treatment-prefill";
/** Fields filled from the last treatment and not touched since, with "Produkt vom TT.MM.JJJJ" of that treatment. */
export type Prefilled = Partial<Record<PrefillField, string>>;
function isPrefillField(key: string): key is PrefillField {
return (PREFILL_FIELDS as readonly string[]).includes(key);
}
function withoutPrefilled<T extends PrefillValues>(values: T, prefilled: Prefilled): T {
const next = { ...values };
for (const field of Object.keys(prefilled) as PrefillField[]) {
next[field] = "" as T[PrefillField];
}
return next;
}
interface PrefilledFields {
prefilled: Prefilled;
/** Call for every edit: the field stops being a suggestion and is never refilled. */
markEdited: (key: string) => void;
/** Empties the fields that are still untouched suggestions. */
undo: () => void;
/** Forgets all markers, e.g. after a draft replaced the values. */
reset: () => void;
}
/**
* Prefills the form from the beekeeper's last treatment of the typed product.
* A new settled answer replaces earlier untouched suggestions; anything the
* user typed or cleared stays as it is.
*/
export function usePrefilledFields<T extends PrefillValues>(
values: T,
setValues: Dispatch<SetStateAction<T>>,
): PrefilledFields {
const [prefilled, setPrefilled] = useState<Prefilled>({});
const [touched, setTouched] = useState<readonly PrefillField[]>([]);
const prefill = useTreatmentPrefill(values.product);
const [applied, setApplied] = useState<TreatmentPrefill | null>(null);
if (prefill !== applied) {
setApplied(prefill);
const base = withoutPrefilled(values, prefilled);
if (prefill) {
const result = applyPrefill(base, prefill, touched);
const source = `${prefill.product} vom ${formatPrefillDate(prefill.sourceDate)}`;
setValues(result.values);
setPrefilled(Object.fromEntries(result.filled.map((field) => [field, source])));
} else {
setValues(base);
setPrefilled({});
}
}
return {
prefilled,
markEdited: (key) => {
if (!isPrefillField(key)) return;
setTouched((current) => (current.includes(key) ? current : [...current, key]));
setPrefilled((current) => {
const rest = { ...current };
delete rest[key];
return rest;
});
},
undo: () => {
setValues((current) => withoutPrefilled(current, prefilled));
setPrefilled({});
},
reset: () => setPrefilled({}),
};
}
@@ -0,0 +1,54 @@
"use client";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useConnectivity } from "@/lib/offline/connectivity";
import { fetchTreatmentPrefill } from "./api";
import type { TreatmentPrefill } from "./types";
const DEBOUNCE_MS = 500;
const MIN_PRODUCT_LENGTH = 2;
const MAX_PRODUCT_LENGTH = 200;
/**
* The beekeeper's own last values for the product typed so far. Waits until
* typing pauses, needs at least two characters and asks only while online -
* a prefill is a convenience, never a reason to block or queue anything.
* While a request for the same product is pending or the device is offline,
* the last answer for that product stays; for any other product it is dropped.
*/
export function useTreatmentPrefill(product: string): TreatmentPrefill | null {
const trimmed = product.trim();
const [debounced, setDebounced] = useState("");
const connectivity = useConnectivity();
useEffect(() => {
const timer = setTimeout(() => setDebounced(trimmed), DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [trimmed]);
const isSettled = debounced === trimmed;
const isAskable =
trimmed.length >= MIN_PRODUCT_LENGTH && trimmed.length <= MAX_PRODUCT_LENGTH;
const enabled = connectivity === "online" && isSettled && isAskable;
const { data } = useQuery({
queryKey: ["treatments", "prefill", debounced],
queryFn: () => fetchTreatmentPrefill(debounced),
enabled,
});
// The last settled answer is kept only for the product it was fetched for
// (pending request, offline). Once the product differs it is dropped at
// once, so a failed request can never leave another product's values.
const [held, setHeld] = useState<{ product: string; prefill: TreatmentPrefill | null } | null>(
null,
);
let settled: TreatmentPrefill | null | undefined;
if (isSettled && !isAskable) settled = null;
else if (enabled) settled = data;
if (settled !== undefined && (held?.product !== trimmed || held.prefill !== settled)) {
setHeld({ product: trimmed, prefill: settled });
}
if (settled !== undefined) return settled;
return held?.product === trimmed ? held.prefill : null;
}
+4
View File
@@ -30,3 +30,7 @@ export function updateVarroaMeasurement(
body: JSON.stringify(input),
});
}
export function fetchVarroaThreshold(): Promise<{ thresholdMitesPerDay: number | null }> {
return apiFetch<{ thresholdMitesPerDay: number | null }>("/varroa-measurements/threshold");
}
+15
View File
@@ -0,0 +1,15 @@
const ONE_DECIMAL = new Intl.NumberFormat("de-DE", {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
const THRESHOLD_FORMAT = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 1 });
/** "10,0" */
export function formatMitesPerDay(rate: number): string {
return ONE_DECIMAL.format(rate);
}
/** "10" or "7,5" */
export function formatThreshold(threshold: number): string {
return THRESHOLD_FORMAT.format(threshold);
}
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { daysToMinutes, formatDays, periodChoiceOf } from "./period";
describe("varroa period", () => {
it("converts days to minutes", () => {
expect(daysToMinutes(3)).toBe(4320);
expect(daysToMinutes(1.5)).toBe(2160);
});
it("maps stored minutes to a chip", () => {
expect(periodChoiceOf("4320")).toBe(3);
expect(periodChoiceOf("1440")).toBe(1);
expect(periodChoiceOf("2000")).toBe("other");
expect(periodChoiceOf("")).toBeNull();
});
it("formats days the German way", () => {
expect(formatDays(2160)).toBe("1,5 Tage");
expect(formatDays(1440)).toBe("1 Tag");
expect(formatDays(2000)).toBe("1,39 Tage");
});
});
+38
View File
@@ -0,0 +1,38 @@
export const MINUTES_PER_DAY = 1440;
export const PERIOD_DAY_CHOICES = [1, 2, 3, 7] as const;
export type PeriodChoice = (typeof PERIOD_DAY_CHOICES)[number] | "other";
export function daysToMinutes(days: number): number {
return Math.round(days * MINUTES_PER_DAY);
}
/** Which chip a stored minutes value belongs to; `null` while empty. */
export function periodChoiceOf(minutes: string): PeriodChoice | null {
const trimmed = minutes.trim();
if (trimmed === "") return null;
const value = Number(trimmed);
if (!Number.isFinite(value)) return "other";
const match = PERIOD_DAY_CHOICES.find((days) => daysToMinutes(days) === value);
return match ?? "other";
}
const DAY_FORMAT = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 2 });
/** Number of days as typed in the "Tage" field, e.g. "1,39". */
export function formatDayCount(minutes: number): string {
return DAY_FORMAT.format(minutes / MINUTES_PER_DAY);
}
export function formatDays(minutes: number): string {
const days = minutes / MINUTES_PER_DAY;
return `${DAY_FORMAT.format(days)} ${days === 1 ? "Tag" : "Tage"}`;
}
/** "1,5" or "1.5" as days; `null` for anything that is not a positive number. */
export function parseDays(text: string): number | null {
const normalized = text.trim().replace(",", ".");
if (normalized === "") return null;
const value = Number(normalized);
return Number.isFinite(value) && value > 0 ? value : null;
}
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { mitesPerDay, varroaLight } from "./rate";
describe("varroa rate", () => {
it("computes mites per day like the backend", () => {
expect(mitesPerDay(30, 4320)).toBe(10);
expect(mitesPerDay(30, null)).toBeNull();
expect(mitesPerDay(30, 0)).toBeNull();
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();
});
});
+24
View File
@@ -0,0 +1,24 @@
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. */
export function mitesPerDay(
miteCount: number | null,
durationMinutes: number | null,
): number | null {
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";
}
@@ -0,0 +1,14 @@
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;
}
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import { describeWeatherCode, weatherPrefillFor } from "./describe";
import type { DailyWeather } from "./types";
function day(overrides: Partial<DailyWeather> = {}): DailyWeather {
return {
date: "2026-10-13",
tempMaxC: 21.54,
tempMinC: 9,
precipitationMm: 0.4,
windSpeedMaxKmh: 12,
inspectionSuitable: true,
weatherCode: 2,
...overrides,
};
}
describe("describeWeatherCode", () => {
it.each([
[0, "Sonnig"],
[1, "Überwiegend sonnig"],
[2, "Teilweise bewölkt"],
[3, "Bedeckt"],
[45, "Nebel"],
[48, "Nebel"],
[53, "Nieselregen"],
[61, "Regen"],
[67, "Regen"],
[75, "Schnee"],
[81, "Regenschauer"],
[86, "Schneeschauer"],
[95, "Gewitter"],
[99, "Gewitter"],
])("describes code %i as %s", (code, text) => {
expect(describeWeatherCode(code)).toBe(text);
});
it("returns null for unknown, missing or null codes", () => {
expect(describeWeatherCode(4)).toBeNull();
expect(describeWeatherCode(null)).toBeNull();
expect(describeWeatherCode(undefined)).toBeNull();
});
});
describe("weatherPrefillFor", () => {
it("builds text and temperature for the chosen date", () => {
const prefill = weatherPrefillFor([day({ date: "2026-10-12" }), day()], "2026-10-13");
expect(prefill).toEqual({
weather: "Teilweise bewölkt, 0.4 mm, Wind 12 km/h",
temperature: "21.5",
});
});
it("leaves out parts that are null", () => {
const prefill = weatherPrefillFor(
[day({ weatherCode: null, precipitationMm: null })],
"2026-10-13",
);
expect(prefill).toEqual({ weather: "Wind 12 km/h", temperature: "21.5" });
});
it("returns null outside the weather window", () => {
expect(weatherPrefillFor([day()], "2026-09-01")).toBeNull();
expect(weatherPrefillFor([], "2026-10-13")).toBeNull();
});
it("returns null when the day holds nothing usable", () => {
const empty = day({
weatherCode: null,
precipitationMm: null,
windSpeedMaxKmh: null,
tempMaxC: null,
});
expect(weatherPrefillFor([empty], "2026-10-13")).toBeNull();
});
});
@@ -0,0 +1,55 @@
import { toCalendarDay } from "@/lib/dates";
import type { DailyWeather } from "./types";
/** WMO weather codes in plain German, as the beekeeper would say it. */
const WEATHER_CODE_RANGES: readonly { from: number; to: number; text: string }[] = [
{ from: 0, to: 0, text: "Sonnig" },
{ from: 1, to: 1, text: "Überwiegend sonnig" },
{ from: 2, to: 2, text: "Teilweise bewölkt" },
{ from: 3, to: 3, text: "Bedeckt" },
{ from: 45, to: 48, text: "Nebel" },
{ from: 51, to: 57, text: "Nieselregen" },
{ from: 61, to: 67, text: "Regen" },
{ from: 71, to: 77, text: "Schnee" },
{ from: 80, to: 82, text: "Regenschauer" },
{ from: 85, to: 86, text: "Schneeschauer" },
{ from: 95, to: 99, text: "Gewitter" },
];
/** `null` for a missing or unknown code: nothing is guessed. */
export function describeWeatherCode(code: number | null | undefined): string | null {
if (code === null || code === undefined) return null;
const match = WEATHER_CODE_RANGES.find((range) => code >= range.from && code <= range.to);
return match?.text ?? null;
}
export interface WeatherPrefill {
weather: string;
temperature: string;
}
/**
* The inspection's weather fields for one day of the apiary's weather data:
* the description with precipitation and wind (parts that are unknown are
* left out) and the day's maximum temperature with one decimal. `null`
* outside the forecast window or when the day holds nothing usable.
*/
export function weatherPrefillFor(
daily: readonly DailyWeather[],
isoDate: string,
): WeatherPrefill | null {
const day = daily.find((entry) => toCalendarDay(entry.date) === isoDate);
if (!day) return null;
const parts = [
describeWeatherCode(day.weatherCode),
day.precipitationMm != null ? `${day.precipitationMm} mm` : null,
day.windSpeedMaxKmh != null ? `Wind ${day.windSpeedMaxKmh} km/h` : null,
].filter((part): part is string => part !== null);
const weather = parts.join(", ");
const temperature = day.tempMaxC != null ? day.tempMaxC.toFixed(1) : "";
if (weather === "" && temperature === "") return null;
return { weather, temperature };
}
@@ -5,6 +5,8 @@ export interface DailyWeather {
precipitationMm: number | null;
windSpeedMaxKmh: number | null;
inspectionSuitable: boolean | null;
/** WMO weather code; null until the backend delivers it. */
weatherCode: number | null;
}
export interface ApiaryWeather {