feat: platform backbone and field capture, first part (checkpoint)
Platform backbone (plan 2026-10-13-plattform-rueckgrat), reviewed tasks: - run roles api/worker/job/all with shared startup checks (Task 1) - schema and migration for DomainEvent, deliveries, JobRun, ThrottlerHit (Task 2) - audit, notifications and domain events written in the same transaction (Task 3) - dispatcher with SKIP LOCKED claims, retries and stale release (Task 4) - worker building blocks: tick, LISTEN/NOTIFY wake-up, health (Task 5) - worker process and entry points (Task 6) - throttler counters in Postgres (Task 19) - separate keys per purpose with key ring (Task 20) - public origin page without exact location (Task 22) - shop keys split into read and write (Task 23) - Kustomize base, components, overlay encry, CI validation (Task 26) Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), reviewed tasks: - brood frames, read endpoints, apiary overview and offline pack - network basics, offline queue v3, navigation sheets, form building blocks - new inspection with last-time hints and weather, varroa per day, treatment prefill, apiary page with round - inspection photo (Task 10) is implemented, review pending Not rolled out. Remaining tasks follow in later commits. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
10cbe9bf2d
commit
c01a38835a
340 files changed
+25695
-1808
No files matched your search
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hasCombinedShopScopes } from "./scopes";
|
||||
|
||||
describe("hasCombinedShopScopes", () => {
|
||||
it("is true for a key that may read and book", () => {
|
||||
expect(hasCombinedShopScopes({ scopes: ["shop-sync:read", "shop-sync:write"] })).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for single-scope keys", () => {
|
||||
expect(hasCombinedShopScopes({ scopes: ["shop-sync:read"] })).toBe(false);
|
||||
expect(hasCombinedShopScopes({ scopes: ["shop-sync:write"] })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ApiKey } from "./types";
|
||||
|
||||
export type ApiKeyScope = "shop-sync:read" | "shop-sync:write";
|
||||
|
||||
export const API_KEY_SCOPE_LABELS: Record<ApiKeyScope, string> = {
|
||||
"shop-sync:read": "Shop-Daten lesen (Wabenhain)",
|
||||
"shop-sync:write": "Verkäufe und Rückbuchungen buchen (Wabenhain)",
|
||||
};
|
||||
|
||||
/**
|
||||
* Keys issued before reading and booking were split may still carry both;
|
||||
* the server no longer creates such keys, the UI nudges towards two keys.
|
||||
*/
|
||||
export function hasCombinedShopScopes(apiKey: Pick<ApiKey, "scopes">): boolean {
|
||||
return apiKey.scopes.includes("shop-sync:read") && apiKey.scopes.includes("shop-sync:write");
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
export type ApiKeyScope = "shop-sync:read";
|
||||
import type { ApiKeyScope } from "./scopes";
|
||||
|
||||
export const API_KEY_SCOPE_LABELS: Record<ApiKeyScope, string> = {
|
||||
"shop-sync:read": "Shop-Daten lesen (Wabenhain)",
|
||||
};
|
||||
export type { ApiKeyScope };
|
||||
|
||||
export interface ApiKey {
|
||||
id: string;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { apiFetch } from "@/lib/auth/api-client";
|
||||
import type { ColonyOverview } from "./field-types";
|
||||
|
||||
export function fetchColonyOverview(apiaryId: string): Promise<ColonyOverview> {
|
||||
return apiFetch<ColonyOverview>(`/apiaries/${encodeURIComponent(apiaryId)}/colony-overview`);
|
||||
}
|
||||
|
||||
/** Query key of the apiary page's colony overview. */
|
||||
export function colonyOverviewKey(apiaryId: string): readonly ["apiaries", string, "colony-overview"] {
|
||||
return ["apiaries", apiaryId, "colony-overview"] as const;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { ApiaryStatus } from "./types";
|
||||
import type { Colony, ColonyCondition, ColonyStatus } from "@/lib/colonies/types";
|
||||
import type { Inspection } from "@/lib/inspections/types";
|
||||
import type { Queen } from "@/lib/queens/types";
|
||||
import type { Task } from "@/lib/tasks/types";
|
||||
|
||||
// Mirrors ApiCore-Backend/src/apiaries/apiary-field.types.ts - keep both in step.
|
||||
|
||||
/** One living colony on the apiary page, reduced to what the traffic light needs. */
|
||||
export interface ColonyOverviewRow {
|
||||
id: string;
|
||||
code: string;
|
||||
status: ColonyStatus;
|
||||
strength: number | null;
|
||||
broodStatus: ColonyCondition | null;
|
||||
feedStatus: ColonyCondition | null;
|
||||
healthStatus: ColonyCondition | null;
|
||||
/** ISO timestamp; always null when the caller may not read inspections. */
|
||||
lastInspectionDate: string | null;
|
||||
/** Unread WARNUNG notifications visible to the caller. */
|
||||
openWarnings: number;
|
||||
/** Unread KRITISCH notifications visible to the caller. */
|
||||
openCritical: number;
|
||||
}
|
||||
|
||||
export interface ColonyOverview {
|
||||
apiaryId: string;
|
||||
inspectionsVisible: boolean;
|
||||
rows: ColonyOverviewRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the offline apiary mode needs. It deliberately carries no
|
||||
* coordinates, address or apiary notes (apiary locations are theft-sensitive)
|
||||
* and no inspection userId. Sections the caller has no read permission for
|
||||
* are null.
|
||||
*/
|
||||
export interface OfflinePackV1 {
|
||||
version: 1;
|
||||
generatedAt: string;
|
||||
apiary: {
|
||||
id: string;
|
||||
name: string;
|
||||
location: string | null;
|
||||
status: ApiaryStatus;
|
||||
};
|
||||
overview: ColonyOverview;
|
||||
colonies: Pick<
|
||||
Colony,
|
||||
| "id"
|
||||
| "code"
|
||||
| "apiaryId"
|
||||
| "status"
|
||||
| "colonyType"
|
||||
| "hiveType"
|
||||
| "strength"
|
||||
| "broodStatus"
|
||||
| "feedStatus"
|
||||
| "healthStatus"
|
||||
| "currentQueenId"
|
||||
| "notes"
|
||||
>[];
|
||||
queens:
|
||||
| Pick<
|
||||
Queen,
|
||||
"id" | "code" | "hatchYear" | "breed" | "markingColor" | "markingNumber" | "status"
|
||||
>[]
|
||||
| null;
|
||||
inspections: Omit<Inspection, "userId">[] | null;
|
||||
tasks:
|
||||
| Pick<
|
||||
Task,
|
||||
| "id"
|
||||
| "title"
|
||||
| "description"
|
||||
| "dueDate"
|
||||
| "priority"
|
||||
| "status"
|
||||
| "relatedType"
|
||||
| "relatedId"
|
||||
>[]
|
||||
| null;
|
||||
}
|
||||
@@ -10,6 +10,8 @@ export interface Apiary {
|
||||
address: string | null;
|
||||
status: ApiaryStatus;
|
||||
notes: string | null;
|
||||
/** Shown publicly on the origin page instead of the exact location. */
|
||||
publicLocationLabel: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deactivatedAt: string | null;
|
||||
@@ -24,6 +26,7 @@ export interface ApiaryInput {
|
||||
longitude?: number | null;
|
||||
address?: string | null;
|
||||
notes?: string | null;
|
||||
publicLocationLabel?: string | null;
|
||||
}
|
||||
|
||||
/** `null` clears an optional field, an omitted one stays unchanged. */
|
||||
@@ -35,4 +38,5 @@ export interface UpdateApiaryInput {
|
||||
longitude?: number | null;
|
||||
address?: string | null;
|
||||
notes?: string | null;
|
||||
publicLocationLabel?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { colonyOverviewKey, fetchColonyOverview } from "./field-api";
|
||||
|
||||
/** The apiary's living colonies with their traffic-light inputs. */
|
||||
export function useColonyOverview(apiaryId: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: colonyOverviewKey(apiaryId),
|
||||
queryFn: () => fetchColonyOverview(apiaryId),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getConnectivity, reportRequestOutcome } from "@/lib/offline/connectivity";
|
||||
import {
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
DOWNLOAD_TIMEOUT_MS,
|
||||
NON_IDEMPOTENT_WRITE_TIMEOUT_MS,
|
||||
UPLOAD_TIMEOUT_MS,
|
||||
} from "@/lib/http/fetch-with-timeout";
|
||||
import { expectTimeoutAt, stubHangingFetch } from "@/lib/http/deadline-test-helper";
|
||||
import { setAccessToken } from "./token-store";
|
||||
import { ApiError, apiFetch, apiFetchBlob, apiFetchFormData } from "./api-client";
|
||||
|
||||
vi.mock("../config/runtime-config", () => ({
|
||||
getApiUrl: vi.fn().mockResolvedValue("http://api.test"),
|
||||
}));
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("api-client", () => {
|
||||
beforeEach(() => {
|
||||
setAccessToken(null);
|
||||
reportRequestOutcome("reached-server");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("ApiError carries the parsed JSON body", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(jsonResponse(400, { message: "Ungültig", invalidColonyIds: ["c1"] })),
|
||||
);
|
||||
const error = await apiFetch("/x").catch((e: unknown) => e);
|
||||
expect(error).toBeInstanceOf(ApiError);
|
||||
const apiError = error as ApiError;
|
||||
expect(apiError.status).toBe(400);
|
||||
expect(apiError.message).toBe("Ungültig");
|
||||
expect((apiError.body as { invalidColonyIds: string[] }).invalidColonyIds).toEqual(["c1"]);
|
||||
});
|
||||
|
||||
it("reports network-error on a failed fetch and reached-server on any HTTP answer", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch")));
|
||||
await expect(apiFetch("/x")).rejects.toBeInstanceOf(TypeError);
|
||||
expect(getConnectivity()).toBe("offline");
|
||||
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(500, { message: "boom" })));
|
||||
await expect(apiFetch("/x")).rejects.toBeInstanceOf(ApiError);
|
||||
expect(getConnectivity()).toBe("online");
|
||||
});
|
||||
|
||||
describe("timeouts", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
stubHangingFetch();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("apiFetch: GET and keyed writes 4 s, unkeyed writes 15 s", async () => {
|
||||
await expectTimeoutAt(() => apiFetch("/x"), DEFAULT_TIMEOUT_MS);
|
||||
await expectTimeoutAt(
|
||||
() => apiFetch("/x", { method: "POST", headers: { "Idempotency-Key": "k" } }),
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
);
|
||||
await expectTimeoutAt(() => apiFetch("/x", { method: "POST" }), NON_IDEMPOTENT_WRITE_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
it("apiFetchFormData uses 30 s by default and honours timeoutMs", async () => {
|
||||
await expectTimeoutAt(() => apiFetchFormData("/x", new FormData()), UPLOAD_TIMEOUT_MS);
|
||||
await expectTimeoutAt(
|
||||
() => apiFetchFormData("/x", new FormData(), { timeoutMs: 5_000 }),
|
||||
5_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("apiFetchFormData passes options.headers through", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(200, {}));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await apiFetchFormData("/x", new FormData(), { headers: { "Idempotency-Key": "k9" } });
|
||||
const init = fetchMock.mock.calls[0][1] as RequestInit;
|
||||
expect(new Headers(init.headers).get("Idempotency-Key")).toBe("k9");
|
||||
});
|
||||
|
||||
it("apiFetchBlob uses 60 s", async () => {
|
||||
await expectTimeoutAt(() => apiFetchBlob("/x", "f.csv"), DOWNLOAD_TIMEOUT_MS);
|
||||
});
|
||||
});
|
||||
|
||||
it("apiFetchBlob builds an ApiError with the parsed body", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(403, { message: "Nein", why: 1 })));
|
||||
const error = (await apiFetchBlob("/x", "f.csv").catch((e: unknown) => e)) as ApiError;
|
||||
expect(error).toBeInstanceOf(ApiError);
|
||||
expect(error.status).toBe(403);
|
||||
expect(error.body).toEqual({ message: "Nein", why: 1 });
|
||||
});
|
||||
});
|
||||
@@ -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