Merge main checkpoint c01a388 into feat/erp-inventar-export
Brings the ERP inventory export up to date with the platform backbone and field capture work. Conflicts in ARCHITECTURE.md, the API key service and the API key settings tab resolved by keeping both sides: - read and write keys stay separate; erp-inventory:read is read-only and cannot be combined with a write scope - the failed-attempt lockout counts in the shared ThrottlerHit table so it holds across API pods - the UI offers only scopes the user holds and creates one scope per key Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
commit
53fab102f2
347 files changed
+26087
-2141
No files matched your search
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { API_KEY_SCOPES, apiKeyScopeLabel, hasReadAndWriteScopes } from "./scopes";
|
||||
|
||||
describe("hasReadAndWriteScopes", () => {
|
||||
it("is true for a key that may read and book", () => {
|
||||
expect(hasReadAndWriteScopes({ scopes: ["shop-sync:read", "shop-sync:write"] })).toBe(true);
|
||||
});
|
||||
|
||||
it("treats the ERP inventory scope as reading", () => {
|
||||
expect(hasReadAndWriteScopes({ scopes: ["erp-inventory:read", "shop-sync:write"] })).toBe(true);
|
||||
expect(hasReadAndWriteScopes({ scopes: ["erp-inventory:read", "shop-sync:read"] })).toBe(false);
|
||||
});
|
||||
|
||||
it("is false for single-scope keys", () => {
|
||||
expect(hasReadAndWriteScopes({ scopes: ["shop-sync:read"] })).toBe(false);
|
||||
expect(hasReadAndWriteScopes({ scopes: ["shop-sync:write"] })).toBe(false);
|
||||
expect(hasReadAndWriteScopes({ scopes: ["erp-inventory:read"] })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("API key scopes", () => {
|
||||
it("match the backend allowlist", () => {
|
||||
expect(API_KEY_SCOPES).toEqual(["shop-sync:read", "shop-sync:write", "erp-inventory:read"]);
|
||||
});
|
||||
|
||||
it("label known scopes and pass unknown ones through", () => {
|
||||
expect(apiKeyScopeLabel("erp-inventory:read")).toBe("Inventar-Spiegel lesen (ERPNext)");
|
||||
expect(apiKeyScopeLabel("future:scope")).toBe("future:scope");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ApiKey } from "./types";
|
||||
|
||||
/** Berechtigungen, die ein API-Schlüssel tragen kann - wie API_KEY_SCOPES im Backend. */
|
||||
export const API_KEY_SCOPES = ["shop-sync:read", "shop-sync:write", "erp-inventory:read"] as const;
|
||||
|
||||
export type ApiKeyScope = (typeof API_KEY_SCOPES)[number];
|
||||
|
||||
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)",
|
||||
"erp-inventory:read": "Inventar-Spiegel lesen (ERPNext)",
|
||||
};
|
||||
|
||||
/** Buchende Rechte - wie API_KEY_WRITE_SCOPES im Backend; alle anderen lesen nur. */
|
||||
const WRITE_SCOPES: readonly string[] = ["shop-sync:write"];
|
||||
|
||||
/** Label für die Anzeige; unbekannte Rechte (neueres Backend) unverändert. */
|
||||
export function apiKeyScopeLabel(scope: string): string {
|
||||
return (API_KEY_SCOPE_LABELS as Record<string, string>)[scope] ?? scope;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* The ERP inventory scope counts as reading.
|
||||
*/
|
||||
export function hasReadAndWriteScopes(apiKey: Pick<ApiKey, "scopes">): boolean {
|
||||
const isWrite = (scope: string) => WRITE_SCOPES.includes(scope);
|
||||
return apiKey.scopes.some(isWrite) && apiKey.scopes.some((scope) => !isWrite(scope));
|
||||
}
|
||||
@@ -1,18 +1,6 @@
|
||||
/** Berechtigungen, die ein API-Schlüssel tragen kann - wie API_KEY_SCOPES im Backend. */
|
||||
export const API_KEY_SCOPES = ["shop-sync:read", "shop-sync:write", "erp-inventory:read"] as const;
|
||||
import type { ApiKeyScope } from "./scopes";
|
||||
|
||||
export type ApiKeyScope = (typeof API_KEY_SCOPES)[number];
|
||||
|
||||
export const API_KEY_SCOPE_LABELS: Record<ApiKeyScope, string> = {
|
||||
"shop-sync:read": "Shop-Daten lesen (Wabenhain)",
|
||||
"shop-sync:write": "Verkäufe und Rückbuchungen des Shops buchen (Wabenhain)",
|
||||
"erp-inventory:read": "Inventar-Spiegel lesen (ERPNext)",
|
||||
};
|
||||
|
||||
/** Label für die Anzeige; unbekannte Rechte (neueres Backend) unverändert. */
|
||||
export function apiKeyScopeLabel(scope: string): string {
|
||||
return (API_KEY_SCOPE_LABELS as Record<string, string>)[scope] ?? scope;
|
||||
}
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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")}`;
|
||||
}
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user